Add shared execution variables and execution-level simulation

This commit is contained in:
Lucio Lelii 2026-03-24 09:52:05 +01:00
parent 1deeac6e6f
commit e233d27026
56 changed files with 2235 additions and 302 deletions

View File

@ -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.

View File

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

View File

@ -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<ChatIn
@JsonProperty(required = true)
private LLMDescriptor llmDescriptor;
@NotBlank
@LongText(placeholder = "Describe the goal of the simulated chat")
@JsonProperty(required = true)
private String goalDescription;
@Structural
@UiUniqueItemsBy("name")
@Valid
@ -39,9 +46,11 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration<ChatIn
@Builder
public ChatInteractionBlockConfiguration(@NonNull String name, LLMDescriptor llmDescriptor,
String goalDescription,
List<ChatInteractionInput> inputs) {
super(name);
this.llmDescriptor = llmDescriptor;
this.goalDescription = goalDescription;
this.inputs = inputs == null ? List.of() : List.copyOf(inputs);
}

View File

@ -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<ConditionalBlockType> {
@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<Conditiona
boolean useLlm;
@Valid
@UiDependency(field = "useLlm", equals = "true", group = "llm")
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
LLMDescriptor llmDescriptor;
@UiDependency(field = "useLlm", equals = "true", group = "llm")
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@LongText(

View File

@ -4,7 +4,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.blocks.types.HTTPServerCallBlockType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@ -28,7 +28,7 @@ public class HTTPServerCallBlockConfiguration extends BlockConfiguration<HTTPSer
private HTTPMethod method;
@Structural
@UiDependency(field = "method", equalsAny = { "POST", "PUT" })
@UiEnabledWhen(field = "method", equalsAny = { "POST", "PUT" })
@LongText(
placeholder = "Optional HTTP request body.",
tip = "Use ${{}} placeholders to inject workflow variables into the URL or request body.",
@ -41,11 +41,11 @@ public class HTTPServerCallBlockConfiguration extends BlockConfiguration<HTTPSer
private Boolean requiresAuthorization;
@NotNull
@UiDependency(field = "requiresAuthorization", equals = "true", group = "authorization")
@UiEnabledWhen(field = "requiresAuthorization", equals = "true", group = "authorization")
@JsonProperty(required = true)
private HTTPAuthorizationType authorizationType;
@UiDependency(field = "authorizationType", equals = "API_KEY", group = "authorization")
@UiEnabledWhen(field = "authorizationType", equals = "API_KEY", group = "authorization")
@JsonProperty(required = false)
private String authorizationHeaderName;

View File

@ -4,10 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -25,22 +22,15 @@ public class HumanInteractiveBlockConfiguration extends BlockConfiguration<Human
@JsonProperty(required = true)
private String actionDescription;
@NotNull
@Valid
@JsonProperty(required = true)
private LLMDescriptor simulateWith;
@Override
public Class<HumanInteractionBlockType> 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() {

View File

@ -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<Class<?>, Map<String, DynamicSchema>> dynamicSchemaMap = collectDynamicSchemaMetadata(type);
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(type);
Map<Class<?>, Map<String, Structural>> structuralMap = collectStructuralMetadata(type);
Map<Class<?>, Map<String, UiDependency>> uiDependencyMap = collectUiDependencyMetadata(type);
Map<Class<?>, Map<String, UiEnabledWhen>> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type);
Map<Class<?>, Map<String, UiOptionsFromNode>> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type);
Map<Class<?>, Map<String, UiRequiredWhen>> 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<Class<?>, Map<String, UiDependency>> collectUiDependencyMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiDependency>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
Queue<Class<?>> queue = new ArrayDeque<>();
queue.add(rootClass);
while (!queue.isEmpty()) {
Class<?> current = queue.poll();
if (current == null || !visited.add(current) || isTerminalType(current)) {
continue;
}
Map<String, UiDependency> 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<Class<?>, Map<String, UiUniqueItemsBy>> collectUiUniqueItemsByMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiUniqueItemsBy>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();
@ -634,39 +590,6 @@ public class JsonSchemaProducer {
return result;
}
private void applyUiDependencyMetadata(ObjectNode classSchema, Map<String, UiDependency> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (Entry<String, UiDependency> 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<String, UiUniqueItemsBy> 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()) {

View File

@ -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<MCPAgentBlock
@Structural
@ConfigurableAsInput
@UiEnabledWhen(field = "useSharedSession", equals = "false")
@FieldRetriever(name = "LLM", url = "/retriever/LLM/models?provider=InternalOllama")
@JsonProperty(required = false)
private String model;
@ -41,14 +45,43 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration<MCPAgentBlock
@JsonProperty(required = false)
@Valid
@UiEnabledWhen(field = "useSharedSession", equals = "false")
private List<MCPServerBinding> 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<MCPServerBinding> mcpServers) {
public MCPAgentBlockConfiguration(@NonNull String name, String model, String prompt, List<MCPServerBinding> 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<MCPAgentBlock
MCPAgentBlockConfiguration configuration = new MCPAgentBlockConfiguration();
configuration.name = MCPAgentBlockType.TYPE;
configuration.mcpServers = List.of();
configuration.shareSession = false;
configuration.useSharedSession = false;
return configuration;
}

View File

@ -9,7 +9,11 @@ import it.cnr.isti.workflow.manager.blocks.types.MCPAgentChatBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput;
import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema;
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.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@ -26,6 +30,7 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
@Structural
@ConfigurableAsInput
@UiEnabledWhen(field = "useSharedSession", equals = "false")
@FieldRetriever(name = "LLM", url = "/retriever/LLM/models?provider=InternalOllama")
@JsonProperty(required = false)
private String model;
@ -34,17 +39,53 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
@Valid
private List<ChatInteractionInput> 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<MCPServerBinding> 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<ChatInteractionInput> inputs,
List<MCPServerBinding> mcpServers) {
String goalDescription,
List<MCPServerBinding> 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<MCPAgentC
configuration.name = MCPAgentChatBlockType.TYPE;
configuration.inputs = List.of();
configuration.mcpServers = List.of();
configuration.shareSession = false;
configuration.useSharedSession = false;
return configuration;
}

View File

@ -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;
@ -84,7 +85,10 @@ public class ChatInteractionBlockFactory
}
private void validateConfiguration(ChatInteractionBlockConfiguration 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()

View File

@ -75,7 +75,10 @@ public class ConditionalBlockFactory implements BlockFactory<ConditionalBlockTyp
}
Matcher matcher = pattern.matcher(source);
while (matcher.find()) {
target.add(matcher.group(group));
String placeholder = matcher.group(group);
if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) {
target.add(placeholder);
}
}
}

View File

@ -63,7 +63,10 @@ public class HTTPServerCallBlockFactory implements BlockFactory<HTTPServerCallBl
}
Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
while (matcher.find()) {
names.add(matcher.group(1));
String placeholder = matcher.group(1);
if (!PlaceholderInputs.isRuntimeExecutionVariable(placeholder)) {
names.add(placeholder);
}
}
}

