diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java index 502dc274..753e5f91 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/ActionCore.java @@ -31,6 +31,7 @@ import java.util.stream.Collectors; @Slf4j public class ActionCore extends PartnerCore { public static final String BUILTIN_LOCATION = "builtin"; + public static final String ORIGIN_LOCATION = "origin"; private final Lock cacheLock = new ReentrantLock(); // 由于当前的执行器逻辑实现,平台线程池大小不得小于 2,这里规定为最小为 4 @@ -322,11 +323,15 @@ public class ActionCore extends PartnerCore { throw new MetaActionNotFoundException("未找到对应的行动程序信息" + actionKey); } - String[] split = actionKey.split("::"); + String[] split = actionKey.split("::", 2); if (split.length < 2) { throw new MetaActionNotFoundException("未找到对应的行动程序,原因: 传入的 actionKey(" + actionKey + ") 存在异常"); } - MetaAction.Type type = BUILTIN_LOCATION.equals(split[0]) ? MetaAction.Type.BUILTIN : MetaAction.Type.MCP; + MetaAction.Type type = switch (split[0]) { + case BUILTIN_LOCATION -> MetaAction.Type.BUILTIN; + case ORIGIN_LOCATION -> MetaAction.Type.ORIGIN; + default -> MetaAction.Type.MCP; + }; return new MetaAction( split[1], metaActionInfo.getIo(), diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/execution/OriginExecutionService.java b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/execution/OriginExecutionService.java index 984c369b..eba315e2 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/execution/OriginExecutionService.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/execution/OriginExecutionService.java @@ -10,6 +10,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import static work.slhaf.partner.core.action.ActionCore.ORIGIN_LOCATION; + public class OriginExecutionService { public OriginExecutionService() { @@ -17,7 +19,7 @@ public class OriginExecutionService { public RunnerClient.RunnerResponse run(MetaAction metaAction) { RunnerClient.RunnerResponse response = new RunnerClient.RunnerResponse(); - File file = new File(metaAction.getLocation()); + File file = new File(resolveOriginPath(metaAction)); String[] commands = CommandExecutionService.INSTANCE.buildFileExecutionCommands(metaAction.getLauncher(), metaAction.getParams(), file.getAbsolutePath()); WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(Arrays.stream(commands).toList()); List wrappedCommands = new ArrayList<>(); @@ -28,4 +30,11 @@ public class OriginExecutionService { response.setData(execResult.getTotal()); return response; } + + private String resolveOriginPath(MetaAction metaAction) { + if (ORIGIN_LOCATION.equals(metaAction.getLocation())) { + return metaAction.getName(); + } + return metaAction.getLocation(); + } } diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinActionRegistry.java b/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinActionRegistry.java index d67217cf..607ba8bf 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinActionRegistry.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinActionRegistry.java @@ -38,11 +38,13 @@ public class BuiltinActionRegistry extends AbstractAgentModule.Standalone { List builtinActionDefinitions = new ArrayList<>(); BuiltinActionProvider commandActionProvider = new BuiltinCommandActionProvider(); BuiltinActionProvider capabilityActionProvider = new BuiltinCapabilityActionProvider(); - BuiltinInterventionActionProvider interventionActionProvider = new BuiltinInterventionActionProvider(); + BuiltinActionProvider interventionActionProvider = new BuiltinInterventionActionProvider(); + BuiltinActionProvider dynamicActionProvider = new BuiltinDynamicActionProvider(); builtinActionDefinitions.addAll(commandActionProvider.provideBuiltinActions()); builtinActionDefinitions.addAll(capabilityActionProvider.provideBuiltinActions()); builtinActionDefinitions.addAll(interventionActionProvider.provideBuiltinActions()); + builtinActionDefinitions.addAll(dynamicActionProvider.provideBuiltinActions()); return builtinActionDefinitions; } diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProvider.java b/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProvider.java new file mode 100644 index 00000000..d9eda858 --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProvider.java @@ -0,0 +1,253 @@ +package work.slhaf.partner.module.modules.action.builtin; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import kotlin.Unit; +import work.slhaf.partner.api.agent.factory.capability.annotation.InjectCapability; +import work.slhaf.partner.api.agent.factory.component.annotation.AgentComponent; +import work.slhaf.partner.api.agent.factory.component.annotation.InjectModule; +import work.slhaf.partner.core.action.ActionCapability; +import work.slhaf.partner.core.action.entity.*; +import work.slhaf.partner.module.modules.action.scheduler.ActionScheduler; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION; + +@AgentComponent +class BuiltinDynamicActionProvider implements BuiltinActionProvider { + private static final String ORIGIN_LOCATION = "origin"; + private static final long TEMP_ACTION_TTL_MILLIS = 30 * 60 * 1000L; + private static final String DYNAMIC_LOCATION = BUILTIN_LOCATION + "::" + "dynamic"; + private final Set basicTags = Set.of("Builtin MetaAction", "Dynamic Generation"); + private final ConcurrentHashMap tempDynamicActions = new ConcurrentHashMap<>(); + @InjectCapability + private ActionCapability actionCapability; + @InjectModule + private ActionScheduler actionScheduler; + + @Override + public List provideBuiltinActions() { + return List.of( + buildGenerateDynamicActionDefinition(), + buildPersistDynamicActionDefinition() + ); + } + + private BuiltinActionRegistry.BuiltinActionDefinition buildGenerateDynamicActionDefinition() { + MetaActionInfo info = new MetaActionInfo( + false, + null, + Map.of( + "desc", "Human-readable description for the temporary action. Required because the generated action name is only a short id.", + "code", "Dynamic action source code content.", + "codeType", "Code extension, for example py/sh/js.", + "launcher", "Interpreter or launcher used for ORIGIN execution.", + "meta", "MetaActionInfo extra fields as JSON string. Available fields example: {\"io\":true,\"params\":{\"input\":\"user input\"},\"tags\":[\"dynamic\"],\"preActions\":[\"builtin::command::execute\"],\"postActions\":[\"builtin::dynamic::persist\"],\"strictDependencies\":false,\"responseSchema\":{\"result\":\"dynamic result\"}}" + ), + "Generate a temporary ORIGIN action from source code and return a temporary actionKey.", + basicTags, + Set.of(), + Set.of(createActionKey("persist")), + false, + JSONObject.of( + "ok", "Whether the dynamic action was generated successfully.", + "actionKey", "Temporary ORIGIN actionKey." + ) + ); + return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("generate"), info, params -> { + String desc = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "desc").trim(); + if (desc.isEmpty()) { + throw new IllegalArgumentException("参数 desc 不能为空"); + } + String code = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "code"); + String codeType = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "codeType"); + String launcher = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "launcher"); + String metaJson = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "meta"); + + JSONObject meta = parseMeta(metaJson); + MetaActionInfo metaActionInfo = buildMetaActionInfo(meta, launcher, desc); + + String tempName = "dyn-" + shortUuid(); + String location = actionCapability.runnerClient().buildTmpPath(tempName, codeType); + MetaAction tempAction = new MetaAction( + tempName, + metaActionInfo.getIo(), + launcher, + MetaAction.Type.ORIGIN, + location + ); + String actionKey = ORIGIN_LOCATION + "::" + location; + try { + actionCapability.runnerClient().tmpSerialize(tempAction, code, codeType); + } catch (java.io.IOException e) { + throw new IllegalStateException("临时动态行动序列化失败", e); + } + actionCapability.registerMetaActions(Map.of(actionKey, metaActionInfo)); + + ActionFileMetaData fileMetaData = buildActionFileMetaData(location, code, codeType); + StateAction cleanupAction = buildCleanupAction(actionKey); + tempDynamicActions.put(actionKey, new TempDynamicActionRecord( + actionKey, + location, + cleanupAction.getUuid(), + fileMetaData, + metaActionInfo + )); + actionScheduler.schedule(cleanupAction); + return JSONObject.of( + "ok", true, + "actionKey", actionKey + ).toJSONString(); + }); + } + + private BuiltinActionRegistry.BuiltinActionDefinition buildPersistDynamicActionDefinition() { + MetaActionInfo info = new MetaActionInfo( + false, + null, + Map.of("actionKey", "Temporary ORIGIN actionKey returned by generate."), + "Persist a temporary ORIGIN action and cancel its cleanup task.", + basicTags, + Set.of(createActionKey("generate")), + Set.of(), + false, + JSONObject.of( + "ok", "Whether the dynamic action was persisted successfully.", + "actionKey", "Temporary ORIGIN actionKey." + ) + ); + return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("persist"), info, params -> { + String actionKey = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionKey"); + TempDynamicActionRecord record = tempDynamicActions.get(actionKey); + if (record == null) { + throw new IllegalArgumentException("未找到对应临时动态行动: " + actionKey); + } + actionCapability.runnerClient().persistSerialize(record.metaActionInfo(), record.fileMetaData()); + actionScheduler.cancel(record.cleanupActionId()); + removeTempDynamicAction(actionKey); + return JSONObject.of( + "ok", true, + "actionKey", actionKey + ).toJSONString(); + }); + } + + @Override + public String createActionKey(String actionName) { + return DYNAMIC_LOCATION + "::" + actionName; + } + + private JSONObject parseMeta(String metaJson) { + try { + return JSON.parseObject(metaJson); + } catch (Exception e) { + throw new IllegalArgumentException("参数 meta 必须为合法 JSON 字符串", e); + } + } + + private MetaActionInfo buildMetaActionInfo(JSONObject meta, String launcher, String description) { + return new MetaActionInfo( + Boolean.TRUE.equals(meta.getBoolean("io")), + launcher, + copyStringMap(meta.getJSONObject("params")), + description, + toOrderedSet(meta.getJSONArray("tags")), + toOrderedSet(meta.getJSONArray("preActions")), + toOrderedSet(meta.getJSONArray("postActions")), + Boolean.TRUE.equals(meta.getBoolean("strictDependencies")), + copyJsonObject(meta.getJSONObject("responseSchema")) + ); + } + + private ActionFileMetaData buildActionFileMetaData(String location, String code, String codeType) { + ActionFileMetaData fileMetaData = new ActionFileMetaData(); + fileMetaData.setContent(code); + fileMetaData.setExt(normalizeCodeType(codeType)); + fileMetaData.setName(extractFileBaseName(location, fileMetaData.getExt())); + return fileMetaData; + } + + private StateAction buildCleanupAction(String actionKey) { + return new StateAction( + "system", + "dynamic-action-cleanup:" + actionKey, + "清理临时动态行动", + Schedulable.ScheduleType.ONCE, + ZonedDateTime.now().plusSeconds(TEMP_ACTION_TTL_MILLIS / 1000).toString(), + new StateAction.Trigger.Call(() -> { + removeTempDynamicAction(actionKey); + return Unit.INSTANCE; + }) + ); + } + + private void removeTempDynamicAction(String actionKey) { + TempDynamicActionRecord record = tempDynamicActions.remove(actionKey); + if (record == null) { + return; + } + actionCapability.listAvailableMetaActions().remove(actionKey); + deleteTempFileQuietly(record.location()); + } + + private void deleteTempFileQuietly(String location) { + try { + Files.deleteIfExists(Path.of(location)); + } catch (Exception ignored) { + } + } + + private Map copyStringMap(JSONObject jsonObject) { + if (jsonObject == null) { + return Map.of(); + } + Map params = new LinkedHashMap<>(); + jsonObject.forEach((key, value) -> params.put(key, value == null ? "" : String.valueOf(value))); + return params; + } + + private Set toOrderedSet(com.alibaba.fastjson2.JSONArray jsonArray) { + if (jsonArray == null) { + return Set.of(); + } + return jsonArray.toJavaList(String.class).stream() + .filter(item -> item != null && !item.isBlank()) + .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)); + } + + private JSONObject copyJsonObject(JSONObject jsonObject) { + return jsonObject == null ? JSONObject.of() : JSONObject.from(jsonObject); + } + + private String normalizeCodeType(String codeType) { + return codeType.startsWith(".") ? codeType.substring(1) : codeType; + } + + private String extractFileBaseName(String location, String ext) { + String fileName = Path.of(location).getFileName().toString(); + String suffix = "." + ext; + if (fileName.endsWith(suffix)) { + return fileName.substring(0, fileName.length() - suffix.length()); + } + return fileName; + } + + private String shortUuid() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private record TempDynamicActionRecord( + String actionKey, + String location, + String cleanupActionId, + ActionFileMetaData fileMetaData, + MetaActionInfo metaActionInfo + ) { + } + +} diff --git a/Partner-Core/src/test/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProviderTest.java b/Partner-Core/src/test/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProviderTest.java new file mode 100644 index 00000000..d01d6745 --- /dev/null +++ b/Partner-Core/src/test/java/work/slhaf/partner/module/modules/action/builtin/BuiltinDynamicActionProviderTest.java @@ -0,0 +1,205 @@ +package work.slhaf.partner.module.modules.action.builtin; + +import com.alibaba.fastjson2.JSONObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import work.slhaf.partner.core.action.ActionCapability; +import work.slhaf.partner.core.action.entity.ActionFileMetaData; +import work.slhaf.partner.core.action.entity.MetaAction; +import work.slhaf.partner.core.action.entity.MetaActionInfo; +import work.slhaf.partner.core.action.entity.StateAction; +import work.slhaf.partner.core.action.runner.RunnerClient; +import work.slhaf.partner.module.modules.action.scheduler.ActionScheduler; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; + +class BuiltinDynamicActionProviderTest { + + @TempDir + Path tempDir; + + @Test + void generateShouldRegisterTempOriginActionAndScheduleCleanup() throws Exception { + TestContext context = createContext(); + BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate"); + + JSONObject result = JSONObject.parseObject(generate.invoker().apply(Map.of( + "desc", "temporary origin", + "code", "print('ok')", + "codeType", "py", + "launcher", "python3", + "meta", """ + { + "io": true, + "params": {"input": "user input"}, + "tags": ["dynamic", "temp"], + "preActions": ["builtin::command::execute"], + "postActions": ["builtin::dynamic::persist"], + "strictDependencies": true, + "responseSchema": {"result": "dynamic result"} + } + """ + ))); + + String actionKey = result.getString("actionKey"); + Assertions.assertTrue(result.getBooleanValue("ok")); + Assertions.assertTrue(actionKey.startsWith("origin::")); + + MetaActionInfo metaActionInfo = context.availableMetaActions.get(actionKey); + Assertions.assertNotNull(metaActionInfo); + Assertions.assertEquals("python3", metaActionInfo.getLauncher()); + Assertions.assertEquals("temporary origin", metaActionInfo.getDescription()); + Assertions.assertTrue(metaActionInfo.getIo()); + Assertions.assertEquals("user input", metaActionInfo.getParams().get("input")); + Assertions.assertTrue(metaActionInfo.getTags().contains("dynamic")); + Assertions.assertTrue(metaActionInfo.getStrictDependencies()); + Assertions.assertEquals("dynamic result", metaActionInfo.getResponseSchema().getString("result")); + + Path tempFile = Path.of(actionKey.substring("origin::".length())); + Assertions.assertTrue(Files.exists(tempFile)); + + ArgumentCaptor cleanupCaptor = ArgumentCaptor.forClass(StateAction.class); + Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture()); + Assertions.assertEquals("清理临时动态行动", cleanupCaptor.getValue().getDescription()); + } + + @Test + void persistShouldPersistDeleteTempFileAndCancelCleanup() throws Exception { + TestContext context = createContext(); + BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate"); + BuiltinActionRegistry.BuiltinActionDefinition persist = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::persist"); + + String actionKey = JSONObject.parseObject(generate.invoker().apply(Map.of( + "desc", "persist target", + "code", "print('persist')", + "codeType", ".py", + "launcher", "python3", + "meta", "{}" + ))).getString("actionKey"); + Path tempFile = Path.of(actionKey.substring("origin::".length())); + + ArgumentCaptor cleanupCaptor = ArgumentCaptor.forClass(StateAction.class); + Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture()); + StateAction cleanupAction = cleanupCaptor.getValue(); + + JSONObject persistResult = JSONObject.parseObject(persist.invoker().apply(Map.of("actionKey", actionKey))); + + Assertions.assertTrue(persistResult.getBooleanValue("ok")); + Assertions.assertEquals(actionKey, persistResult.getString("actionKey")); + Assertions.assertTrue(context.runnerClient.persistCalled); + Assertions.assertFalse(Files.exists(tempFile)); + Assertions.assertFalse(context.availableMetaActions.containsKey(actionKey)); + Mockito.verify(context.actionScheduler).cancel(cleanupAction.getUuid()); + } + + @Test + void cleanupActionShouldDeleteTempFileAndRemoveMetaAction() throws Exception { + TestContext context = createContext(); + BuiltinActionRegistry.BuiltinActionDefinition generate = requireDefinition(context.provider.provideBuiltinActions(), "builtin::dynamic::generate"); + + String actionKey = JSONObject.parseObject(generate.invoker().apply(Map.of( + "desc", "cleanup target", + "code", "print('cleanup')", + "codeType", "py", + "launcher", "python3", + "meta", "{}" + ))).getString("actionKey"); + Path tempFile = Path.of(actionKey.substring("origin::".length())); + + ArgumentCaptor cleanupCaptor = ArgumentCaptor.forClass(StateAction.class); + Mockito.verify(context.actionScheduler).schedule(cleanupCaptor.capture()); + cleanupCaptor.getValue().getTrigger().onTrigger(); + + Assertions.assertFalse(Files.exists(tempFile)); + Assertions.assertFalse(context.availableMetaActions.containsKey(actionKey)); + } + + private TestContext createContext() throws Exception { + BuiltinDynamicActionProvider provider = new BuiltinDynamicActionProvider(); + ActionCapability actionCapability = Mockito.mock(ActionCapability.class); + ActionScheduler actionScheduler = Mockito.mock(ActionScheduler.class); + Map availableMetaActions = new LinkedHashMap<>(); + TestRunnerClient runnerClient = new TestRunnerClient(tempDir); + + Mockito.when(actionCapability.runnerClient()).thenReturn(runnerClient); + Mockito.when(actionCapability.listAvailableMetaActions()).thenReturn(availableMetaActions); + Mockito.doAnswer(invocation -> { + availableMetaActions.putAll(invocation.getArgument(0)); + return null; + }).when(actionCapability).registerMetaActions(Mockito.anyMap()); + + inject(provider, "actionCapability", actionCapability); + inject(provider, "actionScheduler", actionScheduler); + return new TestContext(provider, actionScheduler, availableMetaActions, runnerClient); + } + + private BuiltinActionRegistry.BuiltinActionDefinition requireDefinition( + List definitions, + String actionKey + ) { + return definitions.stream() + .filter(definition -> actionKey.equals(definition.actionKey())) + .findFirst() + .orElseThrow(() -> new AssertionError("definition not found: " + actionKey)); + } + + private void inject(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private record TestContext( + BuiltinDynamicActionProvider provider, + ActionScheduler actionScheduler, + Map availableMetaActions, + TestRunnerClient runnerClient + ) { + } + + private static class TestRunnerClient extends RunnerClient { + private final Path tempBase; + private boolean persistCalled; + + private TestRunnerClient(Path tempBase) { + super(new ConcurrentHashMap<>(), Executors.newSingleThreadExecutor(), tempBase.toString()); + this.tempBase = tempBase; + } + + @Override + protected RunnerResponse doRun(MetaAction metaAction) { + return new RunnerResponse(); + } + + @Override + public String buildTmpPath(String actionKey, String codeType) { + String normalized = codeType.startsWith(".") ? codeType : "." + codeType; + return tempBase.resolve(actionKey + normalized).toString(); + } + + @Override + public void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException { + Path path = Path.of(tempAction.getLocation()); + if (path.getParent() != null) { + Files.createDirectories(path.getParent()); + } + Files.writeString(path, code); + } + + @Override + public void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData) { + persistCalled = true; + } + } +}