diff --git a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantPromptService.java b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantPromptService.java index 73143b6..722e7b0 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantPromptService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantPromptService.java @@ -87,7 +87,7 @@ public class FlowAssistantPromptService { Rules: - Configure only the requested block. - The config object must contain only task-specific fields. - - Do not include system-managed fields like provider, model, llmDescriptor, simulateWith, ids, inputs, outputs. + - Do not include system-managed fields like provider, model, llmDescriptor, ids, inputs, outputs. - Use placeholders like ${{variable}} when needed. - Return valid JSON with no markdown fences. diff --git a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java index bfda445..65eb86c 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/assistant/FlowAssistantService.java @@ -286,7 +286,6 @@ public class FlowAssistantService { String model) { switch (descriptor.configurationType()) { case "LLMBlockConfiguration" -> config.set("llmDescriptor", llmDescriptorNode(model)); - case "HumanInteractiveBlockConfiguration" -> config.set("simulateWith", llmDescriptorNode(model)); case "ChatInteractionBlockConfiguration" -> config.set("llmDescriptor", llmDescriptorNode(model)); case "ConditionalBlockConfiguration" -> { boolean useLlm = inferConditionalUseLlm(config); diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java index f3170fd..15fcd9b 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java @@ -6,10 +6,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; +import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Data; @@ -27,6 +29,11 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration inputs) { super(name); this.llmDescriptor = llmDescriptor; + this.goalDescription = goalDescription; this.inputs = inputs == null ? List.of() : List.copyOf(inputs); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java index 3bbb1f9..1760671 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; -import it.cnr.isti.workflow.manager.configurations.annotations.UiDependency; +import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen; import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen; import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; @@ -22,7 +22,7 @@ import lombok.NonNull; @EqualsAndHashCode(callSuper = true) public class ConditionalBlockConfiguration extends BlockConfiguration { - @UiDependency(field = "useLlm", equals = "false") + @UiEnabledWhen(field = "useLlm", equals = "false") @Structural @LongText( placeholder = "Add a deterministic condition like ${{amount}} > 1000", @@ -35,11 +35,11 @@ public class ConditionalBlockConfiguration extends BlockConfiguration getBlockType() { return HumanInteractionBlockType.class; } @Builder - public HumanInteractiveBlockConfiguration(@NonNull String name, @NonNull String actionDescription, - @NonNull LLMDescriptor simulateWith) { + public HumanInteractiveBlockConfiguration(@NonNull String name, @NonNull String actionDescription) { super(name); this.actionDescription = actionDescription; - this.simulateWith = simulateWith; } public static HumanInteractiveBlockConfiguration empty() { diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java index 2b81e69..d108601 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java @@ -25,7 +25,6 @@ import com.kjetland.jackson.jsonSchema.JsonSchemaGenerator; import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever; import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; -import it.cnr.isti.workflow.manager.configurations.annotations.UiDependency; import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema; import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput; import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues; @@ -56,7 +55,6 @@ public class JsonSchemaProducer { Map, Map> dynamicSchemaMap = collectDynamicSchemaMetadata(type); Map, Map> longTextMap = collectLongTextMetadata(type); Map, Map> structuralMap = collectStructuralMetadata(type); - Map, Map> uiDependencyMap = collectUiDependencyMetadata(type); Map, Map> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type); Map, Map> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type); Map, Map> uiRequiredWhenMap = collectUiRequiredWhenMetadata(type); @@ -70,7 +68,6 @@ public class JsonSchemaProducer { applyDynamicSchemaMetadata(root, dynamicSchemaMap.getOrDefault(type, Map.of())); applyLongTextMetadata(root, longTextMap.getOrDefault(type, Map.of())); applyStructuralMetadata(root, structuralMap.getOrDefault(type, Map.of())); - applyUiDependencyMetadata(root, uiDependencyMap.getOrDefault(type, Map.of())); applyUiEnabledWhenMetadata(root, uiEnabledWhenMap.getOrDefault(type, Map.of())); applyUiOptionsFromNodeMetadata(root, uiOptionsFromNodeMap.getOrDefault(type, Map.of())); applyUiRequiredWhenMetadata(root, uiRequiredWhenMap.getOrDefault(type, Map.of())); @@ -87,7 +84,6 @@ public class JsonSchemaProducer { metadataClasses.addAll(dynamicSchemaMap.keySet()); metadataClasses.addAll(longTextMap.keySet()); metadataClasses.addAll(structuralMap.keySet()); - metadataClasses.addAll(uiDependencyMap.keySet()); metadataClasses.addAll(uiEnabledWhenMap.keySet()); metadataClasses.addAll(uiOptionsFromNodeMap.keySet()); metadataClasses.addAll(uiRequiredWhenMap.keySet()); @@ -107,7 +103,6 @@ public class JsonSchemaProducer { applyDynamicSchemaMetadata(classSchema, dynamicSchemaMap.get(matchedClass)); applyLongTextMetadata(classSchema, longTextMap.get(matchedClass)); applyStructuralMetadata(classSchema, structuralMap.get(matchedClass)); - applyUiDependencyMetadata(classSchema, uiDependencyMap.get(matchedClass)); applyUiEnabledWhenMetadata(classSchema, uiEnabledWhenMap.get(matchedClass)); applyUiOptionsFromNodeMetadata(classSchema, uiOptionsFromNodeMap.get(matchedClass)); applyUiRequiredWhenMetadata(classSchema, uiRequiredWhenMap.get(matchedClass)); @@ -556,45 +551,6 @@ public class JsonSchemaProducer { } } - private Map, Map> collectUiDependencyMetadata(Class rootClass) { - Map, Map> result = new HashMap<>(); - Set> visited = new HashSet<>(); - Queue> queue = new ArrayDeque<>(); - queue.add(rootClass); - - while (!queue.isEmpty()) { - Class current = queue.poll(); - if (current == null || !visited.add(current) || isTerminalType(current)) { - continue; - } - - Map metadata = new LinkedHashMap<>(); - for (Field field : current.getDeclaredFields()) { - UiDependency uiDependency = field.getAnnotation(UiDependency.class); - if (uiDependency != null) { - metadata.put(field.getName(), uiDependency); - } - enqueueRelatedTypes(queue, field.getGenericType(), field.getType()); - } - - if (current.isRecord()) { - for (RecordComponent component : current.getRecordComponents()) { - UiDependency uiDependency = component.getAnnotation(UiDependency.class); - if (uiDependency != null) { - metadata.put(component.getName(), uiDependency); - } - enqueueRelatedTypes(queue, component.getGenericType(), component.getType()); - } - } - - if (!metadata.isEmpty()) { - result.put(current, metadata); - } - } - - return result; - } - private Map, Map> collectUiUniqueItemsByMetadata(Class rootClass) { Map, Map> result = new HashMap<>(); Set> visited = new HashSet<>(); @@ -634,39 +590,6 @@ public class JsonSchemaProducer { return result; } - private void applyUiDependencyMetadata(ObjectNode classSchema, Map metadata) { - if (metadata == null || metadata.isEmpty()) { - return; - } - JsonNode propsNode = classSchema.get("properties"); - if (!(propsNode instanceof ObjectNode properties)) { - return; - } - - for (Entry entry : metadata.entrySet()) { - JsonNode propNode = properties.get(entry.getKey()); - if (!(propNode instanceof ObjectNode propertySchema)) { - continue; - } - - UiDependency dependency = entry.getValue(); - if (!dependency.group().isBlank()) { - propertySchema.put("x-ui-group", dependency.group()); - } - ObjectNode visibleWhen = propertySchema.putObject("x-ui-visible-when"); - visibleWhen.put("field", dependency.field()); - if (!dependency.equals().isBlank()) { - visibleWhen.put("equals", dependency.equals()); - } - if (dependency.equalsAny().length > 0) { - ArrayNode equalsAny = visibleWhen.putArray("in"); - for (String value : dependency.equalsAny()) { - equalsAny.add(value); - } - } - } - } - private void applyUiUniqueItemsByMetadata(ObjectNode classSchema, Map metadata) { if (metadata == null || metadata.isEmpty()) { return; @@ -926,6 +849,9 @@ public class JsonSchemaProducer { } UiEnabledWhen dependency = entry.getValue(); + if (!dependency.group().isBlank()) { + propertySchema.put("x-ui-group", dependency.group()); + } ObjectNode enabledWhen = propertySchema.putObject("x-ui-enabled-when"); enabledWhen.put("field", dependency.field()); if (!dependency.equals().isBlank()) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java index 8fdd5eb..b16d79b 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java @@ -10,6 +10,9 @@ import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema; import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput; +import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys; +import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen; +import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -27,6 +30,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration mcpServers; + @Structural + @JsonProperty(required = false) + private Boolean shareSession; + + @Structural + @UiEnabledWhen(field = "shareSession", equals = "true") + @UiRequiredWhen(field = "shareSession", equals = "true") + @JsonProperty(required = false) + private String sharedSessionName; + + @Structural + @UiEnabledWhen(field = "shareSession", equals = "false") + @JsonProperty(required = false) + private Boolean useSharedSession; + + @Structural + @UiEnabledWhen(field = "useSharedSession", equals = "true") + @UiRequiredWhen(field = "useSharedSession", equals = "true") + @FieldRetriever(name = "ExecutionVariables", url = "/secure-retriever/ExecutionVariables/shared/items?kind=MCP_SESSION", + dependsOn = {UiContextKeys.FLOW_ID}, requiresAuth = true) + @JsonProperty(required = false) + private String sharedSessionRef; + @Builder - public MCPAgentBlockConfiguration(@NonNull String name, String model, String prompt, List mcpServers) { + public MCPAgentBlockConfiguration(@NonNull String name, String model, String prompt, List mcpServers, + Boolean shareSession, String sharedSessionName, Boolean useSharedSession, String sharedSessionRef) { super(name); this.model = model; this.prompt = prompt; this.mcpServers = mcpServers == null ? List.of() : List.copyOf(mcpServers); + this.shareSession = shareSession; + this.sharedSessionName = sharedSessionName; + this.useSharedSession = useSharedSession; + this.sharedSessionRef = sharedSessionRef; } @Override @@ -60,6 +93,8 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration inputs = List.of(); + @NotBlank + @LongText(placeholder = "Describe the goal of the simulated chat") + @JsonProperty(required = true) + private String goalDescription; + @JsonProperty(required = false) @Valid + @UiEnabledWhen(field = "useSharedSession", equals = "false") private List mcpServers = List.of(); + @Structural + @JsonProperty(required = false) + private Boolean shareSession; + + @Structural + @UiEnabledWhen(field = "shareSession", equals = "true") + @UiRequiredWhen(field = "shareSession", equals = "true") + @JsonProperty(required = false) + private String sharedSessionName; + + @Structural + @UiEnabledWhen(field = "shareSession", equals = "false") + @JsonProperty(required = false) + private Boolean useSharedSession; + + @Structural + @UiEnabledWhen(field = "useSharedSession", equals = "true") + @UiRequiredWhen(field = "useSharedSession", equals = "true") + @FieldRetriever(name = "ExecutionVariables", url = "/secure-retriever/ExecutionVariables/shared/items?kind=MCP_SESSION", + dependsOn = {UiContextKeys.FLOW_ID}, requiresAuth = true) + @JsonProperty(required = false) + private String sharedSessionRef; + @Builder public MCPAgentChatBlockConfiguration(@NonNull String name, String model, List inputs, - List mcpServers) { + String goalDescription, + List mcpServers, + Boolean shareSession, String sharedSessionName, Boolean useSharedSession, String sharedSessionRef) { super(name); this.model = model; this.inputs = inputs == null ? List.of() : List.copyOf(inputs); + this.goalDescription = goalDescription; this.mcpServers = mcpServers == null ? List.of() : List.copyOf(mcpServers); + this.shareSession = shareSession; + this.sharedSessionName = sharedSessionName; + this.useSharedSession = useSharedSession; + this.sharedSessionRef = sharedSessionRef; } @Override @@ -57,6 +98,8 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration names = new LinkedHashSet<>(); - while (matcher.find()) - names.add(matcher.group(1)); + while (matcher.find()) { + String placeholder = matcher.group(1); + if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) { + names.add(placeholder); + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java index 64b7ff7..fbc3daf 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java @@ -34,9 +34,12 @@ public class MCPAgentBlockFactory implements BlockFactory create(MCPAgentBlockConfiguration configuration) { + validateConfiguration(configuration); List inputs = Stream.concat( retrieveInputs(configuration.getPrompt()).stream(), - configurableInputDescriptors(configuration).stream()) + configurableInputDescriptors(configuration).stream() + .filter(descriptor -> !Boolean.TRUE.equals(configuration.getUseSharedSession()) + || !"model".equals(descriptor.getName()))) .distinct() .toList(); return Block.builder() @@ -64,7 +67,10 @@ public class MCPAgentBlockFactory implements BlockFactory names = new LinkedHashSet<>(); while (matcher.find()) { - names.add(matcher.group(1)); + String placeholder = matcher.group(1); + if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) { + names.add(placeholder); + } } return names.stream().map(name -> IODescriptor.input(name, IOType.TEXT, false, INPUT_CAPABILITIES)).toList(); } @@ -78,4 +84,9 @@ public class MCPAgentBlockFactory implements BlockFactory supportedOutputCapabilities() { return OUTPUT_CAPABILITIES; } + + private void validateConfiguration(MCPAgentBlockConfiguration configuration) { + // Factory creation must allow partial draft configurations. + // Executability is validated later at flow-validation/runtime level. + } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java index 992d411..ca6591e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java @@ -5,6 +5,7 @@ import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.IOCapability; @@ -70,7 +71,11 @@ public class MCPAgentChatBlockFactory } private List resolveInputs(MCPAgentChatBlockConfiguration configuration) { - java.util.ArrayList inputs = new java.util.ArrayList<>(configurableInputDescriptors(configuration)); + java.util.ArrayList inputs = new java.util.ArrayList<>( + configurableInputDescriptors(configuration).stream() + .filter(descriptor -> !Boolean.TRUE.equals(configuration.getUseSharedSession()) + || !MODEL_INPUT.equals(descriptor.getName())) + .toList()); if (configuration.getInputs() != null) { inputs.addAll(configuration.getInputs().stream().map(this::toDescriptor).toList()); } @@ -84,7 +89,10 @@ public class MCPAgentChatBlockFactory } private void validateConfiguration(MCPAgentChatBlockConfiguration configuration) { - if (configuration == null || configuration.getInputs() == null || configuration.getInputs().isEmpty()) { + if (configuration == null) { + return; + } + if (configuration.getInputs() == null || configuration.getInputs().isEmpty()) { return; } long distinctNames = configuration.getInputs().stream() diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/PlaceholderInputs.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/PlaceholderInputs.java new file mode 100644 index 0000000..3d5e92b --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/PlaceholderInputs.java @@ -0,0 +1,11 @@ +package it.cnr.isti.workflow.manager.blocks.factories; + +final class PlaceholderInputs { + + private PlaceholderInputs() { + } + + static boolean isRuntimeExecutionVariable(String placeholder) { + return placeholder != null && placeholder.startsWith("vars."); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiContextKeys.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiContextKeys.java new file mode 100644 index 0000000..bd4e57f --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiContextKeys.java @@ -0,0 +1,12 @@ +package it.cnr.isti.workflow.manager.configurations.annotations; + +public final class UiContextKeys { + + public static final String FLOW_ID = "$context.flowId"; + public static final String BLOCK_ID = "$context.blockId"; + public static final String INPUT_NAMES = "$context.inputNames"; + public static final String OUTPUT_NAMES = "$context.outputNames"; + + private UiContextKeys() { + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiDependency.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiDependency.java deleted file mode 100644 index fb64d6d..0000000 --- a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiDependency.java +++ /dev/null @@ -1,18 +0,0 @@ -package it.cnr.isti.workflow.manager.configurations.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT }) -@Retention(RetentionPolicy.RUNTIME) -public @interface UiDependency { - String field(); - - String equals() default ""; - - String[] equalsAny() default {}; - - String group() default ""; -} diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiEnabledWhen.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiEnabledWhen.java index 3732ce8..47547fc 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiEnabledWhen.java +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiEnabledWhen.java @@ -15,4 +15,6 @@ public @interface UiEnabledWhen { String[] equalsAny() default {}; boolean present() default false; + + String group() default ""; } diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/ExecutionVariablesFieldRetriever.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/ExecutionVariablesFieldRetriever.java new file mode 100644 index 0000000..e17075e --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/ExecutionVariablesFieldRetriever.java @@ -0,0 +1,54 @@ +package it.cnr.isti.workflow.manager.configurations.retrievers; + +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind; +import it.cnr.isti.workflow.manager.executions.design.FlowSharedVariableCatalogService; + +@Component +public class ExecutionVariablesFieldRetriever implements SecureDynamicFieldRetriever { + + private final FlowSharedVariableCatalogService catalogService; + + public ExecutionVariablesFieldRetriever(FlowSharedVariableCatalogService catalogService) { + this.catalogService = catalogService; + } + + @Override + public String getCategory() { + return "ExecutionVariables"; + } + + @Override + public List retrieve(String parameter, Map params, LoginEntity user) { + if (!"shared".equals(parameter)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Unknown ExecutionVariables retriever parameter: " + parameter); + } + String flowId = params.get("flowId"); + String kindParam = params.get("kind"); + ExecutionVariableKind kind = kindParam == null || kindParam.isBlank() + ? null + : ExecutionVariableKind.valueOf(kindParam.trim().toUpperCase()); + return catalogService.listForFlow(flowId, user.getUsername(), kind).stream() + .map(definition -> new RetrieverItem( + new RetrieverItemDescriptor( + definition.name(), + "Shared variable exposed by " + definition.producerBlockName(), + Map.of( + "kind", definition.kind().name(), + "producerBlockId", definition.producerBlockId(), + "producerBlockName", definition.producerBlockName())), + definition.name(), + false, + true, + List.of())) + .toList(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java index bcc783b..1158125 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java @@ -9,6 +9,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaProducer; import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory; import it.cnr.isti.workflow.manager.blocks.types.BlockType; +import it.cnr.isti.workflow.manager.executions.design.FlowSharedVariableCatalogService; import java.util.List; import java.util.Map; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.springframework.web.server.ResponseStatusException; @@ -40,6 +42,9 @@ public class BlocksController { @Autowired JsonSchemaProducer schemaProducer; + @Autowired + FlowSharedVariableCatalogService flowSharedVariableCatalogService; + public record BlockConfigurationDescriptor( String type, String description, @@ -167,11 +172,23 @@ public class BlocksController { return null; } + @SuppressWarnings("unchecked") + public > Block create(C blockConfiguration) { + return create(blockConfiguration, null, null); + } + + @SuppressWarnings("unchecked") + public > Block create(C blockConfiguration, String flowId) { + return create(blockConfiguration, flowId, null); + } + @SuppressWarnings("unchecked") @PostMapping @SecurityRequirement(name = "bearerAuth") @Operation(summary = "create block", description = "Creates a block from the provided block configuration.") - public > Block create(@RequestBody C blockConfiguration) { + public > Block create(@RequestBody C blockConfiguration, + @RequestParam(required = false) String flowId, + @RequestParam(required = false) String replacesBlockId) { logger.debug("Creating block of type: {}", blockConfiguration.getBlockType().getName()); BlockFactory factory = (BlockFactory) blockFactories.stream() .filter(f -> f.getBlockType().equals(blockConfiguration.getBlockType())) @@ -179,6 +196,7 @@ public class BlocksController { .orElseThrow(() -> new IllegalArgumentException("Block factory not found for type: " + blockConfiguration.getBlockType())); Block block = factory.create(blockConfiguration); + flowSharedVariableCatalogService.registerDraftBlock(flowId, block, replacesBlockId); String blockAsJson = null; try { blockAsJson = ObjectMapperHolder.mapper.writeValueAsString(block); diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java index e3783d6..72f2e95 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java @@ -28,6 +28,7 @@ import org.springframework.web.server.ResponseStatusException; import it.cnr.isti.workflow.manager.executions.ExecutionContext; import it.cnr.isti.workflow.manager.executions.ExecutionObject; import it.cnr.isti.workflow.manager.executions.ExecutionAuthorizationValueRequest; +import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest; import it.cnr.isti.workflow.manager.executions.ExecutionsService; import it.cnr.isti.workflow.manager.flows.repo.FlowEntity; import it.cnr.isti.workflow.manager.flows.repo.FlowRepository; @@ -175,6 +176,13 @@ public class ExecutionsController { return executionService.startExecution(id); } + @PutMapping(path = "{id}/simulate") + @Operation(summary = "Starts an execution in simulation mode", + description = "Starts the execution and enables simulation for interactive nodes.") + public ExecutionObject simulate(@PathVariable String id, @RequestBody @jakarta.validation.Valid ExecutionSimulationRequest request) { + return executionService.startSimulationExecution(id, request.simulator()); + } + @PutMapping(path = "{id}/resume") @Operation(summary = "Resumes an execution", description = "Rebuilds a persisted execution and resumes it when possible.") public ExecutionObject resume(@PathVariable String id) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java index 0edea90..772688e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java @@ -14,6 +14,7 @@ import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.steps.Step; import it.cnr.isti.workflow.manager.executions.steps.StepStatus; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -25,6 +26,8 @@ public class ExecutionContext implements ExecutionListener { Map inputs = new HashMap<>(); Map authorizations = new HashMap<>(); + Map executionVariables = new HashMap<>(); + Map executionVariableDescriptors = new HashMap<>(); @Getter() private Map result = new HashMap<>(); @@ -47,11 +50,27 @@ public class ExecutionContext implements ExecutionListener { @JsonIgnore Runnable stateChangeListener; + boolean interactionSimulationEnabled = false; + + LLMDescriptor interactionSimulationDescriptor; + + protected void setInteractionSimulationEnabled(boolean interactionSimulationEnabled) { + this.interactionSimulationEnabled = interactionSimulationEnabled; + } + + protected void setInteractionSimulationDescriptor(LLMDescriptor interactionSimulationDescriptor) { + this.interactionSimulationDescriptor = interactionSimulationDescriptor; + this.steps.values().forEach(step -> step.setInteractionSimulationDescriptor(interactionSimulationDescriptor)); + } + public ExecutionContext(Map> steps) { this.steps = steps; this.steps.values().forEach(step -> { step.setListener(this); step.setAuthorizations(this.authorizations); + step.setExecutionVariables(this.executionVariables); + step.setExecutionVariableDescriptors(this.executionVariableDescriptors); + step.setInteractionSimulationDescriptor(this.interactionSimulationDescriptor); }); } @@ -224,6 +243,55 @@ public class ExecutionContext implements ExecutionListener { notifyStateChanged(); } + protected void setExecutionVariable(String key, Object value) { + registerExecutionVariable(ExecutionVariableDescriptor.builder() + .name(key) + .value(value) + .kind(ExecutionVariableKind.ANY) + .cleanupPolicy(ExecutionVariableCleanupPolicy.NONE) + .build()); + } + + protected void registerExecutionVariable(ExecutionVariableDescriptor descriptor) { + ExecutionVariableDescriptor normalized = ExecutionVariableRegistry.normalize(descriptor); + this.executionVariableDescriptors.put(normalized.getName(), normalized); + this.executionVariables.put(normalized.getName(), normalized.getValue()); + notifyStateChanged(); + } + + protected void setExecutionVariables(Map executionVariables) { + setExecutionVariableDescriptors(ExecutionVariableRegistry.descriptorsFromValues(executionVariables)); + } + + protected void setExecutionVariableDescriptors(Map descriptors) { + this.executionVariableDescriptors.clear(); + this.executionVariables.clear(); + if (descriptors != null) { + descriptors.values().stream() + .map(ExecutionVariableRegistry::normalize) + .forEach(descriptor -> { + this.executionVariableDescriptors.put(descriptor.getName(), descriptor); + this.executionVariables.put(descriptor.getName(), descriptor.getValue()); + }); + } + notifyStateChanged(); + } + + protected Object getExecutionVariable(String key) { + return this.executionVariables.get(key); + } + + protected ExecutionVariableDescriptor getExecutionVariableDescriptor(String key) { + return this.executionVariableDescriptors.get(key); + } + + protected Object removeExecutionVariable(String key) { + this.executionVariableDescriptors.remove(key); + Object removed = this.executionVariables.remove(key); + notifyStateChanged(); + return removed; + } + protected void start(ExecutorService executorService) { this.setStatus(ExecutionStatus.RUNNING); this.steps.values().forEach(step -> step.start(executorService)); @@ -235,6 +303,8 @@ public class ExecutionContext implements ExecutionListener { this.authorizations.clear(); this.result.clear(); this.partialResult.clear(); + this.executionVariables.clear(); + this.executionVariableDescriptors.clear(); this.errors.clear(); this.warnings.clear(); this.waitingSteps.clear(); @@ -252,6 +322,10 @@ public class ExecutionContext implements ExecutionListener { .status(this.status) .startTime(this.startTime) .endTime(this.endTime) + .interactionSimulationEnabled(this.interactionSimulationEnabled) + .interactionSimulationDescriptor(this.interactionSimulationDescriptor) + .executionVariables(Map.copyOf(this.executionVariables)) + .executionVariableDescriptors(Map.copyOf(this.executionVariableDescriptors)) .providedAuthorizations(providedAuthorizations == null ? Map.of() : Map.copyOf(providedAuthorizations)) .inputs(Map.copyOf(this.inputs)) .result(Map.copyOf(this.result)) @@ -272,6 +346,27 @@ public class ExecutionContext implements ExecutionListener { if (snapshot.getProvidedAuthorizations() != null) { this.authorizations.putAll(snapshot.getProvidedAuthorizations()); } + this.executionVariables.clear(); + this.executionVariableDescriptors.clear(); + if (snapshot.getExecutionVariableDescriptors() != null && !snapshot.getExecutionVariableDescriptors().isEmpty()) { + snapshot.getExecutionVariableDescriptors().values().stream() + .map(ExecutionVariableRegistry::normalize) + .forEach(descriptor -> { + this.executionVariableDescriptors.put(descriptor.getName(), descriptor); + this.executionVariables.put(descriptor.getName(), descriptor.getValue()); + }); + } else if (snapshot.getExecutionVariables() != null) { + snapshot.getExecutionVariables().forEach((key, value) -> { + ExecutionVariableDescriptor descriptor = ExecutionVariableDescriptor.builder() + .name(key) + .value(value) + .kind(ExecutionVariableKind.ANY) + .cleanupPolicy(ExecutionVariableCleanupPolicy.NONE) + .build(); + this.executionVariableDescriptors.put(key, descriptor); + this.executionVariables.put(key, value); + }); + } this.result.clear(); if (snapshot.getResult() != null) { this.result.putAll(snapshot.getResult()); @@ -294,6 +389,8 @@ public class ExecutionContext implements ExecutionListener { } this.startTime = snapshot.getStartTime(); this.endTime = snapshot.getEndTime(); + this.interactionSimulationEnabled = snapshot.isInteractionSimulationEnabled(); + this.setInteractionSimulationDescriptor(snapshot.getInteractionSimulationDescriptor()); this.status = normalizeRestoredStatus(snapshot.getStatus()); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java index 17685b6..c6fdb4d 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java @@ -23,6 +23,7 @@ import it.cnr.isti.workflow.manager.executions.steps.Step; import it.cnr.isti.workflow.manager.flows.model.FlowNode; import it.cnr.isti.workflow.manager.flows.model.Connection; import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -51,6 +52,12 @@ public class ExecutionObject { @JsonIgnore ExecutorService executorService; + boolean interactionSimulationEnabled = false; + + boolean simulationAvailable = false; + + LLMDescriptor interactionSimulationDescriptor; + @Builder public ExecutionObject(String executionName, FlowData flow, List requiredAuthorizations) { this.name = executionName; @@ -68,6 +75,7 @@ public class ExecutionObject { .distinct() .filter(this.providedAuthorizations::containsKey) .forEach(key -> this.context.setAuthorization(key, this.providedAuthorizations.get(key))); + this.simulationAvailable = hasSimulationAvailable(steps); refreshInitializationStatus(); } @@ -111,11 +119,63 @@ public class ExecutionObject { refreshInitializationStatus(); } + protected void setExecutionVariable(String key, Object value) { + this.context.setExecutionVariable(key, value); + } + + protected void setExecutionVariables(Map executionVariables) { + this.context.setExecutionVariables(executionVariables); + } + + protected void setExecutionVariableDescriptors(Map executionVariableDescriptors) { + this.context.setExecutionVariableDescriptors(executionVariableDescriptors); + } + + protected void registerExecutionVariable(ExecutionVariableDescriptor descriptor) { + this.context.registerExecutionVariable(descriptor); + } + + protected Object removeExecutionVariable(String key) { + return this.context.removeExecutionVariable(key); + } + + protected Object getExecutionVariable(String key) { + return this.context.getExecutionVariable(key); + } + + protected ExecutionVariableDescriptor getExecutionVariableDescriptor(String key) { + return this.context.getExecutionVariableDescriptor(key); + } + protected void start() { + start(false); + } + + protected void startSimulation() { + if (!this.simulationAvailable) { + throw new IllegalStateException("Execution with id " + this.getId() + " does not contain simulable steps"); + } + start(true); + } + + protected void setInteractionSimulationDescriptor(LLMDescriptor interactionSimulationDescriptor) { + this.interactionSimulationDescriptor = interactionSimulationDescriptor; + this.context.setInteractionSimulationDescriptor(interactionSimulationDescriptor); + } + + protected void start(boolean simulateInteractions) { List missingAuthorizations = getMissingAuthorizationKeys(); if (!missingAuthorizations.isEmpty()) { throw new IllegalStateException("Missing required authorizations: " + String.join(", ", missingAuthorizations)); } + this.interactionSimulationEnabled = simulateInteractions; + this.context.setInteractionSimulationEnabled(simulateInteractions); + this.context.setInteractionSimulationDescriptor(this.interactionSimulationDescriptor); + this.context.getSteps().values().forEach(step -> { + if (step.getNode().isUserInteractive()) { + step.setSimulated(simulateInteractions); + } + }); if (this.context.getStatus() == ExecutionStatus.READY){ this.context.start(executorService); } else @@ -126,7 +186,7 @@ public class ExecutionObject { protected ExecutionStatus resume() { ExecutionStatus resumedStatus = this.context.resumeFromSuspended(); if (resumedStatus == ExecutionStatus.READY) { - start(); + start(this.interactionSimulationEnabled); return this.context.getStatus(); } return resumedStatus; @@ -186,9 +246,27 @@ public class ExecutionObject { } if (snapshot != null) { this.context.restore(snapshot); + this.interactionSimulationEnabled = snapshot.isInteractionSimulationEnabled(); + this.interactionSimulationDescriptor = snapshot.getInteractionSimulationDescriptor(); } this.providedAuthorizations.forEach(this.context::setAuthorization); + this.context.setInteractionSimulationDescriptor(this.interactionSimulationDescriptor); + this.simulationAvailable = hasSimulationAvailable(new ArrayList<>(this.context.getSteps().values())); refreshInitializationStatus(); } + private boolean hasSimulationAvailable(List> steps) { + boolean hasInteractiveSteps = false; + for (Step step : steps) { + if (!step.getNode().isUserInteractive()) { + continue; + } + hasInteractiveSteps = true; + if (!NodeExecutors.supportsSimulation(step.getNode())) { + return false; + } + } + return hasInteractiveSteps; + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java new file mode 100644 index 0000000..787fad0 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionSimulationRequest.java @@ -0,0 +1,9 @@ +package it.cnr.isti.workflow.manager.executions; + +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record ExecutionSimulationRequest( + @Valid @NotNull LLMDescriptor simulator) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionTemplateResolver.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionTemplateResolver.java new file mode 100644 index 0000000..a7c1071 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionTemplateResolver.java @@ -0,0 +1,53 @@ +package it.cnr.isti.workflow.manager.executions; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.executions.steps.Input; + +public final class ExecutionTemplateResolver { + + private ExecutionTemplateResolver() { + } + + public static String resolve(String template, Iterable inputs, Map executionVariables) { + Map values = new LinkedHashMap<>(); + if (inputs != null) { + for (Input input : inputs) { + values.put(input.getDescriptor().getName(), input.getValue()); + } + } + return resolve(template, values, executionVariables); + } + + public static String resolve(String template, Map values, Map executionVariables) { + if (!StringUtils.hasText(template)) { + return template; + } + String resolved = template; + if (values != null) { + for (Map.Entry entry : values.entrySet()) { + resolved = resolved.replace("${{" + entry.getKey() + "}}", formatValue(entry.getValue())); + } + } + if (executionVariables != null) { + for (Map.Entry entry : executionVariables.entrySet()) { + resolved = resolved.replace("${{vars." + entry.getKey() + "}}", formatValue(entry.getValue())); + } + } + return resolved; + } + + public static String formatValue(Object value) { + if (value instanceof Collection collection) { + return collection.stream() + .map(item -> item == null ? "null" : item.toString()) + .reduce((left, right) -> left + System.lineSeparator() + right) + .orElse(""); + } + return value == null ? "null" : value.toString(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableCleanupPolicy.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableCleanupPolicy.java new file mode 100644 index 0000000..b9ccc5b --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableCleanupPolicy.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.executions; + +public enum ExecutionVariableCleanupPolicy { + NONE, + CLOSE_RESOURCE, + DELETE_FILE +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableDescriptor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableDescriptor.java new file mode 100644 index 0000000..c1bd002 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableDescriptor.java @@ -0,0 +1,22 @@ +package it.cnr.isti.workflow.manager.executions; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionVariableDescriptor { + + private String name; + @Builder.Default + private ExecutionVariableKind kind = ExecutionVariableKind.ANY; + private Object value; + private String producerBlockId; + private String description; + @Builder.Default + private ExecutionVariableCleanupPolicy cleanupPolicy = ExecutionVariableCleanupPolicy.NONE; +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableKind.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableKind.java new file mode 100644 index 0000000..7defca3 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableKind.java @@ -0,0 +1,10 @@ +package it.cnr.isti.workflow.manager.executions; + +public enum ExecutionVariableKind { + ANY, + TEXT, + JSON, + FILE_PATH, + HTTP_RESOURCE, + MCP_SESSION +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableRegistry.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableRegistry.java new file mode 100644 index 0000000..4fb40ea --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionVariableRegistry.java @@ -0,0 +1,53 @@ +package it.cnr.isti.workflow.manager.executions; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.util.StringUtils; + +public final class ExecutionVariableRegistry { + + private ExecutionVariableRegistry() { + } + + public static ExecutionVariableDescriptor normalize(ExecutionVariableDescriptor descriptor) { + if (descriptor == null || !StringUtils.hasText(descriptor.getName())) { + throw new IllegalArgumentException("Execution variable name must not be blank"); + } + return ExecutionVariableDescriptor.builder() + .name(descriptor.getName().trim()) + .kind(descriptor.getKind() == null ? ExecutionVariableKind.ANY : descriptor.getKind()) + .value(descriptor.getValue()) + .producerBlockId(descriptor.getProducerBlockId()) + .description(descriptor.getDescription()) + .cleanupPolicy(descriptor.getCleanupPolicy() == null + ? ExecutionVariableCleanupPolicy.NONE + : descriptor.getCleanupPolicy()) + .build(); + } + + public static Map valuesView(Map descriptors) { + LinkedHashMap values = new LinkedHashMap<>(); + if (descriptors == null) { + return values; + } + descriptors.values().stream() + .map(ExecutionVariableRegistry::normalize) + .forEach(descriptor -> values.put(descriptor.getName(), descriptor.getValue())); + return values; + } + + public static Map descriptorsFromValues(Map values) { + LinkedHashMap descriptors = new LinkedHashMap<>(); + if (values == null) { + return descriptors; + } + values.forEach((key, value) -> descriptors.put(key, ExecutionVariableDescriptor.builder() + .name(key) + .kind(ExecutionVariableKind.ANY) + .value(value) + .cleanupPolicy(ExecutionVariableCleanupPolicy.NONE) + .build())); + return descriptors; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 2a70217..ee29c51 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -8,6 +8,8 @@ import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration; @@ -15,6 +17,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockCo import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration; import it.cnr.isti.workflow.manager.containers.Container; import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration; import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot; @@ -25,6 +28,8 @@ import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.validation.FlowExecutionValidator; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; +import it.cnr.isti.workflow.manager.mcp.MCPAgentService; +import it.cnr.isti.workflow.manager.mcp.MCPSharedSessionRegistry; @Service public class ExecutionsService { @@ -40,6 +45,9 @@ public class ExecutionsService { @Autowired ExecutionRepository executionRepository; + @Autowired + MCPAgentService mcpAgentService; + public ExecutionObject createExecution(String executionName, FlowData flow) { flowExecutionValidator.validate(flow); List requiredAuthorizations = resolveRequiredAuthorizations(flow); @@ -132,12 +140,64 @@ public class ExecutionsService { return eo; } + public ExecutionObject setExecutionVariable(String executionId, String key, Object value) { + ExecutionObject eo = getExecution(executionId); + eo.setExecutionVariable(key, value); + return eo; + } + + public ExecutionObject setExecutionVariables(String executionId, Map executionVariables) { + ExecutionObject eo = getExecution(executionId); + eo.setExecutionVariables(executionVariables); + return eo; + } + + public ExecutionObject setExecutionVariableDescriptors(String executionId, + Map executionVariableDescriptors) { + ExecutionObject eo = getExecution(executionId); + eo.setExecutionVariableDescriptors(executionVariableDescriptors); + return eo; + } + + public ExecutionObject registerExecutionVariable(String executionId, ExecutionVariableDescriptor descriptor) { + ExecutionObject eo = getExecution(executionId); + eo.registerExecutionVariable(descriptor); + return eo; + } + + public Object getExecutionVariable(String executionId, String key) { + return getExecution(executionId).getExecutionVariable(key); + } + + public ExecutionVariableDescriptor getExecutionVariableDescriptor(String executionId, String key) { + return getExecution(executionId).getExecutionVariableDescriptor(key); + } + + public Object removeExecutionVariable(String executionId, String key) { + return getExecution(executionId).removeExecutionVariable(key); + } + public ExecutionObject startExecution(String id) { ExecutionObject eo = getExecution(id); eo.start(); return eo; } + public ExecutionObject startSimulationExecution(String id, LLMDescriptor simulatorDescriptor) { + ExecutionObject eo = getExecution(id); + if (!eo.isSimulationAvailable()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Execution does not contain simulable steps"); + } + if (simulatorDescriptor == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Simulation requires a simulator descriptor"); + } + eo.setInteractionSimulationDescriptor(simulatorDescriptor); + eo.startSimulation(); + return eo; + } + public ExecutionObject resumeExecution(String id) { ExecutionObject eo = getExecution(id); if (eo.getContext().getStatus().isFinalState()) { @@ -173,7 +233,8 @@ public class ExecutionsService { return; } for (Block block : flow.getBlocks() == null ? List.>of() : flow.getBlocks()) { - collectRequirement(requirements, block, resolveDescriptor(block), block == null ? null : block.getName()); + resolveDescriptors(block).forEach(descriptor -> + collectRequirement(requirements, block, descriptor, block == null ? null : block.getName())); collectHttpRequirement(requirements, block); } for (Container container : flow.getContainers() == null ? List.>of() : flow.getContainers()) { @@ -183,25 +244,34 @@ public class ExecutionsService { } } - private LLMDescriptor resolveDescriptor(Block block) { + private List resolveDescriptors(Block block) { if (block == null || block.getSpecificConfiguration() == null) { - return null; + return List.of(); } Object configuration = block.getSpecificConfiguration(); if (configuration instanceof LLMBlockConfiguration llmConfiguration) { - return llmConfiguration.getLlmDescriptor(); + return listOfDescriptors(llmConfiguration.getLlmDescriptor()); } if (configuration instanceof HumanInteractiveBlockConfiguration humanConfiguration) { - return humanConfiguration.getSimulateWith(); + return List.of(); } if (configuration instanceof ChatInteractionBlockConfiguration chatConfiguration) { - return chatConfiguration.getLlmDescriptor(); + return listOfDescriptors(chatConfiguration.getLlmDescriptor()); + } + if (configuration instanceof MCPAgentChatBlockConfiguration mcpAgentChatConfiguration) { + return List.of(); } if (configuration instanceof ConditionalBlockConfiguration conditionalConfiguration && conditionalConfiguration.isUseLlm()) { - return conditionalConfiguration.getLlmDescriptor(); + return listOfDescriptors(conditionalConfiguration.getLlmDescriptor()); } - return null; + return List.of(); + } + + private List listOfDescriptors(LLMDescriptor... descriptors) { + return java.util.Arrays.stream(descriptors) + .filter(Objects::nonNull) + .toList(); } private void collectRequirement(Map requirements, Block block, LLMDescriptor descriptor, @@ -246,7 +316,10 @@ public class ExecutionsService { } private void attachPersistence(ExecutionObject executionObject) { - executionObject.setStateChangeListener(() -> persist(executionObject)); + executionObject.setStateChangeListener(() -> { + persist(executionObject); + cleanupManagedResourcesIfFinal(executionObject); + }); } private void persist(ExecutionObject executionObject) { @@ -275,6 +348,31 @@ public class ExecutionsService { return executionObject; } + private void cleanupManagedResourcesIfFinal(ExecutionObject executionObject) { + if (executionObject == null || !executionObject.getContext().getStatus().isFinalState()) { + return; + } + Map executionVariableDescriptors = + executionObject.getContext().getExecutionVariableDescriptors(); + if (executionVariableDescriptors == null || executionVariableDescriptors.isEmpty()) { + return; + } + java.util.LinkedHashSet sessionIds = new java.util.LinkedHashSet<>(); + java.util.List cleanedDescriptors = new java.util.ArrayList<>(); + for (ExecutionVariableDescriptor descriptor : executionVariableDescriptors.values()) { + if (descriptor == null + || descriptor.getKind() != ExecutionVariableKind.MCP_SESSION + || descriptor.getCleanupPolicy() != ExecutionVariableCleanupPolicy.CLOSE_RESOURCE + || descriptor.getValue() == null) { + continue; + } + sessionIds.add(String.valueOf(descriptor.getValue())); + cleanedDescriptors.add(descriptor); + } + sessionIds.forEach(mcpAgentService::closeSessionQuietly); + cleanedDescriptors.forEach(descriptor -> descriptor.setCleanupPolicy(ExecutionVariableCleanupPolicy.NONE)); + } + private static class RequirementAccumulator { private final String key; private final String provider; diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/design/FlowSharedVariableCatalogService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/design/FlowSharedVariableCatalogService.java new file mode 100644 index 0000000..c1fdf97 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/design/FlowSharedVariableCatalogService.java @@ -0,0 +1,138 @@ +package it.cnr.isti.workflow.manager.executions.design; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind; +import it.cnr.isti.workflow.manager.flows.FlowService; +import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.model.FlowView; + +@Service +public class FlowSharedVariableCatalogService { + + public record SharedVariableDefinition( + String name, + ExecutionVariableKind kind, + String producerBlockId, + String producerBlockName) { + } + + private final FlowService flowService; + private final Map>> draftBlocksByFlowId = new ConcurrentHashMap<>(); + private final Map> removedBlockIdsByFlowId = new ConcurrentHashMap<>(); + + public FlowSharedVariableCatalogService(FlowService flowService) { + this.flowService = flowService; + } + + public List listForFlow(String flowId, String owner, ExecutionVariableKind kind) { + List definitions = List.of(); + if (StringUtils.hasText(flowId)) { + LinkedHashMap> mergedBlocks = new LinkedHashMap<>(); + FlowView flowView = flowService.getFlow(flowId, owner); + if (flowView != null && flowView.flow() != null && flowView.flow().getBlocks() != null) { + flowView.flow().getBlocks().forEach(block -> { + if (block != null && StringUtils.hasText(block.getId())) { + mergedBlocks.put(block.getId(), block); + } + }); + } + removedBlockIdsByFlowId.getOrDefault(flowId, Set.of()).forEach(mergedBlocks::remove); + mergedBlocks.putAll(draftBlocksByFlowId.getOrDefault(flowId, Map.of())); + definitions = analyze(new ArrayList<>(mergedBlocks.values())); + } + return definitions.stream() + .filter(definition -> kind == null || definition.kind() == kind) + .sorted(Comparator.comparing(SharedVariableDefinition::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + public void registerDraftBlock(String flowId, Block block) { + registerDraftBlock(flowId, block, null); + } + + public void registerDraftBlock(String flowId, Block block, String replacedBlockId) { + if (!StringUtils.hasText(flowId) || block == null) { + return; + } + if (StringUtils.hasText(replacedBlockId) && !replacedBlockId.equals(block.getId())) { + removedBlockIdsByFlowId.computeIfAbsent(flowId, ignored -> ConcurrentHashMap.newKeySet()) + .add(replacedBlockId.trim()); + } + draftBlocksByFlowId.compute(flowId, (ignored, current) -> { + LinkedHashMap> updated = new LinkedHashMap<>(); + if (current != null) { + updated.putAll(current); + } + updated.put(block.getId(), block); + return updated; + }); + removedBlockIdsByFlowId.computeIfPresent(flowId, (ignored, removed) -> { + removed.remove(block.getId()); + return removed.isEmpty() ? null : removed; + }); + } + + public void removeDraftBlock(String flowId, String blockId) { + if (!StringUtils.hasText(flowId) || !StringUtils.hasText(blockId)) { + return; + } + draftBlocksByFlowId.computeIfPresent(flowId, (ignored, current) -> { + current.remove(blockId); + return current.isEmpty() ? null : current; + }); + removedBlockIdsByFlowId.computeIfAbsent(flowId, ignored -> ConcurrentHashMap.newKeySet()) + .add(blockId.trim()); + } + + private List analyze(FlowData flowData) { + if (flowData == null) { + return List.of(); + } + return analyze(flowData.getBlocks() == null ? List.of() : flowData.getBlocks()); + } + + private List analyze(List> blocks) { + LinkedHashMap definitionsByKey = new LinkedHashMap<>(); + for (Block block : blocks) { + if (block == null || block.getSpecificConfiguration() == null) { + continue; + } + if (block.getSpecificConfiguration() instanceof MCPAgentBlockConfiguration configuration) { + String producedName = producedName(configuration.getShareSession(), configuration.getSharedSessionName()); + if (StringUtils.hasText(producedName)) { + SharedVariableDefinition definition = new SharedVariableDefinition(producedName, ExecutionVariableKind.MCP_SESSION, + block.getId(), block.getName()); + definitionsByKey.put(definition.kind().name() + ":" + definition.name(), definition); + } + } else if (block.getSpecificConfiguration() instanceof MCPAgentChatBlockConfiguration configuration) { + String producedName = producedName(configuration.getShareSession(), configuration.getSharedSessionName()); + if (StringUtils.hasText(producedName)) { + SharedVariableDefinition definition = new SharedVariableDefinition(producedName, ExecutionVariableKind.MCP_SESSION, + block.getId(), block.getName()); + definitionsByKey.put(definition.kind().name() + ":" + definition.name(), definition); + } + } + } + return new ArrayList<>(definitionsByKey.values()); + } + + private String producedName(Boolean shareSession, String sharedSessionName) { + if (Boolean.TRUE.equals(shareSession) && StringUtils.hasText(sharedSessionName)) { + return sharedSessionName.trim(); + } + return null; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/NodeExecutors.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/NodeExecutors.java index b9813db..c521317 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/NodeExecutors.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/NodeExecutors.java @@ -5,9 +5,11 @@ import java.util.Map; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.containers.Container; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.flows.model.FlowNode; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; public final class NodeExecutors { @@ -24,28 +26,52 @@ public final class NodeExecutors { return false; } - public static Map execute(FlowNode node, List inputs, Map authorizations) { + public static boolean supportsSimulation(FlowNode node) { if (node instanceof Block block) { - return BlockExecutors.get(block.getType()).execute((Block) block, inputs, authorizations); + return BlockExecutors.get(block.getType()).supportsSimulation(); + } + return false; + } + + public static Map execute(FlowNode node, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { + if (node instanceof Block block) { + return BlockExecutors.get(block.getType()).execute((Block) block, inputs, authorizations, executionVariables, + executionVariableDescriptors); } if (node instanceof Container container) { - return ContainerExecutors.get(container.getType()).execute((Container) container, inputs, authorizations); + return ContainerExecutors.get(container.getType()).execute((Container) container, inputs, authorizations, + executionVariables, executionVariableDescriptors); } throw new IllegalStateException("No executor found for node type " + node.getClass().getName()); } - public static InteractionResult interact(FlowNode node, List inputs, Map interaction, - Map partialResults, Map authorizations) { + public static Map simulate(FlowNode node, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors, + LLMDescriptor simulatorDescriptor) { if (node instanceof Block block) { - return BlockExecutors.get(block.getType()).interact((Block) block, inputs, interaction, partialResults, authorizations); + return BlockExecutors.get(block.getType()).simulate((Block) block, inputs, authorizations, executionVariables, + executionVariableDescriptors, simulatorDescriptor); + } + throw new IllegalStateException("No simulation executor found for node type " + node.getClass().getName()); + } + + public static InteractionResult interact(FlowNode node, List inputs, Map interaction, + Map partialResults, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { + if (node instanceof Block block) { + return BlockExecutors.get(block.getType()).interact((Block) block, inputs, interaction, partialResults, + authorizations, executionVariables, executionVariableDescriptors); } throw new IllegalStateException("No interactive executor found for node type " + node.getClass().getName()); } public static void cancel(FlowNode node, List inputs, Map partialResults, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { if (node instanceof Block block) { - BlockExecutors.get(block.getType()).cancel((Block) block, inputs, partialResults, authorizations); + BlockExecutors.get(block.getType()).cancel((Block) block, inputs, partialResults, authorizations, + executionVariables, executionVariableDescriptors); } } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/BlockExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/BlockExecutor.java index ec309ab..f99fa1e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/BlockExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/BlockExecutor.java @@ -5,12 +5,15 @@ import java.util.Map; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.executions.InteractionResult; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.blocks.types.BlockType; import it.cnr.isti.workflow.manager.executions.steps.Input; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; public interface BlockExecutor { - Map execute(Block block, List inputs, Map context); + Map execute(Block block, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors); Class getBlockType(); @@ -18,18 +21,26 @@ public interface BlockExecutor { return false; } - default Map simulate(Block block, List inputs, Map authorizations) + default boolean supportsSimulation() { + return isInteractive(); + } + + default Map simulate(Block block, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors, + LLMDescriptor simulatorDescriptor) { throw new UnsupportedOperationException("Simulation not supported for this block type"); } default InteractionResult interact(Block block, List inputs, Map interaction, - Map partialResults, Map authorizations) { + Map partialResults, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { throw new UnsupportedOperationException("Interaction not supported for this block type"); } default void cancel(Block block, List inputs, Map partialResults, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java index f16d55b..d136de2 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java @@ -15,6 +15,8 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.ChatInteractionBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.llms.ChatMessage; @@ -24,13 +26,59 @@ import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component public class ChatInteractionExecutor implements BlockExecutor { + private static final int MAX_SIMULATED_INTERACTIONS = 5; + @Autowired private Map llmProviders; @Override public Map execute(Block block, List inputs, - Map authorizations) { - throw new UnsupportedOperationException("ChatInteraction blocks require user interaction."); + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { + throw new UnsupportedOperationException("ChatInteraction blocks require user interaction unless execution simulation is enabled."); + } + + @Override + public Map simulate(Block block, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors, + LLMDescriptor simulatorDescriptor) { + ChatInteractionBlockConfiguration configuration = + (ChatInteractionBlockConfiguration) block.getSpecificConfiguration(); + if (simulatorDescriptor == null) { + throw new IllegalArgumentException("Missing simulation descriptor for ChatInteraction execution"); + } + if (!StringUtils.hasText(configuration.getGoalDescription())) { + throw new IllegalArgumentException("Simulated ChatInteraction requires goalDescription"); + } + + LLMDescriptor chatDescriptor = configuration.getLlmDescriptor(); + LLMProvider chatProvider = resolveProvider(chatDescriptor.provider()); + String chatAuthorization = resolveAuthorization(chatProvider, chatDescriptor, authorizations); + + LLMProvider simulatorProvider = resolveProvider(simulatorDescriptor.provider()); + String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, authorizations); + + String resolvedGoal = resolvePlaceholders(configuration.getGoalDescription(), inputs, executionVariables); + List history = new ArrayList<>(); + + for (int turn = 1; turn <= MAX_SIMULATED_INTERACTIONS; turn++) { + String simulatedMessage = generateSimulatorMessage(simulatorProvider, simulatorDescriptor, simulatorAuthorization, + resolvedGoal, inputs, history, turn); + history.add(formatConversationLine(ChatMessage.Role.USER, simulatedMessage)); + + List messages = history.stream().map(this::parseHistoryLine).collect(Collectors.toList()); + String assistantResponse = chatAuthorization == null + ? chatProvider.chat(chatDescriptor.model(), List.copyOf(messages)) + : chatProvider.chat(chatDescriptor.model(), List.copyOf(messages), chatAuthorization); + history.add(formatConversationLine(ChatMessage.Role.ASSISTANT, assistantResponse)); + } + + String finalResponse = generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor, simulatorAuthorization, + resolvedGoal, inputs, history); + return Map.of( + ChatInteractionBlockFactory.RESPONSE_OUTPUT, finalResponse, + ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(history)); } @Override @@ -45,7 +93,8 @@ public class ChatInteractionExecutor implements BlockExecutor block, List inputs, - Map interaction, Map partialResults, Map authorizations) { + Map interaction, Map partialResults, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { ChatInteractionBlockConfiguration configuration = (ChatInteractionBlockConfiguration) block.getSpecificConfiguration(); LLMDescriptor llmDescriptor = configuration.getLlmDescriptor(); @@ -63,7 +112,7 @@ public class ChatInteractionExecutor implements BlockExecutor history = existingHistory(partialResults); List messages = history.stream() .map(this::parseHistoryLine) @@ -112,6 +161,75 @@ public class ChatInteractionExecutor implements BlockExecutor authorizations) { + if (!provider.requiresAuthorization()) { + return null; + } + String authKey = provider.authorizationKey(); + if (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey)))) { + throw new IllegalArgumentException("Missing authorization for provider: " + descriptor.provider()); + } + return String.valueOf(authorizations.get(authKey)); + } + + private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, + String simulatorAuthorization, String goalDescription, List inputs, List history, int turn) { + String prompt = """ + ###SIMULATED_CHAT_MESSAGE### + Goal: + %s + + Turn: %d/%d + + Inputs: + %s + + Conversation so far: + %s + + Return only the next user message for the conversation. + """.formatted(goalDescription, turn, MAX_SIMULATED_INTERACTIONS, formatInputs(inputs), formatHistory(history)); + String response = simulatorAuthorization == null + ? simulatorProvider.generate(simulatorDescriptor.model(), prompt) + : simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization); + if (!StringUtils.hasText(response)) { + throw new IllegalArgumentException("Simulated ChatInteraction produced an empty message"); + } + return stripDirective(response, "MESSAGE:"); + } + + private String generateSimulatorFinalResponse(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, + String simulatorAuthorization, String goalDescription, List inputs, List history) { + String prompt = """ + ###SIMULATED_CHAT_FINAL### + Goal: + %s + + Inputs: + %s + + Final conversation: + %s + + Return only the final response value that the simulated user would submit. + """.formatted(goalDescription, formatInputs(inputs), formatHistory(history)); + String response = simulatorAuthorization == null + ? simulatorProvider.generate(simulatorDescriptor.model(), prompt) + : simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization); + if (!StringUtils.hasText(response)) { + throw new IllegalArgumentException("Simulated ChatInteraction produced an empty final response"); + } + return stripDirective(response, "FINAL:"); + } + + private String stripDirective(String value, String directive) { + String normalized = value == null ? "" : value.strip(); + if (normalized.startsWith(directive)) { + return normalized.substring(directive.length()).strip(); + } + return normalized; + } + private String formatConversationLine(ChatMessage.Role role, String content) { return "[" + role.name() + "] " + content; } @@ -142,27 +260,24 @@ public class ChatInteractionExecutor implements BlockExecutor inputs) { - if (!StringUtils.hasText(template)) { - return template; + private String resolvePlaceholders(String template, List inputs, Map executionVariables) { + return ExecutionTemplateResolver.resolve(template, inputs, executionVariables); + } + + private String formatInputs(List inputs) { + if (inputs == null || inputs.isEmpty()) { + return "(none)"; } - Map values = new LinkedHashMap<>(); - for (Input input : inputs) { - values.put(input.getDescriptor().getName(), formatInputValue(input.getValue())); - } - String resolved = template; - for (Map.Entry entry : values.entrySet()) { - resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue()); - } - return resolved; + return inputs.stream() + .map(input -> input.getDescriptor().getName() + "=" + formatInputValue(input.getValue())) + .collect(Collectors.joining(System.lineSeparator())); + } + + private String formatHistory(List history) { + return history == null || history.isEmpty() ? "(empty)" : String.join(System.lineSeparator(), history); } private String formatInputValue(Object value) { - if (value instanceof Collection collection) { - return collection.stream() - .map(item -> item == null ? "null" : item.toString()) - .collect(Collectors.joining(System.lineSeparator())); - } - return value == null ? "null" : value.toString(); + return ExecutionTemplateResolver.formatValue(value); } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java index 80c2dab..bdc2a1e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java @@ -20,6 +20,8 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @@ -44,13 +46,14 @@ public class ConditionalExecutor implements BlockExecutor @Override public Map execute(Block block, List inputs, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { ConditionalBlockConfiguration config = (ConditionalBlockConfiguration) block.getSpecificConfiguration(); Map inputValues = collectInputValues(inputs); boolean matches = config.isUseLlm() - ? evaluateWithLlm(config, inputValues, authorizations) - : evaluateWithExpression(config.getCondition(), inputValues); - String payload = resolvePlaceholders(config.getOutputTemplate(), inputValues); + ? evaluateWithLlm(config, inputValues, authorizations, executionVariables) + : evaluateWithExpression(config.getCondition(), inputValues, executionVariables); + String payload = resolvePlaceholders(config.getOutputTemplate(), inputValues, executionVariables); return matches ? Map.of(ConditionalBlockFactory.TRUE_OUTPUT, payload) : Map.of(ConditionalBlockFactory.FALSE_OUTPUT, payload); @@ -69,10 +72,12 @@ public class ConditionalExecutor implements BlockExecutor return values; } - private boolean evaluateWithExpression(String expression, Map inputValues) { + private boolean evaluateWithExpression(String expression, Map inputValues, + Map executionVariables) { String normalizedExpression = normalizeExpression(expression); StandardEvaluationContext context = new StandardEvaluationContext(inputValues); inputValues.forEach(context::setVariable); + context.setVariable("vars", executionVariables == null ? Map.of() : executionVariables); Boolean result = expressionParser.parseExpression(normalizedExpression).getValue(context, Boolean.class); if (result == null) { throw new IllegalArgumentException("Checker expression did not resolve to a boolean value"); @@ -81,7 +86,7 @@ public class ConditionalExecutor implements BlockExecutor } private boolean evaluateWithLlm(ConditionalBlockConfiguration config, Map inputValues, - Map authorizations) { + Map authorizations, Map executionVariables) { LLMDescriptor llmDescriptor = config.getLlmDescriptor(); LLMProvider llmProvider = resolveProvider(llmDescriptor.provider()); String authKey = llmProvider.authorizationKey(); @@ -90,7 +95,7 @@ public class ConditionalExecutor implements BlockExecutor throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider()); } - String prompt = buildLlmPrompt(config, inputValues); + String prompt = buildLlmPrompt(config, inputValues, executionVariables); String response = llmProvider.requiresAuthorization() ? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()) : llmProvider.generate(llmDescriptor.model(), prompt); @@ -108,10 +113,11 @@ public class ConditionalExecutor implements BlockExecutor .orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName)); } - private String buildLlmPrompt(ConditionalBlockConfiguration config, Map inputValues) { + private String buildLlmPrompt(ConditionalBlockConfiguration config, Map inputValues, + Map executionVariables) { StringBuilder builder = new StringBuilder(); builder.append(LLM_SYSTEM_PROMPT).append("\n"); - builder.append("Decision prompt: ").append(resolvePlaceholders(config.getPrompt(), inputValues)).append("\n"); + builder.append("Decision prompt: ").append(resolvePlaceholders(config.getPrompt(), inputValues, executionVariables)).append("\n"); builder.append("Inputs: ").append(inputValues).append("\n"); return builder.toString(); } @@ -126,12 +132,8 @@ public class ConditionalExecutor implements BlockExecutor return buffer.toString(); } - private String resolvePlaceholders(String template, Map inputValues) { - String resolved = template; - for (Map.Entry entry : inputValues.entrySet()) { - resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue() == null ? "null" : entry.getValue().toString()); - } - return resolved; + private String resolvePlaceholders(String template, Map inputValues, Map executionVariables) { + return ExecutionTemplateResolver.resolve(template, inputValues, executionVariables); } private boolean parseBooleanResponse(String response) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HTTPServerCallExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HTTPServerCallExecutor.java index 4fb3bf7..3ea7eb3 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HTTPServerCallExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HTTPServerCallExecutor.java @@ -11,6 +11,8 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.http.HTTPServerCallService; @@ -22,10 +24,11 @@ public class HTTPServerCallExecutor implements BlockExecutor execute(Block block, List inputs, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { HTTPServerCallBlockConfiguration config = (HTTPServerCallBlockConfiguration) block.getSpecificConfiguration(); - String resolvedUrl = resolveTemplate(config.getUrl(), inputs); - String resolvedRequestBody = resolveTemplate(config.getRequestBody(), inputs); + String resolvedUrl = resolveTemplate(config.getUrl(), inputs, executionVariables); + String resolvedRequestBody = resolveTemplate(config.getRequestBody(), inputs, executionVariables); String authorizationValue = null; if (Boolean.TRUE.equals(config.getRequiresAuthorization())) { String authorizationKey = config.authorizationKey(block.getId()); @@ -44,16 +47,7 @@ public class HTTPServerCallExecutor implements BlockExecutor inputs) { - if (!StringUtils.hasText(template)) { - return template; - } - String resolved = template; - for (Input input : inputs) { - Object value = input.getValue(); - resolved = resolved.replace("${{" + input.getDescriptor().getName() + "}}", - value == null ? "null" : value.toString()); - } - return resolved; + private String resolveTemplate(String template, List inputs, Map executionVariables) { + return ExecutionTemplateResolver.resolve(template, inputs, executionVariables); } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java index 9d53a1f..f16427a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java @@ -11,6 +11,7 @@ import org.springframework.util.StringUtils; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; @@ -25,16 +26,28 @@ public class HumanInteractionExecutor implements BlockExecutor llmProviders; @Override - public Map execute(Block block, List inputs, Map authorizations) { + public Map execute(Block block, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { + throw new UnsupportedOperationException("HumanInteraction blocks require user interaction unless execution simulation is enabled."); + } + + @Override + public Map simulate(Block block, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors, + LLMDescriptor simulatorDescriptor) { HumanInteractiveBlockConfiguration config = (HumanInteractiveBlockConfiguration) block.getSpecificConfiguration(); - //TODO: consider multi input cases String context = inputs.stream() .map(input -> "%s= %s".formatted(input.getDescriptor().getName(), input.getValue())) .collect(Collectors.joining(", ")); String prompt = "giving the following context as input { %s } perform the Task : '%s'" .formatted(context, config.getActionDescription()); logger.debug("Generated prompt for Human Interaction Block '{}': {}", block.getName(), prompt); - LLMDescriptor llmDescriptor = config.getSimulateWith(); + LLMDescriptor llmDescriptor = simulatorDescriptor; + if (llmDescriptor == null) { + throw new IllegalArgumentException("Missing simulation descriptor for HumanInteraction execution"); + } LLMProvider llmProvider = llmProviders.get(llmDescriptor.provider()); if (llmProvider == null) { llmProvider = llmProviders.values().stream() @@ -71,14 +84,10 @@ public class HumanInteractionExecutor implements BlockExecutor simulate(Block block, List inputs, Map authorizations) { - throw new UnsupportedOperationException("HumanInteractionExecutor does not support simulation."); - } - @Override public InteractionResult interact(Block block, List inputs, - Map interaction, Map partialResults, Map authorizations) { + Map interaction, Map partialResults, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { Object value = interaction.get("output"); if (value == null) { throw new IllegalArgumentException("Missing interaction value for field: output"); diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java index fe133b9..6b885da 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java @@ -1,9 +1,7 @@ package it.cnr.isti.workflow.manager.executions.executors.blocks; -import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +13,8 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @@ -28,21 +28,19 @@ public class LLMExecutor implements BlockExecutor { private Map llmProviders; @Override - public Map execute(Block block, List inputs, Map authorizations) { + public Map execute(Block block, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { logger.info("Executing LLM Block: {}", block.getName()); LLMBlockConfiguration config = (LLMBlockConfiguration) block.getSpecificConfiguration(); String prompt = config.getPrompt(); - if (prompt != null) - for (Input input : inputs) { - prompt = prompt.replaceAll("\\$\\{\\{" + input.getDescriptor().getName() + "\\}\\}", - formatInputValue(input.getValue())); - } - else { + if (prompt != null) { + prompt = ExecutionTemplateResolver.resolve(prompt, inputs, executionVariables); + } else { // If no prompt is defined in the configuration, use the first input's value as the prompt if (inputs.isEmpty()) throw new IllegalArgumentException("No inputs provided for LLM block and no prompt defined in configuration"); - prompt = formatInputValue(inputs.getFirst().getValue()); + prompt = ExecutionTemplateResolver.formatValue(inputs.getFirst().getValue()); } LLMDescriptor llmDescriptor = config.getLlmDescriptor(); @@ -76,13 +74,4 @@ public class LLMExecutor implements BlockExecutor { return LLMBlockType.class; } - private String formatInputValue(Object value) { - if (value instanceof Collection collection) { - return collection.stream() - .map(item -> item == null ? "null" : item.toString()) - .collect(Collectors.joining(System.lineSeparator())); - } - return value == null ? "null" : value.toString(); - } - } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java index a1f445e..1b09387 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java @@ -16,22 +16,86 @@ import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfigura import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentChatBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; import it.cnr.isti.workflow.manager.mcp.MCPAgentService; +import it.cnr.isti.workflow.manager.mcp.MCPSharedSessionRegistry; @Component public class MCPAgentChatExecutor implements BlockExecutor { private static final String SESSION_ID_STATE = "__sessionId"; + private static final int MAX_SIMULATED_INTERACTIONS = 5; @Autowired private MCPAgentService mcpAgentService; + @Autowired + private Map llmProviders; + @Override public Map execute(Block block, List inputs, - Map authorizations) { - throw new UnsupportedOperationException("MCPAgentChat blocks require user interaction."); + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { + throw new UnsupportedOperationException("MCPAgentChat blocks require user interaction unless execution simulation is enabled."); + } + + @Override + public Map simulate(Block block, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors, + LLMDescriptor simulatorDescriptor) { + MCPAgentChatBlockConfiguration configuration = + (MCPAgentChatBlockConfiguration) block.getSpecificConfiguration(); + if (!StringUtils.hasText(configuration.getGoalDescription())) { + throw new IllegalArgumentException("Simulated MCPAgentChat requires goalDescription"); + } + + String model = Boolean.TRUE.equals(configuration.getUseSharedSession()) + ? null + : resolveConfigurableInput(MCPAgentChatBlockFactory.MODEL_INPUT, configuration.getModel(), inputs); + + if (simulatorDescriptor == null) { + throw new IllegalArgumentException("Missing simulation descriptor for MCPAgentChat execution"); + } + LLMProvider simulatorProvider = resolveProvider(simulatorDescriptor.provider()); + String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, authorizations); + String resolvedGoal = resolvePlaceholders(configuration.getGoalDescription(), inputs, executionVariables); + List history = new ArrayList<>(); + String sharedSessionKey = normalize(Boolean.TRUE.equals(configuration.getUseSharedSession()) + ? configuration.getSharedSessionRef() + : null); + String shareSessionAs = normalize(Boolean.TRUE.equals(configuration.getShareSession()) + ? configuration.getSharedSessionName() + : null); + String sessionId = resolveOrCreateSession(block, configuration, executionVariables, executionVariableDescriptors, model, + sharedSessionKey, shareSessionAs); + boolean managedSharedSession = isManagedSharedSession(configuration, executionVariableDescriptors); + + try { + for (int turn = 1; turn <= MAX_SIMULATED_INTERACTIONS; turn++) { + String simulatedMessage = generateSimulatorMessage(simulatorProvider, simulatorDescriptor, simulatorAuthorization, + resolvedGoal, inputs, history, turn); + history.add(formatConversationLine("USER", simulatedMessage)); + + String assistantResponse = mcpAgentService.querySession(sessionId, simulatedMessage); + history.add(formatConversationLine("ASSISTANT", assistantResponse)); + } + + String finalResponse = generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor, simulatorAuthorization, + resolvedGoal, inputs, history); + return Map.of( + MCPAgentChatBlockFactory.RESPONSE_OUTPUT, finalResponse, + MCPAgentChatBlockFactory.HISTORY_OUTPUT, List.copyOf(history)); + } finally { + if (!managedSharedSession) { + mcpAgentService.closeSessionQuietly(sessionId); + } + } } @Override @@ -46,7 +110,8 @@ public class MCPAgentChatExecutor implements BlockExecutor block, List inputs, - Map interaction, Map partialResults, Map authorizations) { + Map interaction, Map partialResults, Map authorizations, + Map executionVariables, Map executionVariableDescriptors) { MCPAgentChatBlockConfiguration configuration = (MCPAgentChatBlockConfiguration) block.getSpecificConfiguration(); @@ -57,16 +122,22 @@ public class MCPAgentChatExecutor implements BlockExecutor history = existingHistory(partialResults); return InteractionResult.completed(Map.of( MCPAgentChatBlockFactory.RESPONSE_OUTPUT, response, @@ -108,8 +181,93 @@ public class MCPAgentChatExecutor implements BlockExecutor block, List inputs, Map partialResults, - Map authorizations) { - mcpAgentService.closeSessionQuietly(existingSessionId(partialResults)); + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { + MCPAgentChatBlockConfiguration configuration = + (MCPAgentChatBlockConfiguration) block.getSpecificConfiguration(); + if (!isManagedSharedSession(configuration, executionVariableDescriptors)) { + mcpAgentService.closeSessionQuietly(existingSessionId(partialResults)); + } + } + + private LLMProvider resolveProvider(String providerName) { + LLMProvider provider = llmProviders.get(providerName); + if (provider != null) { + return provider; + } + return llmProviders.values().stream() + .filter(candidate -> candidate.getName().equals(providerName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName)); + } + + private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, Map authorizations) { + if (!provider.requiresAuthorization()) { + return null; + } + String authKey = provider.authorizationKey(); + if (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey)))) { + throw new IllegalArgumentException("Missing authorization for provider: " + descriptor.provider()); + } + return String.valueOf(authorizations.get(authKey)); + } + + private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, + String simulatorAuthorization, String goalDescription, List inputs, List history, int turn) { + String prompt = """ + ###SIMULATED_CHAT_MESSAGE### + Goal: + %s + + Turn: %d/%d + + Inputs: + %s + + Conversation so far: + %s + + Return only the next user message for the conversation. + """.formatted(goalDescription, turn, MAX_SIMULATED_INTERACTIONS, formatInputs(inputs), formatHistory(history)); + String response = simulatorAuthorization == null + ? simulatorProvider.generate(simulatorDescriptor.model(), prompt) + : simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization); + if (!StringUtils.hasText(response)) { + throw new IllegalArgumentException("Simulated MCPAgentChat produced an empty message"); + } + return stripDirective(response, "MESSAGE:"); + } + + private String generateSimulatorFinalResponse(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, + String simulatorAuthorization, String goalDescription, List inputs, List history) { + String prompt = """ + ###SIMULATED_CHAT_FINAL### + Goal: + %s + + Inputs: + %s + + Final conversation: + %s + + Return only the final response value that the simulated user would submit. + """.formatted(goalDescription, formatInputs(inputs), formatHistory(history)); + String response = simulatorAuthorization == null + ? simulatorProvider.generate(simulatorDescriptor.model(), prompt) + : simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization); + if (!StringUtils.hasText(response)) { + throw new IllegalArgumentException("Simulated MCPAgentChat produced an empty final response"); + } + return stripDirective(response, "FINAL:"); + } + + private String stripDirective(String value, String directive) { + String normalized = value == null ? "" : value.strip(); + if (normalized.startsWith(directive)) { + return normalized.substring(directive.length()).strip(); + } + return normalized; } @SuppressWarnings("unchecked") @@ -126,10 +284,62 @@ public class MCPAgentChatExecutor implements BlockExecutor block, MCPAgentChatBlockConfiguration configuration, + Map executionVariables, Map executionVariableDescriptors, + String model, String sharedSessionKey, String shareSessionAs) { + String existingSharedSessionId = MCPSharedSessionRegistry.getSessionId(executionVariableDescriptors, sharedSessionKey); + if (StringUtils.hasText(existingSharedSessionId)) { + if (StringUtils.hasText(shareSessionAs)) { + MCPSharedSessionRegistry.registerSharedSession(executionVariableDescriptors, shareSessionAs, existingSharedSessionId, + block.getId()); + executionVariables.put(shareSessionAs, existingSharedSessionId); + } + return existingSharedSessionId; + } + if (StringUtils.hasText(sharedSessionKey)) { + throw new IllegalArgumentException("Shared MCP session not found: " + sharedSessionKey); + } + String sessionId = mcpAgentService.openSession(model, mapServers(configuration.getMcpServers())); + if (StringUtils.hasText(shareSessionAs)) { + MCPSharedSessionRegistry.registerSharedSession(executionVariableDescriptors, shareSessionAs, sessionId, block.getId()); + executionVariables.put(shareSessionAs, sessionId); + } + return sessionId; + } + + private boolean isManagedSharedSession(MCPAgentChatBlockConfiguration configuration, + Map executionVariableDescriptors) { + String sharedSessionKey = normalize(Boolean.TRUE.equals(configuration.getUseSharedSession()) + ? configuration.getSharedSessionRef() + : null); + String shareSessionAs = normalize(Boolean.TRUE.equals(configuration.getShareSession()) + ? configuration.getSharedSessionName() + : null); + return MCPSharedSessionRegistry.isManagedSharedSession(executionVariableDescriptors, sharedSessionKey) + || MCPSharedSessionRegistry.isManagedSharedSession(executionVariableDescriptors, shareSessionAs); + } + private String formatConversationLine(String role, String content) { return "[" + role + "] " + content; } + private String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private String formatInputs(List inputs) { + if (inputs == null || inputs.isEmpty()) { + return "(none)"; + } + return inputs.stream() + .map(input -> input.getDescriptor().getName() + "=" + formatInputValue(input.getValue())) + .collect(Collectors.joining(System.lineSeparator())); + } + + private String formatHistory(List history) { + return history == null || history.isEmpty() ? "(empty)" : String.join(System.lineSeparator(), history); + } + private String resolveConfigurableInput(String inputName, String configuredValue, List inputs) { return inputs.stream() .filter(input -> inputName.equals(input.getDescriptor().getName())) @@ -154,27 +364,11 @@ public class MCPAgentChatExecutor implements BlockExecutor inputs) { - if (!StringUtils.hasText(template)) { - return template; - } - Map values = new LinkedHashMap<>(); - for (Input input : inputs) { - values.put(input.getDescriptor().getName(), formatInputValue(input.getValue())); - } - String resolved = template; - for (Map.Entry entry : values.entrySet()) { - resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue()); - } - return resolved; + private String resolvePlaceholders(String template, List inputs, Map executionVariables) { + return ExecutionTemplateResolver.resolve(template, inputs, executionVariables); } private String formatInputValue(Object value) { - if (value instanceof Collection collection) { - return collection.stream() - .map(item -> item == null ? "null" : item.toString()) - .collect(Collectors.joining(System.lineSeparator())); - } - return value == null ? "null" : value.toString(); + return ExecutionTemplateResolver.formatValue(value); } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPBridgeExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPBridgeExecutor.java index 8809e7c..50d46dc 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPBridgeExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPBridgeExecutor.java @@ -11,8 +11,11 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.mcp.MCPAgentService; +import it.cnr.isti.workflow.manager.mcp.MCPSharedSessionRegistry; @Component public class MCPBridgeExecutor implements BlockExecutor { @@ -22,14 +25,50 @@ public class MCPBridgeExecutor implements BlockExecutor { @Override public Map execute(Block block, List inputs, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { MCPAgentBlockConfiguration config = (MCPAgentBlockConfiguration) block.getSpecificConfiguration(); - String model = resolveConfigurableInput("model", config.getModel(), inputs); - String prompt = resolvePrompt(config.getPrompt(), inputs); - if (!StringUtils.hasText(model)) { - throw new IllegalArgumentException("MCPAgent requires a model either from configuration or runtime input"); + String prompt = resolvePrompt(config.getPrompt(), inputs, executionVariables); + String model = Boolean.TRUE.equals(config.getUseSharedSession()) + ? null + : resolveConfigurableInput("model", config.getModel(), inputs); + String configuredSharedSessionKey = normalize(Boolean.TRUE.equals(config.getUseSharedSession()) + ? config.getSharedSessionRef() + : null); + String shareSessionAs = normalize(Boolean.TRUE.equals(config.getShareSession()) + ? config.getSharedSessionName() + : null); + String existingSharedSessionId = MCPSharedSessionRegistry.getSessionId(executionVariableDescriptors, configuredSharedSessionKey); + + String response; + if (StringUtils.hasText(existingSharedSessionId)) { + response = mcpAgentService.querySession(existingSharedSessionId, prompt); + if (StringUtils.hasText(shareSessionAs)) { + MCPSharedSessionRegistry.registerSharedSession(executionVariableDescriptors, shareSessionAs, existingSharedSessionId, + block.getId()); + executionVariables.put(shareSessionAs, existingSharedSessionId); + } + } else if (StringUtils.hasText(configuredSharedSessionKey)) { + throw new IllegalArgumentException("Shared MCP session not found: " + configuredSharedSessionKey); + } else if (StringUtils.hasText(shareSessionAs)) { + if (!StringUtils.hasText(model)) { + throw new IllegalArgumentException("MCPAgent requires a model either from configuration or runtime input"); + } + String sessionId = mcpAgentService.openSession(model, config.getMcpServers()); + try { + response = mcpAgentService.querySession(sessionId, prompt); + MCPSharedSessionRegistry.registerSharedSession(executionVariableDescriptors, shareSessionAs, sessionId, block.getId()); + executionVariables.put(shareSessionAs, sessionId); + } catch (RuntimeException ex) { + mcpAgentService.closeSessionQuietly(sessionId); + throw ex; + } + } else { + if (!StringUtils.hasText(model)) { + throw new IllegalArgumentException("MCPAgent requires a model either from configuration or runtime input"); + } + response = mcpAgentService.execute(model, prompt, config.getMcpServers()); } - String response = mcpAgentService.execute(model, prompt, config.getMcpServers()); return Map.of(MCPAgentBlockFactory.OUTPUT_NAME, response); } @@ -38,7 +77,7 @@ public class MCPBridgeExecutor implements BlockExecutor { return MCPAgentBlockType.class; } - private String resolvePrompt(String template, List inputs) { + private String resolvePrompt(String template, List inputs, Map executionVariables) { if (!StringUtils.hasText(template)) { return inputs.stream() .filter(input -> MCPAgentBlockFactory.INPUT_NAME.equals(input.getDescriptor().getName())) @@ -49,13 +88,7 @@ public class MCPBridgeExecutor implements BlockExecutor { ? null : inputs.getFirst().getValue().toString()); } - String resolved = template; - for (Input input : inputs) { - Object value = input.getValue(); - resolved = resolved.replace("${{" + input.getDescriptor().getName() + "}}", - value == null ? "null" : value.toString()); - } - return resolved; + return ExecutionTemplateResolver.resolve(template, inputs, executionVariables); } private String resolveConfigurableInput(String inputName, String configuredValue, List inputs) { @@ -67,4 +100,9 @@ public class MCPBridgeExecutor implements BlockExecutor { .filter(StringUtils::hasText) .orElse(configuredValue); } + + private String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/ContainerExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/ContainerExecutor.java index 2c7a0c9..5e929f5 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/ContainerExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/ContainerExecutor.java @@ -5,11 +5,13 @@ import java.util.Map; import it.cnr.isti.workflow.manager.containers.Container; import it.cnr.isti.workflow.manager.containers.types.ContainerType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.executions.steps.Input; public interface ContainerExecutor { - Map execute(Container container, List inputs, Map authorizations); + Map execute(Container container, List inputs, Map authorizations, + Map executionVariables, Map executionVariableDescriptors); Class getContainerType(); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/GenericContainerExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/GenericContainerExecutor.java index a0e778c..6d0c755 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/GenericContainerExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/GenericContainerExecutor.java @@ -13,6 +13,7 @@ import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterface import it.cnr.isti.workflow.manager.containers.types.GenericContainerType; import it.cnr.isti.workflow.manager.executions.ExecutionObject; import it.cnr.isti.workflow.manager.executions.ExecutionStatus; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.executions.ExecutionsService; import it.cnr.isti.workflow.manager.executions.FieldKey; import it.cnr.isti.workflow.manager.executions.steps.Input; @@ -24,7 +25,9 @@ public class GenericContainerExecutor implements ContainerExecutor execute(Container container, List inputs, Map authorizations) { + public Map execute(Container container, List inputs, + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { GenericContainerConfiguration configuration = (GenericContainerConfiguration) container.getSpecificConfiguration(); if (configuration.getSubFlow() == null || ((configuration.getSubFlow().getBlocks() == null || configuration.getSubFlow().getBlocks().isEmpty()) @@ -35,6 +38,7 @@ public class GenericContainerExecutor implements ContainerExecutor inputPortsByName = ContainerFlowInterfaceResolver .getExposedInputs(configuration.getSubFlow()).stream() @@ -68,6 +72,10 @@ public class GenericContainerExecutor implements ContainerExecutor outputs = new java.util.LinkedHashMap<>(); for (ContainerFlowInterfaceResolver.ExposedHandle exposedHandle : ContainerFlowInterfaceResolver diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/IteratorContainerExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/IteratorContainerExecutor.java index 78e560c..26d3a78 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/IteratorContainerExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/IteratorContainerExecutor.java @@ -13,6 +13,7 @@ import it.cnr.isti.workflow.manager.containers.iresolvers.IteratorContainerInter import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType; import it.cnr.isti.workflow.manager.executions.ExecutionObject; import it.cnr.isti.workflow.manager.executions.ExecutionStatus; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; import it.cnr.isti.workflow.manager.executions.ExecutionsService; import it.cnr.isti.workflow.manager.executions.FieldKey; import it.cnr.isti.workflow.manager.executions.steps.Input; @@ -28,7 +29,8 @@ public class IteratorContainerExecutor implements ContainerExecutor execute(Container container, List inputs, - Map authorizations) { + Map authorizations, Map executionVariables, + Map executionVariableDescriptors) { IteratorContainerConfiguration configuration = (IteratorContainerConfiguration) container.getSpecificConfiguration(); IteratorContainerInterfaceResolver.Resolution resolution = IteratorContainerInterfaceResolver.resolvePorts(configuration); IteratorContainerInterfaceResolver.ResolvedInput iteratedInput = resolution.inputsByPublicName() @@ -56,6 +58,7 @@ public class IteratorContainerExecutor implements ContainerExecutor executionVariables; + private Map executionVariableDescriptors; private Map providedAuthorizations; private Map inputs; private Map result; diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/steps/Step.java b/src/main/java/it/cnr/isti/workflow/manager/executions/steps/Step.java index a577c89..0f41157 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/steps/Step.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/steps/Step.java @@ -14,6 +14,7 @@ import it.cnr.isti.workflow.manager.executions.persistence.ExecutionInputSnapsho import it.cnr.isti.workflow.manager.executions.persistence.ExecutionStepSnapshot; import it.cnr.isti.workflow.manager.executions.executors.NodeExecutors; import it.cnr.isti.workflow.manager.flows.model.FlowNode; +import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -66,6 +67,18 @@ public class Step implements InputListener { @Getter private Map authorizations = Map.of(); + @Setter + @Getter + private Map executionVariables = Map.of(); + + @Setter + @Getter + private Map executionVariableDescriptors = Map.of(); + + @Setter + @Getter + private LLMDescriptor interactionSimulationDescriptor; + @Builder public Step(@NonNull N node) { // Initialize the step with the provided block @@ -130,7 +143,11 @@ public class Step implements InputListener { this.status = StepStatus.RUNNING; listener.started(this.id); try { - Map outputs = NodeExecutors.execute(this.node, this.inputs, authorizations); + Map outputs = isSimulated() && this.node.isUserInteractive() + ? NodeExecutors.simulate(this.node, this.inputs, authorizations, executionVariables, + executionVariableDescriptors, this.interactionSimulationDescriptor) + : NodeExecutors.execute(this.node, this.inputs, authorizations, executionVariables, + executionVariableDescriptors); for (Output output : this.outputs) { if (outputs.containsKey(output.getDescriptor().getName())) { output.setValue(outputs.get(output.getDescriptor().getName())); @@ -157,7 +174,8 @@ public class Step implements InputListener { logger.info("Resuming step " + this.id + " of node " + this.node.getName()); this.status = StepStatus.RUNNING; listener.resumed(this.id); - var interactionResult = NodeExecutors.interact(this.node, this.inputs, providedOutputs, Map.copyOf(this.partialResults), authorizations); + var interactionResult = NodeExecutors.interact(this.node, this.inputs, providedOutputs, + Map.copyOf(this.partialResults), authorizations, executionVariables, executionVariableDescriptors); this.partialResults.clear(); this.partialResults.putAll(interactionResult.partialResults()); listener.partialUpdated(this.id, this.partialResults); @@ -224,7 +242,8 @@ public class Step implements InputListener { return; } try { - NodeExecutors.cancel(this.node, this.inputs, Map.copyOf(this.partialResults), this.authorizations); + NodeExecutors.cancel(this.node, this.inputs, Map.copyOf(this.partialResults), this.authorizations, + this.executionVariables, this.executionVariableDescriptors); } catch (RuntimeException ex) { logger.warn("Failed to cancel resources for step {}", this.id, ex); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowExecutionValidator.java b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowExecutionValidator.java index 0c5b371..9e89786 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowExecutionValidator.java +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/validation/FlowExecutionValidator.java @@ -1,7 +1,10 @@ package it.cnr.isti.workflow.manager.flows.validation; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; @@ -10,8 +13,11 @@ import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration; import it.cnr.isti.workflow.manager.containers.Container; import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration; +import it.cnr.isti.workflow.manager.flows.model.Connection; import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.model.FlowNode; import jakarta.validation.ConstraintViolation; @@ -108,6 +114,122 @@ public class FlowExecutionValidator { } } + if (flowData != null) { + errors.addAll(validateSharedExecutionVariableOrdering(flowData)); + } + return errors; } + + private List validateSharedExecutionVariableOrdering(FlowData flowData) { + List errors = new ArrayList<>(); + Map> producerIdsByVariable = new HashMap<>(); + + for (FlowNode node : flowData.getNodes()) { + if (!(node instanceof Block block) || block.getSpecificConfiguration() == null) { + continue; + } + String producedName = sharedSessionProducedName(block); + if (producedName != null) { + producerIdsByVariable.computeIfAbsent(producedName, ignored -> new ArrayList<>()) + .add(block.getId()); + } + } + + producerIdsByVariable.forEach((variableName, producerIds) -> { + if (producerIds.size() <= 1) { + return; + } + producerIds.forEach(producerId -> errors.add(new ValidationError( + "block", + producerId, + "specificConfiguration.sharedSessionName", + "Shared MCP session name '" + variableName + "' must be unique within the flow"))); + }); + + if (producerIdsByVariable.isEmpty()) { + return errors; + } + + Map> outgoing = buildOutgoingGraph(flowData.getConnections()); + for (FlowNode node : flowData.getNodes()) { + if (!(node instanceof Block block) || block.getSpecificConfiguration() == null) { + continue; + } + String referencedName = sharedSessionReferencedName(block); + if (referencedName == null) { + continue; + } + List producerIds = producerIdsByVariable.getOrDefault(referencedName, List.of()); + boolean reachable = producerIds.stream() + .anyMatch(producerId -> !producerId.equals(block.getId()) + && isReachable(producerId, block.getId(), outgoing, new HashSet<>())); + if (!reachable) { + errors.add(new ValidationError( + "block", + block.getId(), + "specificConfiguration.sharedSessionRef", + "Shared MCP session '" + referencedName + "' must be produced by a block that executes before this block")); + } + } + + return errors; + } + + private Map> buildOutgoingGraph(List connections) { + Map> outgoing = new HashMap<>(); + if (connections == null) { + return outgoing; + } + for (Connection connection : connections) { + if (connection == null || connection.getSourceId() == null || connection.getTargetId() == null) { + continue; + } + outgoing.computeIfAbsent(connection.getSourceId(), ignored -> new ArrayList<>()) + .add(connection.getTargetId()); + } + return outgoing; + } + + private boolean isReachable(String sourceId, String targetId, Map> outgoing, Set visited) { + if (!visited.add(sourceId)) { + return false; + } + for (String nextId : outgoing.getOrDefault(sourceId, List.of())) { + if (targetId.equals(nextId) || isReachable(nextId, targetId, outgoing, visited)) { + return true; + } + } + return false; + } + + private String sharedSessionProducedName(Block block) { + if (block.getSpecificConfiguration() instanceof MCPAgentBlockConfiguration configuration) { + if (Boolean.TRUE.equals(configuration.getShareSession()) && hasText(configuration.getSharedSessionName())) { + return configuration.getSharedSessionName().trim(); + } + } else if (block.getSpecificConfiguration() instanceof MCPAgentChatBlockConfiguration configuration) { + if (Boolean.TRUE.equals(configuration.getShareSession()) && hasText(configuration.getSharedSessionName())) { + return configuration.getSharedSessionName().trim(); + } + } + return null; + } + + private String sharedSessionReferencedName(Block block) { + if (block.getSpecificConfiguration() instanceof MCPAgentBlockConfiguration configuration) { + if (Boolean.TRUE.equals(configuration.getUseSharedSession()) && hasText(configuration.getSharedSessionRef())) { + return configuration.getSharedSessionRef().trim(); + } + } else if (block.getSpecificConfiguration() instanceof MCPAgentChatBlockConfiguration configuration) { + if (Boolean.TRUE.equals(configuration.getUseSharedSession()) && hasText(configuration.getSharedSessionRef())) { + return configuration.getSharedSessionRef().trim(); + } + } + return null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPSharedSessionRegistry.java b/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPSharedSessionRegistry.java new file mode 100644 index 0000000..b2867f9 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/mcp/MCPSharedSessionRegistry.java @@ -0,0 +1,71 @@ +package it.cnr.isti.workflow.manager.mcp; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.executions.ExecutionVariableCleanupPolicy; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableRegistry; + +public final class MCPSharedSessionRegistry { + + private MCPSharedSessionRegistry() { + } + + public static String getSessionId(Map descriptors, String key) { + ExecutionVariableDescriptor descriptor = getSessionDescriptor(descriptors, key); + if (descriptor == null || descriptor.getValue() == null) { + return null; + } + String sessionId = String.valueOf(descriptor.getValue()).trim(); + return sessionId.isEmpty() ? null : sessionId; + } + + public static ExecutionVariableDescriptor getSessionDescriptor(Map descriptors, String key) { + if (!StringUtils.hasText(key) || descriptors == null) { + return null; + } + ExecutionVariableDescriptor descriptor = descriptors.get(key.trim()); + if (descriptor == null) { + return null; + } + ExecutionVariableDescriptor normalized = ExecutionVariableRegistry.normalize(descriptor); + return normalized.getKind() == ExecutionVariableKind.MCP_SESSION ? normalized : null; + } + + public static void registerSharedSession(Map descriptors, String key, String sessionId, + String producerBlockId) { + if (!StringUtils.hasText(key) || !StringUtils.hasText(sessionId) || descriptors == null) { + return; + } + ExecutionVariableDescriptor descriptor = ExecutionVariableDescriptor.builder() + .name(key.trim()) + .kind(ExecutionVariableKind.MCP_SESSION) + .value(sessionId) + .producerBlockId(producerBlockId) + .description("Shared MCP session") + .cleanupPolicy(ExecutionVariableCleanupPolicy.CLOSE_RESOURCE) + .build(); + descriptors.put(descriptor.getName(), descriptor); + } + + public static boolean isManagedSharedSession(Map descriptors, String key) { + return getSessionDescriptor(descriptors, key) != null; + } + + public static Set sharedKeys(Map descriptors) { + if (descriptors == null || descriptors.isEmpty()) { + return Set.of(); + } + LinkedHashSet keys = new LinkedHashSet<>(); + descriptors.values().stream() + .map(ExecutionVariableRegistry::normalize) + .filter(descriptor -> descriptor.getKind() == ExecutionVariableKind.MCP_SESSION) + .forEach(descriptor -> keys.add(descriptor.getName())); + return Set.copyOf(keys); + } +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java b/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java index 83c4783..98c56a6 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java @@ -105,6 +105,7 @@ public class BlockTest { MCPAgentChatBlockConfiguration config = MCPAgentChatBlockConfiguration.builder() .name("agent-chat-1") .model("llama3.1:8b") + .goalDescription("Assess the candidate") .inputs(List.of(new ChatInteractionInput("candidate", IOType.TEXT, false))) .mcpServers(List.of( MCPAgentChatBlockConfiguration.MCPServerBinding.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java index 9dc8ad2..59f2606 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java @@ -340,7 +340,6 @@ public class AssistantControllerTest { HumanInteractiveBlockConfiguration reviewConfiguration = HumanInteractiveBlockConfiguration.builder() .name("Human review") .actionDescription("Review high-risk tickets") - .simulateWith(llmDescriptor) .build(); Block reviewBlock = Block.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index d99e599..08c51c0 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -12,10 +12,12 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; import org.springframework.test.context.TestPropertySource; import com.fasterxml.jackson.databind.JsonNode; +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.IOCapabilityType; import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration; @@ -32,6 +34,11 @@ import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType; +import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys; +import it.cnr.isti.workflow.manager.configurations.retrievers.ExecutionVariablesFieldRetriever; +import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest; +import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.model.FlowView; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; @@ -43,6 +50,19 @@ public class BlocksControllerTest { @Autowired private BlocksController blocksController; + @Autowired + private FlowController flowController; + + @Autowired + private ExecutionVariablesFieldRetriever executionVariablesFieldRetriever; + + private LLMDescriptor simulatorDescriptor() { + return LLMDescriptor.builder() + .provider("testProvider") + .model("simulateModel") + .build(); + } + @Test public void getTypes(){ List types = blocksController.getTypes(); @@ -92,6 +112,7 @@ public class BlocksControllerTest { .provider("testProvider") .model("testModel") .build()) + .goalDescription("Assess the candidate") .inputs(List.of(new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT))) .build(); @@ -124,6 +145,7 @@ public class BlocksControllerTest { .provider("testProvider") .model("testModel") .build()) + .goalDescription("Assess the candidate") .inputs(List.of(new ChatInteractionInput("candidate", null))) .build(); @@ -148,6 +170,7 @@ public class BlocksControllerTest { "provider": "testProvider", "model": "testModel" }, + "goalDescription": "Assess the candidate", "inputs": [ { "name": "candidate", @@ -184,6 +207,7 @@ public class BlocksControllerTest { .provider("testProvider") .model("testModel") .build()) + .goalDescription("Assess the candidate") .inputs(List.of( new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT), new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT, true))) @@ -202,6 +226,7 @@ public class BlocksControllerTest { .provider("testProvider") .model("testModel") .build()) + .goalDescription("Assess the candidate") .inputs(List.of(new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.FILE))) .build(); @@ -215,6 +240,7 @@ public class BlocksControllerTest { MCPAgentChatBlockConfiguration config = MCPAgentChatBlockConfiguration.builder() .name("Agent chat") .model("llama3.1:8b") + .goalDescription("Assess the candidate") .inputs(List.of(new ChatInteractionInput("candidate", it.cnr.isti.workflow.manager.ios.IOType.TEXT))) .mcpServers(List.of(MCPAgentChatBlockConfiguration.MCPServerBinding.builder() .serverName("filesystem") @@ -231,6 +257,179 @@ public class BlocksControllerTest { assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple())); } + @Test + public void createMcpAgentChatBlockAllowsPartialConfigurationForUiDraft() { + MCPAgentChatBlockConfiguration config = MCPAgentChatBlockConfiguration.builder() + .name("Agent chat") + .model("llama3.1:8b") + .build(); + + Block block = blocksController.create(config); + + assertNotNull(block); + assertEquals(MCPAgentChatBlockType.TYPE, block.getType().getName()); + assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && !output.isMultiple())); + assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple())); + } + + @Test + public void executionVariablesRetrieverReturnsSharedMcpSessionsForFlow() { + Block block = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Shared agent") + .model("llama3.1:8b") + .prompt("Find data for ${{candidate}}") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + ResponseEntity createdFlow = flowController.createFlow( + new FlowCreateRequest( + "Shared Session Flow", + "Flow with MCP shared session", + FlowData.builder().block(block).build()), + new LoginEntity("testuser", "testpassword")); + + List items = + executionVariablesFieldRetriever.retrieve( + "shared", + java.util.Map.of("flowId", createdFlow.getBody().id(), "kind", "MCP_SESSION"), + new LoginEntity("testuser", "testpassword")); + + assertEquals(1, items.size()); + assertEquals("candidateResearchSession", items.getFirst().data()); + assertEquals("MCP_SESSION", items.getFirst().descriptor().meta().get("kind")); + } + + @Test + public void executionVariablesRetrieverUsesDraftReplacementForUnsavedBlockUpdates() { + Block initialBlock = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Shared agent") + .model("llama3.1:8b") + .prompt("Find data for ${{candidate}}") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + ResponseEntity createdFlow = flowController.createFlow( + new FlowCreateRequest( + "Shared Session Flow", + "Flow with MCP shared session", + FlowData.builder().block(initialBlock).build()), + new LoginEntity("testuser", "testpassword")); + + Block updatedBlock = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Shared agent") + .model("llama3.1:8b") + .prompt("Find data for ${{candidate}}") + .shareSession(true) + .sharedSessionName("renamedSharedSession") + .build(), + createdFlow.getBody().id(), + initialBlock.getId()); + + List items = + executionVariablesFieldRetriever.retrieve( + "shared", + java.util.Map.of("flowId", createdFlow.getBody().id(), "kind", "MCP_SESSION"), + new LoginEntity("testuser", "testpassword")); + + assertEquals(1, items.size()); + assertEquals(updatedBlock.getId(), items.getFirst().descriptor().meta().get("producerBlockId")); + assertEquals("renamedSharedSession", items.getFirst().data()); + } + + @Test + public void executionVariablesRetrieverDeduplicatesSharedSessionNamesPreferringLatestDraft() { + Block persistedBlock = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Persisted agent") + .model("llama3.1:8b") + .prompt("Find data for ${{candidate}}") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + ResponseEntity createdFlow = flowController.createFlow( + new FlowCreateRequest( + "Shared Session Flow", + "Flow with MCP shared session", + FlowData.builder().block(persistedBlock).build()), + new LoginEntity("testuser", "testpassword")); + + Block draftBlock = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Draft agent") + .model("llama3.1:8b") + .prompt("Find more data for ${{candidate}}") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build(), + createdFlow.getBody().id(), + null); + + List items = + executionVariablesFieldRetriever.retrieve( + "shared", + java.util.Map.of("flowId", createdFlow.getBody().id(), "kind", "MCP_SESSION"), + new LoginEntity("testuser", "testpassword")); + + assertEquals(1, items.size()); + assertEquals("candidateResearchSession", items.getFirst().data()); + assertEquals(draftBlock.getId(), items.getFirst().descriptor().meta().get("producerBlockId")); + assertEquals("Draft agent", items.getFirst().descriptor().meta().get("producerBlockName")); + } + + @Test + public void createMcpAgentBlockAllowsPartialConfigurationForUiDraft() { + MCPAgentBlockConfiguration config = MCPAgentBlockConfiguration.builder() + .name("Agent") + .shareSession(true) + .build(); + + Block block = blocksController.create(config); + + assertNotNull(block); + assertEquals(MCPAgentBlockType.TYPE, block.getType().getName()); + assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && !output.isMultiple())); + } + + @Test + public void mcpAgentSchemaMarksSharedSessionRetrieverAsAuthenticated() { + BlockConfigurationDescriptor descriptor = blocksController + .getConfigurationDescriptorForType(MCPAgentBlockType.TYPE); + + JsonNode schema = (JsonNode) descriptor.schema(); + JsonNode properties = schema.path("properties"); + JsonNode shareSession = properties.path("shareSession"); + JsonNode sharedSessionName = properties.path("sharedSessionName"); + JsonNode useSharedSession = properties.path("useSharedSession"); + JsonNode sharedSessionRef = schema.path("properties").path("sharedSessionRef"); + + assertEquals("boolean", shareSession.path("type").asText()); + assertTrue(shareSession.path("x-ui-structural").asBoolean()); + assertTrue(sharedSessionName.path("x-ui-structural").asBoolean()); + assertTrue(useSharedSession.path("x-ui-structural").asBoolean()); + assertTrue(sharedSessionRef.path("x-ui-structural").asBoolean()); + assertEquals("shareSession", sharedSessionName.path("x-ui-enabled-when").path("field").asText()); + assertEquals("true", sharedSessionName.path("x-ui-enabled-when").path("equals").asText()); + assertEquals("shareSession", sharedSessionName.path("x-ui-required-when").path("field").asText()); + assertEquals("true", sharedSessionName.path("x-ui-required-when").path("equals").asText()); + assertEquals("shareSession", useSharedSession.path("x-ui-enabled-when").path("field").asText()); + assertEquals("false", useSharedSession.path("x-ui-enabled-when").path("equals").asText()); + assertEquals("useSharedSession", sharedSessionRef.path("x-ui-enabled-when").path("field").asText()); + assertEquals("true", sharedSessionRef.path("x-ui-enabled-when").path("equals").asText()); + assertEquals("useSharedSession", sharedSessionRef.path("x-ui-required-when").path("field").asText()); + assertEquals("true", sharedSessionRef.path("x-ui-required-when").path("equals").asText()); + assertEquals("useSharedSession", properties.path("model").path("x-ui-enabled-when").path("field").asText()); + assertEquals("false", properties.path("model").path("x-ui-enabled-when").path("equals").asText()); + assertFalse(properties.path("prompt").has("x-ui-enabled-when")); + assertEquals("useSharedSession", properties.path("mcpServers").path("x-ui-enabled-when").path("field").asText()); + assertEquals("false", properties.path("mcpServers").path("x-ui-enabled-when").path("equals").asText()); + assertEquals("/secure-retriever/ExecutionVariables/shared/items?kind=MCP_SESSION", + sharedSessionRef.path("x-retriever-url").asText()); + assertEquals(UiContextKeys.FLOW_ID, + sharedSessionRef.path("x-retriever-depends-on").get(0).asText()); + assertTrue(sharedSessionRef.path("x-retriever-requires-auth").asBoolean()); + } + @Test public void mcpAgentChatDescriptorExposesInteractionContract() { BlockConfigurationDescriptor descriptor = blocksController @@ -446,6 +645,37 @@ public class BlocksControllerTest { assertEquals("filesystem", ((MCPAgentBlockConfiguration) block.getSpecificConfiguration()).getMcpServers().getFirst().serverName()); } + @Test + public void createMcpAgentBlockUsingSharedSessionDoesNotExposeModelInput() { + MCPAgentBlockConfiguration config = MCPAgentBlockConfiguration.builder() + .name("Bridge") + .prompt("Bridge prompt for ${{candidate}}") + .useSharedSession(true) + .sharedSessionRef("sharedSession") + .build(); + + Block block = blocksController.create(config); + + assertNotNull(block); + assertFalse(block.getInputs().stream().anyMatch(input -> input.getName().equals("model"))); + assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("candidate"))); + } + + @Test + public void createMcpAgentChatBlockUsingSharedSessionDoesNotExposeModelInput() { + MCPAgentChatBlockConfiguration config = MCPAgentChatBlockConfiguration.builder() + .name("Agent chat") + .goalDescription("Assess the candidate") + .useSharedSession(true) + .sharedSessionRef("sharedSession") + .build(); + + Block block = blocksController.create(config); + + assertNotNull(block); + assertFalse(block.getInputs().stream().anyMatch(input -> input.getName().equals("model"))); + } + @Test public void createHTTPServerCallBlock() { HTTPServerCallBlockConfiguration config = HTTPServerCallBlockConfiguration.builder() @@ -491,12 +721,12 @@ public class BlocksControllerTest { assertTrue(method.has("enum")); assertEquals("textarea", requestBody.path("x-ui-widget").asText()); assertTrue(requestBody.path("x-ui-structural").asBoolean()); - assertEquals("method", requestBody.path("x-ui-visible-when").path("field").asText()); - assertTrue(requestBody.path("x-ui-visible-when").path("in").isArray()); - assertTrue(requestBody.path("x-ui-visible-when").path("in").toString().contains("POST")); - assertTrue(requestBody.path("x-ui-visible-when").path("in").toString().contains("PUT")); - assertEquals("requiresAuthorization", authType.path("x-ui-visible-when").path("field").asText()); - assertEquals("authorizationType", authHeader.path("x-ui-visible-when").path("field").asText()); + assertEquals("method", requestBody.path("x-ui-enabled-when").path("field").asText()); + assertTrue(requestBody.path("x-ui-enabled-when").path("in").isArray()); + assertTrue(requestBody.path("x-ui-enabled-when").path("in").toString().contains("POST")); + assertTrue(requestBody.path("x-ui-enabled-when").path("in").toString().contains("PUT")); + assertEquals("requiresAuthorization", authType.path("x-ui-enabled-when").path("field").asText()); + assertEquals("authorizationType", authHeader.path("x-ui-enabled-when").path("field").asText()); } @Test diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java index ff612ef..cca389a 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java @@ -1,12 +1,16 @@ package it.cnr.isti.workflow.manager.controllers; +import java.util.List; + import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.http.HttpStatus; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.ResponseEntity; +import org.springframework.context.annotation.Bean; import org.springframework.test.context.TestPropertySource; import org.springframework.web.server.ResponseStatusException; @@ -15,11 +19,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput; import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType; import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; import it.cnr.isti.workflow.manager.executions.ExecutionObject; +import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest; import it.cnr.isti.workflow.manager.executions.ExecutionStatus; import it.cnr.isti.workflow.manager.executions.ExecutionAuthorizationValueRequest; import it.cnr.isti.workflow.manager.executions.ExecutionsService; @@ -28,7 +36,9 @@ import it.cnr.isti.workflow.manager.flows.model.Flow; import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest; import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.model.FlowView; +import it.cnr.isti.workflow.manager.llms.ChatMessage; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @SpringBootTest @TestPropertySource(locations = "classpath:test.properties") @@ -36,6 +46,41 @@ public class ExecutionControllerTest { private static final Logger logger = LoggerFactory.getLogger(ExecutionControllerTest.class); + @TestConfiguration + static class TestConfig { + + @Bean + public LLMProvider testProvider() { + return new LLMProvider() { + @Override + public String getName() { + return "testProvider"; + } + + @Override + public List getRegisteredModels() { + return List.of("testModel", "simulateModel"); + } + + @Override + public String generate(String model, String prompt) { + if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_MESSAGE###")) { + return "MESSAGE: Please continue the interview"; + } + if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_FINAL###")) { + return "FINAL: Candidate approved"; + } + return "Hello, " + prompt + "!"; + } + + @Override + public String chat(String model, List messages) { + return "Chat, " + model + "!"; + } + }; + } + } + @Autowired private BlocksController blocksController; @@ -159,7 +204,6 @@ public class ExecutionControllerTest { Block reviewBlock = blocksController.create(HumanInteractiveBlockConfiguration.builder() .name("Recruiter review") .actionDescription("Validate the CV assessment") - .simulateWith(llmDescriptor) .build()); FlowCreateRequest request = new FlowCreateRequest( @@ -233,7 +277,6 @@ public class ExecutionControllerTest { Block reviewBlock = blocksController.create(HumanInteractiveBlockConfiguration.builder() .name("Recruiter review") .actionDescription("Validate candidate") - .simulateWith(llmDescriptor) .build()); FlowCreateRequest request = new FlowCreateRequest( @@ -270,7 +313,6 @@ public class ExecutionControllerTest { Block reviewBlock = blocksController.create(HumanInteractiveBlockConfiguration.builder() .name("Recruiter review") .actionDescription("Validate candidate") - .simulateWith(llmDescriptor) .build()); FlowCreateRequest request = new FlowCreateRequest( @@ -299,6 +341,79 @@ public class ExecutionControllerTest { waitForExecutionStatus(resumed, ExecutionStatus.SUCCESS); } + @Test + public void simulateExecutionStartsInteractiveNodesInSimulationMode() { + LLMDescriptor llmDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("testModel") + .build(); + LLMDescriptor simulatorDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("simulateModel") + .build(); + + Block chatBlock = blocksController.create(ChatInteractionBlockConfiguration.builder() + .name("Recruiter chat") + .llmDescriptor(llmDescriptor) + .goalDescription("Assess ${{cand}} and produce a final decision") + .inputs(List.of(new ChatInteractionInput("cand", it.cnr.isti.workflow.manager.ios.IOType.TEXT))) + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Simulated chat flow", + "Flow to test simulation endpoint", + FlowData.builder().block(chatBlock).build()); + + ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); + ExecutionObject executionObject = executionsController.create(createdFlow.getBody().id()); + org.junit.jupiter.api.Assertions.assertTrue(executionObject.isSimulationAvailable()); + + executionsController.prepareStringInputs(executionObject.getId(), chatBlock.getId(), + chatBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + + executionObject = executionsController.simulate(executionObject.getId(), + new ExecutionSimulationRequest(simulatorDescriptor)); + org.junit.jupiter.api.Assertions.assertTrue(executionObject.isInteractionSimulationEnabled()); + waitForExecutionStatus(executionObject, ExecutionStatus.SUCCESS); + } + + @Test + public void simulateExecutionRejectsFlowsWithoutSimulableSteps() { + LLMDescriptor llmDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("testModel") + .build(); + + Block llmBlock = blocksController.create(LLMBlockConfiguration.builder() + .name("Analysis") + .llmDescriptor(llmDescriptor) + .prompt("Analyze ${{candidate}}") + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Non simulable flow", + "Flow without interactive steps", + FlowData.builder().block(llmBlock).build()); + + ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); + ExecutionObject executionObject = executionsController.create(createdFlow.getBody().id()); + + org.junit.jupiter.api.Assertions.assertFalse(executionObject.isSimulationAvailable()); + + executionsController.prepareStringInputs(executionObject.getId(), llmBlock.getId(), + llmBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + + ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> executionsController.simulate(executionObject.getId(), + new ExecutionSimulationRequest(LLMDescriptor.builder() + .provider("testProvider") + .model("simulateModel") + .build()))); + org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.READY, executionObject.getContext().getStatus()); + } + private void waitForExecutionStatus(ExecutionObject executionObject, ExecutionStatus expectedStatus) { long deadline = System.currentTimeMillis() + 5_000; while (System.currentTimeMillis() < deadline) { diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java index 8f139dd..52b21d3 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java @@ -28,9 +28,11 @@ import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.ConditionalBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.ConditionalBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; +import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import it.cnr.isti.workflow.manager.containers.Container; import it.cnr.isti.workflow.manager.containers.configurations.IteratorContainerConfiguration; import it.cnr.isti.workflow.manager.containers.types.IteratorContainerType; @@ -587,6 +589,75 @@ public class FlowControllerTest { assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status()); } + @Test + public void createFlowWithSharedSessionConsumerBeforeProducerReturnsDraftStatus() { + Block producer = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Producer") + .model("llama3.1:8b") + .prompt("Start MCP research") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + Block consumer = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Consumer") + .prompt("Continue with the shared session") + .useSharedSession(true) + .sharedSessionRef("candidateResearchSession") + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Invalid shared session ordering", + "Consumer is not reachable after the producer", + FlowData.builder() + .block(producer) + .block(consumer) + .build()); + + ResponseEntity createResponse = flowController.createFlow( + request, + new LoginEntity("testuser", "testpassword")); + + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status()); + } + + @Test + public void createFlowWithDuplicateSharedSessionProducerNamesReturnsDraftStatus() { + Block firstProducer = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Producer 1") + .model("llama3.1:8b") + .prompt("Start MCP research") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + Block secondProducer = blocksController.create(MCPAgentBlockConfiguration.builder() + .name("Producer 2") + .model("llama3.1:8b") + .prompt("Start MCP research again") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Duplicate shared session names", + "Two MCP producers must not expose the same shared session name", + FlowData.builder() + .block(firstProducer) + .block(secondProducer) + .build()); + + ResponseEntity createResponse = flowController.createFlow( + request, + new LoginEntity("testuser", "testpassword")); + + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + assertEquals(FlowViewStatus.DRAFT, createResponse.getBody().status()); + } + @Test public void createFlowRejectsConditionalBranchMerge() { LLMDescriptor llmDescriptor = LLMDescriptor.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index b8fc465..84c0f80 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -29,6 +29,7 @@ import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.ChatInteractionInput; import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentChatBlockConfiguration; import it.cnr.isti.workflow.manager.containers.Container; import it.cnr.isti.workflow.manager.containers.configurations.ContainerConfiguration; @@ -48,25 +49,35 @@ import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.factories.ChatInteractionBlockFactory; +import it.cnr.isti.workflow.manager.blocks.factories.HumanInteractiveBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory; +import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentChatBlockFactory; import it.cnr.isti.workflow.manager.blocks.types.ChatInteractionBlockType; +import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType; import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; +import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType; +import it.cnr.isti.workflow.manager.executions.ExecutionVariableKind; import it.cnr.isti.workflow.manager.ios.IODescriptor; import it.cnr.isti.workflow.manager.ios.IOType; import it.cnr.isti.workflow.manager.llms.ChatMessage; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; import it.cnr.isti.workflow.manager.mcp.MCPAgentService; +import it.cnr.isti.workflow.manager.flows.model.Connection; @SpringBootTest @TestPropertySource(locations = "classpath:test.properties") public class ExecutionTest { private static final AtomicReference> lastChatMessages = new AtomicReference<>(List.of()); + private static final LLMDescriptor SIMULATOR_DESCRIPTOR = LLMDescriptor.builder() + .provider("testProvider") + .model("simulateModel") + .build(); @TestConfiguration static class TestConfig { @@ -85,7 +96,13 @@ public class ExecutionTest { } @Override - public String generate(String prompt, String model) { + public String generate(String model, String prompt) { + if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_MESSAGE###")) { + return "MESSAGE: Please continue the interview"; + } + if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_FINAL###")) { + return "FINAL: Candidate approved"; + } return "Hello, " + prompt.replace("Hello, ", "").replace("!", "") + "!"; } @@ -114,6 +131,12 @@ public class ExecutionTest { @Autowired MCPAgentChatBlockFactory mcpAgentChatBlockFactory; + @Autowired + MCPAgentBlockFactory mcpAgentBlockFactory; + + @Autowired + HumanInteractiveBlockFactory humanInteractiveBlockFactory; + @Autowired LLMBlockType llmBlockType; @@ -171,6 +194,7 @@ public class ExecutionTest { Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() .name("Recruiter Chat") .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) .build()); @@ -222,11 +246,43 @@ public class ExecutionTest { assertTrue(execObject.getContext().getPartialResult().isEmpty()); } + @Test + public void simulatedChatInteractionExecutionCompletesAfterFiveTurns() { + Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() + .name("Recruiter Chat") + .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") + .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) + .build()); + + Flow flow = Flow.builder() + .name("Simulated Chat flow") + .description("Single simulated chat block") + .block(chatBlock) + .build(); + + ExecutionObject execObject = executionsService.createExecution(flow); + execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); + execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR); + while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { + execObject = executionsService.getExecution(execObject.getId()); + } + + assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); + assertEquals("Candidate approved", + execObject.getContext().getResult().get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.RESPONSE_OUTPUT))); + Object history = execObject.getContext().getResult() + .get(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.HISTORY_OUTPUT)); + assertTrue(history instanceof List); + assertEquals(10, ((List) history).size()); + } + @Test public void chatInteractionExecutionKeepsConversationHistoryAcrossMessages() { Block chatBlock = chatInteractionBlockFactory.create(ChatInteractionBlockConfiguration.builder() .name("Recruiter Chat") .llmDescriptor(llmBrick) + .goalDescription("Assess ${{cand}} and reach a final decision") .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) .build()); @@ -300,6 +356,7 @@ public class ExecutionTest { Block chatBlock = mcpAgentChatBlockFactory.create(MCPAgentChatBlockConfiguration.builder() .name("MCP Chat") .model("llama3.1:8b") + .goalDescription("Assess ${{cand}} and reach a final decision") .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) .mcpServers(List.of()) .build()); @@ -362,6 +419,7 @@ public class ExecutionTest { Block chatBlock = mcpAgentChatBlockFactory.create(MCPAgentChatBlockConfiguration.builder() .name("MCP Chat") .model("llama3.1:8b") + .goalDescription("Assess ${{cand}} and reach a final decision") .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) .mcpServers(List.of()) .build()); @@ -392,11 +450,112 @@ public class ExecutionTest { Mockito.verify(mcpAgentService, Mockito.times(1)).closeSessionQuietly("session-cancel"); } + @Test + public void simulatedMcpAgentChatExecutionCompletesAfterFiveTurns() { + AtomicInteger queryCount = new AtomicInteger(); + Mockito.when(mcpAgentService.openSession(Mockito.eq("llama3.1:8b"), Mockito.anyList())) + .thenReturn("session-simulated"); + Mockito.when(mcpAgentService.querySession(Mockito.eq("session-simulated"), Mockito.anyString())) + .thenAnswer(invocation -> "MCP answer " + queryCount.incrementAndGet()); + + Block chatBlock = mcpAgentChatBlockFactory.create(MCPAgentChatBlockConfiguration.builder() + .name("MCP Chat") + .model("llama3.1:8b") + .goalDescription("Assess ${{cand}} and reach a final decision") + .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) + .mcpServers(List.of()) + .build()); + + Flow flow = Flow.builder() + .name("Simulated MCP Chat flow") + .description("Single simulated MCP chat block") + .block(chatBlock) + .build(); + + ExecutionObject execObject = executionsService.createExecution(flow); + execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe"); + execObject = executionsService.startSimulationExecution(execObject.getId(), SIMULATOR_DESCRIPTOR); + while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { + execObject = executionsService.getExecution(execObject.getId()); + } + + assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); + assertEquals("Candidate approved", + execObject.getContext().getResult().get(new FieldKey(chatBlock.getId(), MCPAgentChatBlockFactory.RESPONSE_OUTPUT))); + Object history = execObject.getContext().getResult() + .get(new FieldKey(chatBlock.getId(), MCPAgentChatBlockFactory.HISTORY_OUTPUT)); + assertTrue(history instanceof List); + assertEquals(10, ((List) history).size()); + Mockito.verify(mcpAgentService, Mockito.times(1)).closeSessionQuietly("session-simulated"); + assertEquals(5, queryCount.get()); + } + + @Test + public void executionVariablesResolveInLlmPromptAndPersistAcrossReload() { + Block llmBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Greeter") + .prompt("Hello, ${{vars.person}} from ${{city}}!") + .llmDescriptor(llmBrick) + .build()); + + Flow flow = Flow.builder() + .name("Execution vars flow") + .description("Single LLM block using execution variables") + .block(llmBlock) + .build(); + + ExecutionObject execObject = executionsService.createExecution(flow); + executionsService.setExecutionVariable(execObject.getId(), "person", "Ada Lovelace"); + executionsService.prepareInput(execObject.getId(), llmBlock.getId(), "city", "Pisa"); + execObject = executionsService.startExecution(execObject.getId()); + while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { + execObject = executionsService.getExecution(execObject.getId()); + } + + assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); + assertEquals("Hello, Ada Lovelace from Pisa!", + execObject.getContext().getResult().get(new FieldKey(llmBlock.getId(), LLMBlockFactory.OUTPUT_NAME))); + assertEquals("Ada Lovelace", execObject.getContext().getExecutionVariables().get("person")); + + String executionId = execObject.getId(); + executionsService.clearInMemoryExecutions(); + ExecutionObject restored = executionsService.getExecution(executionId); + assertEquals("Ada Lovelace", restored.getContext().getExecutionVariables().get("person")); + } + + @Test + public void cancelExecutionClearsExecutionVariables() { + Block reviewBlock = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder() + .name("Recruiter review") + .actionDescription("Validate candidate") + .build()); + + Flow flow = Flow.builder() + .name("Execution vars cancel flow") + .description("Flow to test execution variables cleanup") + .block(reviewBlock) + .build(); + + ExecutionObject execObject = executionsService.createExecution(flow); + executionsService.setExecutionVariable(execObject.getId(), "sharedSessionId", "session-123"); + executionsService.prepareInput(execObject.getId(), reviewBlock.getId(), + reviewBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + execObject = executionsService.startExecution(execObject.getId()); + while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { + execObject = executionsService.getExecution(execObject.getId()); + } + + assertEquals(ExecutionStatus.WAITING, execObject.getContext().getStatus()); + executionsService.cancelExecution(execObject.getId()); + assertTrue(execObject.getContext().getExecutionVariables().isEmpty()); + } + @Test public void persistedWaitingExecutionCanBeRestoredAfterRestart() { Block chatBlock = mcpAgentChatBlockFactory.create(MCPAgentChatBlockConfiguration.builder() .name("MCP Chat") .model("llama3.1:8b") + .goalDescription("Assess ${{cand}} and reach a final decision") .inputs(List.of(new ChatInteractionInput("cand", IOType.TEXT, false))) .mcpServers(List.of()) .build()); @@ -446,6 +605,64 @@ public class ExecutionTest { Mockito.verify(mcpAgentService).querySession("session-resume", "Continue with John Doe"); } + @Test + public void mcpAgentBlockCanShareSessionWithFollowingMcpAgentBlock() { + Block producer = mcpAgentBlockFactory.create(MCPAgentBlockConfiguration.builder() + .name("Research session") + .model("llama3.1:8b") + .prompt("Find data for ${{cand}}") + .shareSession(true) + .sharedSessionName("candidateResearchSession") + .build()); + + Block consumer = mcpAgentBlockFactory.create(MCPAgentBlockConfiguration.builder() + .name("Reuse session") + .model("llama3.1:8b") + .prompt("Summarize next steps for ${{cand}}") + .useSharedSession(true) + .sharedSessionRef("candidateResearchSession") + .build()); + + Flow flow = Flow.builder() + .name("Shared MCP session flow") + .description("Two MCP agent blocks sharing one MCP session") + .block(producer) + .block(consumer) + .connection(Connection.builder() + .sourceId(producer.getId()) + .sourceName(MCPAgentBlockFactory.OUTPUT_NAME) + .targetId(consumer.getId()) + .targetName("cand") + .build()) + .build(); + + Mockito.when(mcpAgentService.openSession(Mockito.eq("llama3.1:8b"), Mockito.anyList())) + .thenReturn("shared-session-1"); + Mockito.when(mcpAgentService.querySession("shared-session-1", "Find data for Ada")) + .thenReturn("Ada"); + Mockito.when(mcpAgentService.querySession("shared-session-1", "Summarize next steps for Ada")) + .thenReturn("Summary for Ada"); + + ExecutionObject execObject = executionsService.createExecution(flow); + execObject = executionsService.prepareInput(execObject.getId(), producer.getId(), "cand", "Ada"); + execObject = executionsService.startExecution(execObject.getId()); + while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { + execObject = executionsService.getExecution(execObject.getId()); + } + + assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); + assertEquals("shared-session-1", execObject.getContext().getExecutionVariables().get("candidateResearchSession")); + assertNotNull(execObject.getContext().getExecutionVariableDescriptors().get("candidateResearchSession")); + assertEquals(ExecutionVariableKind.MCP_SESSION, + execObject.getContext().getExecutionVariableDescriptors().get("candidateResearchSession").getKind()); + assertEquals("Summary for Ada", execObject.getContext().getResult() + .get(new FieldKey(consumer.getId(), MCPAgentBlockFactory.OUTPUT_NAME))); + Mockito.verify(mcpAgentService).openSession(Mockito.eq("llama3.1:8b"), Mockito.anyList()); + Mockito.verify(mcpAgentService).querySession("shared-session-1", "Find data for Ada"); + Mockito.verify(mcpAgentService).querySession("shared-session-1", "Summarize next steps for Ada"); + Mockito.verify(mcpAgentService, Mockito.timeout(1000)).closeSessionQuietly("shared-session-1"); + } + @Test public void createInteractiveExecutionSetInputAndStart() { ExecutionObject eo = createInteractiveExecutionAndSetInputInternally(); @@ -514,7 +731,9 @@ public class ExecutionTest { } assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); - assertTrue(execObject.getContext().getResult().values().contains("Hello, testModel!")); + Object finalResult = execObject.getContext().getResult().values().stream().findFirst().orElseThrow(); + assertTrue(finalResult instanceof String); + assertTrue(((String) finalResult).contains("Senior Java engineer with AI workflow experience")); } private void normalizeSeedFlowProviders(FlowData flowData) { @@ -526,9 +745,11 @@ public class ExecutionTest { if (configuration instanceof LLMBlockConfiguration) { overwriteField(configuration, "llmDescriptor", llmBrick); } else if (configuration instanceof HumanInteractiveBlockConfiguration humanConfiguration) { - if (humanConfiguration.getSimulateWith() != null) { - overwriteField(configuration, "simulateWith", llmBrick); + } else if (configuration instanceof ChatInteractionBlockConfiguration chatConfiguration) { + if (chatConfiguration.getLlmDescriptor() != null) { + overwriteField(configuration, "llmDescriptor", llmBrick); } + } else if (configuration instanceof MCPAgentChatBlockConfiguration mcpChatConfiguration) { } } for (Container container : flowData.getContainers() == null ? List.>of() : flowData.getContainers()) { @@ -642,7 +863,7 @@ public class ExecutionTest { } assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); - assertEquals("Hello, testModel!", execObject.getContext().getResult().values().stream().findFirst().orElseThrow()); + assertEquals("Hello, Frank!", execObject.getContext().getResult().values().stream().findFirst().orElseThrow()); assertTrue(execObject.getContext().getResult().keySet().stream() .anyMatch(key -> key.toString().equals(container.getId() + ":" + exposedOutputName))); } @@ -679,7 +900,7 @@ public class ExecutionTest { assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus()); Object output = execObject.getContext().getResult().values().stream().findFirst().orElseThrow(); assertTrue(output instanceof List); - assertEquals(List.of("Hello, testModel!", "Hello, testModel!"), output); + assertEquals(List.of("Hello, Alice!", "Hello, Bob!"), output); } @Test diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java index 28ad5fc..f7d30d2 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionWithContainer.java @@ -136,11 +136,8 @@ public class ExecutionWithContainer { } } - execObject.getContext().getSteps().values().stream().filter(s -> s.getBlock().getType().isUserInteractive()) - .forEach(s -> s.setSimulated(true)); - assertEquals(ExecutionStatus.READY, execObject.getContext().getStatus()); - execObject = executionsService.startExecution(execObject.getId()); + execObject = executionsService.startSimulationExecution(execObject.getId(), llmBrick); assertEquals(ExecutionStatus.RUNNING, execObject.getContext().getStatus()); while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) { try { diff --git a/src/test/java/it/cnr/isti/workflow/manager/flows/FlowTestCreator.java b/src/test/java/it/cnr/isti/workflow/manager/flows/FlowTestCreator.java index d52fa6c..ad08ceb 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/flows/FlowTestCreator.java +++ b/src/test/java/it/cnr/isti/workflow/manager/flows/FlowTestCreator.java @@ -90,7 +90,6 @@ public class FlowTestCreator { Block block2 = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder() .actionDescription("Answer the question in input") .name("interactive") - .simulateWith(llmBrick) .build()); Connection connection = Connection.builder()