View File

@ -40,8 +40,12 @@ public class LLMBlockFactory implements BlockFactory<LLMBlockType, LLMBlockConfi
Matcher matcher = pattern.matcher(input);
Set<String> 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);
}
}

View File

@ -34,9 +34,12 @@ public class MCPAgentBlockFactory implements BlockFactory<MCPAgentBlockType, MCP
@Override
public Block<MCPAgentBlockType> create(MCPAgentBlockConfiguration configuration) {
validateConfiguration(configuration);
List<IODescriptor> 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.<MCPAgentBlockType>builder()
@ -64,7 +67,10 @@ public class MCPAgentBlockFactory implements BlockFactory<MCPAgentBlockType, MCP
Matcher matcher = PLACEHOLDER_PATTERN.matcher(prompt);
Set<String> 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<MCPAgentBlockType, MCP
public List<IOCapability> 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.
}
}

View File

@ -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<IODescriptor> resolveInputs(MCPAgentChatBlockConfiguration configuration) {
java.util.ArrayList<IODescriptor> inputs = new java.util.ArrayList<>(configurableInputDescriptors(configuration));
java.util.ArrayList<IODescriptor> 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()

View File

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

View File

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

View File

@ -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 "";
}

View File

@ -15,4 +15,6 @@ public @interface UiEnabledWhen {
String[] equalsAny() default {};
boolean present() default false;
String group() default "";
}

View File

@ -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<RetrieverItem> retrieve(String parameter, Map<String, String> 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();
}
}

View File

@ -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 <T extends BlockType, C extends BlockConfiguration<T>> Block<T> create(C blockConfiguration) {
return create(blockConfiguration, null, null);
}
@SuppressWarnings("unchecked")
public <T extends BlockType, C extends BlockConfiguration<T>> Block<T> 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 <T extends BlockType, C extends BlockConfiguration<T>> Block<T> create(@RequestBody C blockConfiguration) {
public <T extends BlockType, C extends BlockConfiguration<T>> Block<T> create(@RequestBody C blockConfiguration,
@RequestParam(required = false) String flowId,
@RequestParam(required = false) String replacesBlockId) {
logger.debug("Creating block of type: {}", blockConfiguration.getBlockType().getName());
BlockFactory<T, C> factory = (BlockFactory<T, C>) 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<T> block = factory.create(blockConfiguration);
flowSharedVariableCatalogService.registerDraftBlock(flowId, block, replacesBlockId);
String blockAsJson = null;
try {
blockAsJson = ObjectMapperHolder.mapper.writeValueAsString(block);

View File

@ -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) {

View File

@ -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<FieldKey, Object> inputs = new HashMap<>();
Map<String, Object> authorizations = new HashMap<>();
Map<String, Object> executionVariables = new HashMap<>();
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors = new HashMap<>();
@Getter()
private Map<FieldKey, Object> 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<String, Step<?>> 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<String, Object> executionVariables) {
setExecutionVariableDescriptors(ExecutionVariableRegistry.descriptorsFromValues(executionVariables));
}
protected void setExecutionVariableDescriptors(Map<String, ExecutionVariableDescriptor> 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());
}

View File

@ -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<ExecutionAuthorizationRequirement> 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<String, Object> executionVariables) {
this.context.setExecutionVariables(executionVariables);
}
protected void setExecutionVariableDescriptors(Map<String, ExecutionVariableDescriptor> 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<String> 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<Step<?>> steps) {
boolean hasInteractiveSteps = false;
for (Step<?> step : steps) {
if (!step.getNode().isUserInteractive()) {
continue;
}
hasInteractiveSteps = true;
if (!NodeExecutors.supportsSimulation(step.getNode())) {
return false;
}
}
return hasInteractiveSteps;
}
}

View File

@ -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) {
}

View File

@ -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<Input> inputs, Map<String, Object> executionVariables) {
Map<String, Object> 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<String, ?> values, Map<String, Object> executionVariables) {
if (!StringUtils.hasText(template)) {
return template;
}
String resolved = template;
if (values != null) {
for (Map.Entry<String, ?> entry : values.entrySet()) {
resolved = resolved.replace("${{" + entry.getKey() + "}}", formatValue(entry.getValue()));
}
}
if (executionVariables != null) {
for (Map.Entry<String, Object> 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();
}
}

View File

@ -0,0 +1,7 @@
package it.cnr.isti.workflow.manager.executions;
public enum ExecutionVariableCleanupPolicy {
NONE,
CLOSE_RESOURCE,
DELETE_FILE
}

View File

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

View File

@ -0,0 +1,10 @@
package it.cnr.isti.workflow.manager.executions;
public enum ExecutionVariableKind {
ANY,
TEXT,
JSON,
FILE_PATH,
HTTP_RESOURCE,
MCP_SESSION
}

View File

@ -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<String, Object> valuesView(Map<String, ExecutionVariableDescriptor> descriptors) {
LinkedHashMap<String, Object> 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<String, ExecutionVariableDescriptor> descriptorsFromValues(Map<String, Object> values) {
LinkedHashMap<String, ExecutionVariableDescriptor> 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;
}
}

View File

@ -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<ExecutionAuthorizationRequirement> 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<String, Object> executionVariables) {
ExecutionObject eo = getExecution(executionId);
eo.setExecutionVariables(executionVariables);
return eo;
}
public ExecutionObject setExecutionVariableDescriptors(String executionId,
Map<String, ExecutionVariableDescriptor> 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.<Block<?>>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.<Container<?>>of() : flow.getContainers()) {
@ -183,25 +244,34 @@ public class ExecutionsService {
}
}
private LLMDescriptor resolveDescriptor(Block<?> block) {
private List<LLMDescriptor> 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<LLMDescriptor> listOfDescriptors(LLMDescriptor... descriptors) {
return java.util.Arrays.stream(descriptors)
.filter(Objects::nonNull)
.toList();
}
private void collectRequirement(Map<String, RequirementAccumulator> 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<String, ExecutionVariableDescriptor> executionVariableDescriptors =
executionObject.getContext().getExecutionVariableDescriptors();
if (executionVariableDescriptors == null || executionVariableDescriptors.isEmpty()) {
return;
}
java.util.LinkedHashSet<String> sessionIds = new java.util.LinkedHashSet<>();
java.util.List<ExecutionVariableDescriptor> 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;

View File

@ -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<String, Map<String, Block<?>>> draftBlocksByFlowId = new ConcurrentHashMap<>();
private final Map<String, Set<String>> removedBlockIdsByFlowId = new ConcurrentHashMap<>();
public FlowSharedVariableCatalogService(FlowService flowService) {
this.flowService = flowService;
}
public List<SharedVariableDefinition> listForFlow(String flowId, String owner, ExecutionVariableKind kind) {
List<SharedVariableDefinition> definitions = List.of();
if (StringUtils.hasText(flowId)) {
LinkedHashMap<String, Block<?>> 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<String, Block<?>> 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<SharedVariableDefinition> analyze(FlowData flowData) {
if (flowData == null) {
return List.of();
}
return analyze(flowData.getBlocks() == null ? List.of() : flowData.getBlocks());
}
private List<SharedVariableDefinition> analyze(List<Block<?>> blocks) {
LinkedHashMap<String, SharedVariableDefinition> 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;
}
}

View File

@ -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<String, Object> execute(FlowNode node, List<Input> inputs, Map<String, Object> 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<String, Object> execute(FlowNode node, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> 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<Input> inputs, Map<String, Object> interaction,
Map<String, Object> partialResults, Map<String, Object> authorizations) {
public static Map<String, Object> simulate(FlowNode node, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> 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<Input> inputs, Map<String, Object> interaction,
Map<String, Object> partialResults, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> 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<Input> inputs, Map<String, Object> partialResults,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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);
}
}
}

View File

@ -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<B extends BlockType> {
Map<String, Object> execute(Block<B> block, List<Input> inputs, Map<String, Object> context);
Map<String, Object> execute(Block<B> block, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors);
Class<B> getBlockType();
@ -18,18 +21,26 @@ public interface BlockExecutor<B extends BlockType> {
return false;
}
default Map<String, Object> simulate(Block<B> block, List<Input> inputs, Map<String, Object> authorizations)
default boolean supportsSimulation() {
return isInteractive();
}
default Map<String, Object> simulate(Block<B> block, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors,
LLMDescriptor simulatorDescriptor)
{
throw new UnsupportedOperationException("Simulation not supported for this block type");
}
default InteractionResult interact(Block<B> block, List<Input> inputs, Map<String, Object> interaction,
Map<String, Object> partialResults, Map<String, Object> authorizations) {
Map<String, Object> partialResults, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
throw new UnsupportedOperationException("Interaction not supported for this block type");
}
default void cancel(Block<B> block, List<Input> inputs, Map<String, Object> partialResults,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
}
}

View File

@ -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<ChatInteractionBlockType> {
private static final int MAX_SIMULATED_INTERACTIONS = 5;
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
throw new UnsupportedOperationException("ChatInteraction blocks require user interaction.");
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
throw new UnsupportedOperationException("ChatInteraction blocks require user interaction unless execution simulation is enabled.");
}
@Override
public Map<String, Object> simulate(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<String> 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<ChatMessage> 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<ChatInteractionBlo
@Override
public InteractionResult interact(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations) {
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
ChatInteractionBlockConfiguration configuration =
(ChatInteractionBlockConfiguration) block.getSpecificConfiguration();
LLMDescriptor llmDescriptor = configuration.getLlmDescriptor();
@ -63,7 +112,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
+ ChatInteractionBlockFactory.INTERACTION_FIELD);
}
String resolvedMessage = resolvePlaceholders(message, inputs);
String resolvedMessage = resolvePlaceholders(message, inputs, executionVariables);
List<String> history = existingHistory(partialResults);
List<ChatMessage> messages = history.stream()
.map(this::parseHistoryLine)
@ -112,6 +161,75 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
return llmProvider;
}
private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, Map<String, Object> 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<Input> inputs, List<String> 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<Input> inputs, List<String> 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<ChatInteractionBlo
return new ChatMessage(ChatMessage.Role.USER, line);
}
private String resolvePlaceholders(String template, List<Input> inputs) {
if (!StringUtils.hasText(template)) {
return template;
private String resolvePlaceholders(String template, List<Input> inputs, Map<String, Object> executionVariables) {
return ExecutionTemplateResolver.resolve(template, inputs, executionVariables);
}
private String formatInputs(List<Input> inputs) {
if (inputs == null || inputs.isEmpty()) {
return "(none)";
}
Map<String, String> values = new LinkedHashMap<>();
for (Input input : inputs) {
values.put(input.getDescriptor().getName(), formatInputValue(input.getValue()));
}
String resolved = template;
for (Map.Entry<String, String> 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<String> 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);
}
}

View File

@ -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<ConditionalBlockType>
@Override
public Map<String, Object> execute(Block<ConditionalBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
ConditionalBlockConfiguration config = (ConditionalBlockConfiguration) block.getSpecificConfiguration();
Map<String, Object> 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<ConditionalBlockType>
return values;
}
private boolean evaluateWithExpression(String expression, Map<String, Object> inputValues) {
private boolean evaluateWithExpression(String expression, Map<String, Object> inputValues,
Map<String, Object> 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<ConditionalBlockType>
}
private boolean evaluateWithLlm(ConditionalBlockConfiguration config, Map<String, Object> inputValues,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables) {
LLMDescriptor llmDescriptor = config.getLlmDescriptor();
LLMProvider llmProvider = resolveProvider(llmDescriptor.provider());
String authKey = llmProvider.authorizationKey();
@ -90,7 +95,7 @@ public class ConditionalExecutor implements BlockExecutor<ConditionalBlockType>
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<ConditionalBlockType>
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
}
private String buildLlmPrompt(ConditionalBlockConfiguration config, Map<String, Object> inputValues) {
private String buildLlmPrompt(ConditionalBlockConfiguration config, Map<String, Object> inputValues,
Map<String, Object> 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<ConditionalBlockType>
return buffer.toString();
}
private String resolvePlaceholders(String template, Map<String, Object> inputValues) {
String resolved = template;
for (Map.Entry<String, Object> entry : inputValues.entrySet()) {
resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue() == null ? "null" : entry.getValue().toString());
}
return resolved;
private String resolvePlaceholders(String template, Map<String, Object> inputValues, Map<String, Object> executionVariables) {
return ExecutionTemplateResolver.resolve(template, inputValues, executionVariables);
}
private boolean parseBooleanResponse(String response) {

View File

@ -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<HTTPServerCallBlock
@Override
public Map<String, Object> execute(Block<HTTPServerCallBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<HTTPServerCallBlock
return HTTPServerCallBlockType.class;
}
private String resolveTemplate(String template, List<Input> 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<Input> inputs, Map<String, Object> executionVariables) {
return ExecutionTemplateResolver.resolve(template, inputs, executionVariables);
}
}

View File

@ -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<HumanInteractionB
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<HumanInteractionBlockType> block, List<Input> inputs, Map<String, Object> authorizations) {
public Map<String, Object> execute(Block<HumanInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
throw new UnsupportedOperationException("HumanInteraction blocks require user interaction unless execution simulation is enabled.");
}
@Override
public Map<String, Object> simulate(Block<HumanInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<HumanInteractionB
return true;
}
@Override
public Map<String, Object> simulate(Block<HumanInteractionBlockType> block, List<Input> inputs, Map<String, Object> authorizations) {
throw new UnsupportedOperationException("HumanInteractionExecutor does not support simulation.");
}
@Override
public InteractionResult interact(Block<HumanInteractionBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations) {
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
Object value = interaction.get("output");
if (value == null) {
throw new IllegalArgumentException("Missing interaction value for field: output");

View File

@ -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<LLMBlockType> {
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<LLMBlockType> block, List<Input> inputs, Map<String, Object> authorizations) {
public Map<String, Object> execute(Block<LLMBlockType> block, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> 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<LLMBlockType> {
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();
}
}

View File

@ -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<MCPAgentChatBlockType> {
private static final String SESSION_ID_STATE = "__sessionId";
private static final int MAX_SIMULATED_INTERACTIONS = 5;
@Autowired
private MCPAgentService mcpAgentService;
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<MCPAgentChatBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
throw new UnsupportedOperationException("MCPAgentChat blocks require user interaction.");
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
throw new UnsupportedOperationException("MCPAgentChat blocks require user interaction unless execution simulation is enabled.");
}
@Override
public Map<String, Object> simulate(Block<MCPAgentChatBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<String> 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<MCPAgentChatBlockType
@Override
public InteractionResult interact(Block<MCPAgentChatBlockType> block, List<Input> inputs,
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations) {
Map<String, Object> interaction, Map<String, Object> partialResults, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors) {
MCPAgentChatBlockConfiguration configuration =
(MCPAgentChatBlockConfiguration) block.getSpecificConfiguration();
@ -57,16 +122,22 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
+ MCPAgentChatBlockFactory.INTERACTION_FIELD);
}
String resolvedMessage = resolvePlaceholders(message, inputs);
String model = resolveConfigurableInput(MCPAgentChatBlockFactory.MODEL_INPUT, configuration.getModel(), inputs);
if (!StringUtils.hasText(model)) {
throw new IllegalArgumentException("MCPAgentChat requires a model either from configuration or runtime input");
}
String resolvedMessage = resolvePlaceholders(message, inputs, executionVariables);
String model = Boolean.TRUE.equals(configuration.getUseSharedSession())
? null
: resolveConfigurableInput(MCPAgentChatBlockFactory.MODEL_INPUT, configuration.getModel(), inputs);
String existingSessionId = existingSessionId(partialResults);
boolean createdSession = false;
if (!StringUtils.hasText(existingSessionId)) {
existingSessionId = mcpAgentService.openSession(model, mapServers(configuration.getMcpServers()));
existingSessionId = resolveOrCreateSession(block, configuration, executionVariables, executionVariableDescriptors,
model,
normalize(Boolean.TRUE.equals(configuration.getUseSharedSession())
? configuration.getSharedSessionRef()
: null),
normalize(Boolean.TRUE.equals(configuration.getShareSession())
? configuration.getSharedSessionName()
: null));
createdSession = true;
}
@ -83,7 +154,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
MCPAgentChatBlockFactory.HISTORY_OUTPUT, List.copyOf(updatedHistory),
SESSION_ID_STATE, existingSessionId));
} catch (RuntimeException ex) {
if (createdSession) {
if (createdSession && !isManagedSharedSession(configuration, executionVariableDescriptors)) {
mcpAgentService.closeSessionQuietly(existingSessionId);
}
throw ex;
@ -96,7 +167,9 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
throw new IllegalArgumentException("Missing interaction value for field: "
+ MCPAgentChatBlockFactory.FINAL_RESPONSE_FIELD);
}
mcpAgentService.closeSessionQuietly(existingSessionId(partialResults));
if (!isManagedSharedSession(configuration, executionVariableDescriptors)) {
mcpAgentService.closeSessionQuietly(existingSessionId(partialResults));
}
List<String> history = existingHistory(partialResults);
return InteractionResult.completed(Map.of(
MCPAgentChatBlockFactory.RESPONSE_OUTPUT, response,
@ -108,8 +181,93 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
@Override
public void cancel(Block<MCPAgentChatBlockType> block, List<Input> inputs, Map<String, Object> partialResults,
Map<String, Object> authorizations) {
mcpAgentService.closeSessionQuietly(existingSessionId(partialResults));
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<String, Object> 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<Input> inputs, List<String> 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<Input> inputs, List<String> 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<MCPAgentChatBlockType
return value == null ? null : String.valueOf(value);
}
private String resolveOrCreateSession(Block<MCPAgentChatBlockType> block, MCPAgentChatBlockConfiguration configuration,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> 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<String, ExecutionVariableDescriptor> 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<Input> 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<String> history) {
return history == null || history.isEmpty() ? "(empty)" : String.join(System.lineSeparator(), history);
}
private String resolveConfigurableInput(String inputName, String configuredValue, List<Input> inputs) {
return inputs.stream()
.filter(input -> inputName.equals(input.getDescriptor().getName()))
@ -154,27 +364,11 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
.toList();
}
private String resolvePlaceholders(String template, List<Input> inputs) {
if (!StringUtils.hasText(template)) {
return template;
}
Map<String, String> values = new LinkedHashMap<>();
for (Input input : inputs) {
values.put(input.getDescriptor().getName(), formatInputValue(input.getValue()));
}
String resolved = template;
for (Map.Entry<String, String> entry : values.entrySet()) {
resolved = resolved.replace("${{" + entry.getKey() + "}}", entry.getValue());
}
return resolved;
private String resolvePlaceholders(String template, List<Input> inputs, Map<String, Object> 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);
}
}

View File

@ -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<MCPAgentBlockType> {
@ -22,14 +25,50 @@ public class MCPBridgeExecutor implements BlockExecutor<MCPAgentBlockType> {
@Override
public Map<String, Object> execute(Block<MCPAgentBlockType> block, List<Input> inputs,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<MCPAgentBlockType> {
return MCPAgentBlockType.class;
}
private String resolvePrompt(String template, List<Input> inputs) {
private String resolvePrompt(String template, List<Input> inputs, Map<String, Object> 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<MCPAgentBlockType> {
? 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<Input> inputs) {
@ -67,4 +100,9 @@ public class MCPBridgeExecutor implements BlockExecutor<MCPAgentBlockType> {
.filter(StringUtils::hasText)
.orElse(configuredValue);
}
private String normalize(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}

View File

@ -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<T extends ContainerType> {
Map<String, Object> execute(Container<T> container, List<Input> inputs, Map<String, Object> authorizations);
Map<String, Object> execute(Container<T> container, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors);
Class<T> getContainerType();
}

View File

@ -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<GenericContai
private ExecutionsService executionsService;
@Override
public Map<String, Object> execute(Container<GenericContainerType> container, List<Input> inputs, Map<String, Object> authorizations) {
public Map<String, Object> execute(Container<GenericContainerType> container, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<GenericContai
ExecutionObject innerExecution = executionsService.createExecution(container.getName() + " subflow", configuration.getSubFlow());
propagateAuthorizations(innerExecution, authorizations);
executionsService.setExecutionVariableDescriptors(innerExecution.getId(), executionVariableDescriptors);
Map<String, ContainerFlowInterfaceResolver.ExposedHandle> inputPortsByName = ContainerFlowInterfaceResolver
.getExposedInputs(configuration.getSubFlow()).stream()
@ -68,6 +72,10 @@ public class GenericContainerExecutor implements ContainerExecutor<GenericContai
if (innerExecution.getContext().getStatus() != ExecutionStatus.SUCCESS) {
throw new IllegalStateException("GenericContainer subflow ended in unexpected status: " + innerExecution.getContext().getStatus());
}
executionVariables.clear();
executionVariables.putAll(innerExecution.getContext().getExecutionVariables());
executionVariableDescriptors.clear();
executionVariableDescriptors.putAll(innerExecution.getContext().getExecutionVariableDescriptors());
Map<String, Object> outputs = new java.util.LinkedHashMap<>();
for (ContainerFlowInterfaceResolver.ExposedHandle exposedHandle : ContainerFlowInterfaceResolver

View File

@ -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<IteratorCont
@Override
public Map<String, Object> execute(Container<IteratorContainerType> container, List<Input> inputs,
Map<String, Object> authorizations) {
Map<String, Object> authorizations, Map<String, Object> executionVariables,
Map<String, ExecutionVariableDescriptor> 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<IteratorCont
configuration.getSubFlow());
propagateAuthorizations(innerExecution, authorizations);
executionsService.setExecutionVariableDescriptors(innerExecution.getId(), executionVariableDescriptors);
for (IteratorContainerInterfaceResolver.ResolvedInput resolvedInput : resolution.resolvedInputs()) {
Object value = resolvedInput.iterated()
@ -69,6 +72,10 @@ public class IteratorContainerExecutor implements ContainerExecutor<IteratorCont
}
innerExecution = startAndWait(innerExecution);
executionVariables.clear();
executionVariables.putAll(innerExecution.getContext().getExecutionVariables());
executionVariableDescriptors.clear();
executionVariableDescriptors.putAll(innerExecution.getContext().getExecutionVariableDescriptors());
for (IteratorContainerInterfaceResolver.ResolvedOutput resolvedOutput : resolution.resolvedOutputs()) {
Object value = innerExecution.getContext().getResult()

View File

@ -3,8 +3,10 @@ package it.cnr.isti.workflow.manager.executions.persistence;
import java.util.List;
import java.util.Map;
import it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor;
import it.cnr.isti.workflow.manager.executions.ExecutionStatus;
import it.cnr.isti.workflow.manager.executions.FieldKey;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -19,6 +21,10 @@ public class ExecutionSnapshot {
private ExecutionStatus status;
private Long startTime;
private Long endTime;
private boolean interactionSimulationEnabled;
private LLMDescriptor interactionSimulationDescriptor;
private Map<String, Object> executionVariables;
private Map<String, ExecutionVariableDescriptor> executionVariableDescriptors;
private Map<String, Object> providedAuthorizations;
private Map<FieldKey, Object> inputs;
private Map<FieldKey, Object> result;

View File

@ -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<N extends FlowNode> implements InputListener {
@Getter
private Map<String, Object> authorizations = Map.of();
@Setter
@Getter
private Map<String, Object> executionVariables = Map.of();
@Setter
@Getter
private Map<String, it.cnr.isti.workflow.manager.executions.ExecutionVariableDescriptor> 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<N extends FlowNode> implements InputListener {
this.status = StepStatus.RUNNING;
listener.started(this.id);
try {
Map<String, Object> outputs = NodeExecutors.execute(this.node, this.inputs, authorizations);
Map<String, Object> 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<N extends FlowNode> 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<N extends FlowNode> 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);
}

View File

@ -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<ValidationError> validateSharedExecutionVariableOrdering(FlowData flowData) {
List<ValidationError> errors = new ArrayList<>();
Map<String, List<String>> 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<String, List<String>> 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<String> 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<String, List<String>> buildOutgoingGraph(List<Connection> connections) {
Map<String, List<String>> 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<String, List<String>> outgoing, Set<String> 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();
}
}

View File

@ -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<String, ExecutionVariableDescriptor> 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<String, ExecutionVariableDescriptor> 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<String, ExecutionVariableDescriptor> 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<String, ExecutionVariableDescriptor> descriptors, String key) {
return getSessionDescriptor(descriptors, key) != null;
}
public static Set<String> sharedKeys(Map<String, ExecutionVariableDescriptor> descriptors) {
if (descriptors == null || descriptors.isEmpty()) {
return Set.of();
}
LinkedHashSet<String> 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);
}
}

View File

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

View File

@ -340,7 +340,6 @@ public class AssistantControllerTest {
HumanInteractiveBlockConfiguration reviewConfiguration = HumanInteractiveBlockConfiguration.builder()
.name("Human review")
.actionDescription("Review high-risk tickets")
.simulateWith(llmDescriptor)
.build();
Block<HumanInteractionBlockType> reviewBlock = Block.<HumanInteractionBlockType>builder()

View File

@ -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<BlockConfigurationDescriptor> 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<MCPAgentChatBlockType> 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<MCPAgentBlockType> block = blocksController.create(MCPAgentBlockConfiguration.builder()
.name("Shared agent")
.model("llama3.1:8b")
.prompt("Find data for ${{candidate}}")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
ResponseEntity<FlowView> createdFlow = flowController.createFlow(
new FlowCreateRequest(
"Shared Session Flow",
"Flow with MCP shared session",
FlowData.builder().block(block).build()),
new LoginEntity("testuser", "testpassword"));
List<it.cnr.isti.workflow.manager.configurations.retrievers.SecureDynamicFieldRetriever.RetrieverItem> 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<MCPAgentBlockType> initialBlock = blocksController.create(MCPAgentBlockConfiguration.builder()
.name("Shared agent")
.model("llama3.1:8b")
.prompt("Find data for ${{candidate}}")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
ResponseEntity<FlowView> createdFlow = flowController.createFlow(
new FlowCreateRequest(
"Shared Session Flow",
"Flow with MCP shared session",
FlowData.builder().block(initialBlock).build()),
new LoginEntity("testuser", "testpassword"));
Block<MCPAgentBlockType> 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<it.cnr.isti.workflow.manager.configurations.retrievers.SecureDynamicFieldRetriever.RetrieverItem> 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<MCPAgentBlockType> persistedBlock = blocksController.create(MCPAgentBlockConfiguration.builder()
.name("Persisted agent")
.model("llama3.1:8b")
.prompt("Find data for ${{candidate}}")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
ResponseEntity<FlowView> createdFlow = flowController.createFlow(
new FlowCreateRequest(
"Shared Session Flow",
"Flow with MCP shared session",
FlowData.builder().block(persistedBlock).build()),
new LoginEntity("testuser", "testpassword"));
Block<MCPAgentBlockType> 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<it.cnr.isti.workflow.manager.configurations.retrievers.SecureDynamicFieldRetriever.RetrieverItem> 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<MCPAgentBlockType> 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<MCPAgentBlockType> 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<MCPAgentChatBlockType> 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

View File

@ -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<String> 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<ChatMessage> messages) {
return "Chat, " + model + "!";
}
};
}
}
@Autowired
private BlocksController blocksController;
@ -159,7 +204,6 @@ public class ExecutionControllerTest {
Block<HumanInteractionBlockType> 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<HumanInteractionBlockType> 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<HumanInteractionBlockType> 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<ChatInteractionBlockType> 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<FlowView> 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<LLMBlockType> 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<FlowView> 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) {

View File

@ -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<MCPAgentBlockType> producer = blocksController.create(MCPAgentBlockConfiguration.builder()
.name("Producer")
.model("llama3.1:8b")
.prompt("Start MCP research")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
Block<MCPAgentBlockType> 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<FlowView> 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<MCPAgentBlockType> firstProducer = blocksController.create(MCPAgentBlockConfiguration.builder()
.name("Producer 1")
.model("llama3.1:8b")
.prompt("Start MCP research")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
Block<MCPAgentBlockType> 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<FlowView> 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()

View File

@ -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<List<ChatMessage>> 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<ChatInteractionBlockType> 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<ChatInteractionBlockType> 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<ChatInteractionBlockType> 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<MCPAgentChatBlockType> 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<MCPAgentChatBlockType> 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<MCPAgentChatBlockType> 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<LLMBlockType> 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<HumanInteractionBlockType> 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<MCPAgentChatBlockType> 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<MCPAgentBlockType> producer = mcpAgentBlockFactory.create(MCPAgentBlockConfiguration.builder()
.name("Research session")
.model("llama3.1:8b")
.prompt("Find data for ${{cand}}")
.shareSession(true)
.sharedSessionName("candidateResearchSession")
.build());
Block<MCPAgentBlockType> 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.<Container<?>>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

View File

@ -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 {

View File

@ -90,7 +90,6 @@ public class FlowTestCreator {
Block<HumanInteractionBlockType> block2 = humanInteractiveBlockFactory.create(HumanInteractiveBlockConfiguration.builder()
.actionDescription("Answer the question in input")
.name("interactive")
.simulateWith(llmBrick)
.build());
Connection connection = Connection.builder()