Add JsonSchemaProducer, UiOrder annotation, and enhance LoopContainer and block configurations

This commit is contained in:
Lucio Lelii 2026-04-24 12:23:43 +02:00
parent a50c6d7802
commit c88875c3ba
26 changed files with 904 additions and 106 deletions

View File

@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver;
import it.cnr.isti.workflow.manager.blocks.types.BlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.NoArgsConstructor;
@ -24,6 +25,7 @@ import lombok.NoArgsConstructor;
public abstract class BlockConfiguration<T extends BlockType> {
@NotBlank
@UiOrder(10)
@JsonProperty(required = true)
String name;

View File

@ -39,6 +39,10 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration<ChatIn
@Valid
private List<ChatInteractionInput> inputs = List.of();
@Structural
@JsonProperty(required = true)
private boolean exposeHistory = true;
@Override
public Class<ChatInteractionBlockType> getBlockType() {
return ChatInteractionBlockType.class;
@ -47,17 +51,20 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration<ChatIn
@Builder
public ChatInteractionBlockConfiguration(@NonNull String name, LLMDescriptor llmDescriptor,
String goalDescription,
List<ChatInteractionInput> inputs) {
List<ChatInteractionInput> inputs,
Boolean exposeHistory) {
super(name);
this.llmDescriptor = llmDescriptor;
this.goalDescription = goalDescription;
this.inputs = inputs == null ? List.of() : List.copyOf(inputs);
this.exposeHistory = exposeHistory == null ? true : exposeHistory;
}
public static ChatInteractionBlockConfiguration empty() {
ChatInteractionBlockConfiguration configuration = new ChatInteractionBlockConfiguration();
configuration.name = ChatInteractionBlockType.TYPE;
configuration.inputs = List.of();
configuration.exposeHistory = true;
return configuration;
}

View File

@ -6,6 +6,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.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
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,6 +23,12 @@ import lombok.NonNull;
@EqualsAndHashCode(callSuper = true)
public class ConditionalBlockConfiguration extends BlockConfiguration<ConditionalBlockType> {
@UiOrder(30)
@JsonProperty(required = true)
@Structural
boolean useLlm;
@UiOrder(40)
@UiEnabledWhen(field = "useLlm", equals = "false")
@Structural
@LongText(
@ -30,15 +37,13 @@ public class ConditionalBlockConfiguration extends BlockConfiguration<Conditiona
acceptVariableAsPlaceholder = true)
String condition;
@JsonProperty(required = true)
@Structural
boolean useLlm;
@Valid
@UiOrder(50)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
LLMDescriptor llmDescriptor;
@UiOrder(60)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@ -48,6 +53,7 @@ public class ConditionalBlockConfiguration extends BlockConfiguration<Conditiona
acceptVariableAsPlaceholder = true)
String prompt;
@UiOrder(70)
@Structural
@LongText(
placeholder = "Define the output to propagate, for example: Approved request for ${{customer}}",

View File

@ -5,9 +5,13 @@ import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
@ -33,6 +37,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiLabel;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionsFromNode;
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
import jakarta.validation.constraints.Size;
@ -57,6 +62,7 @@ public class JsonSchemaProducer {
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(type);
Map<Class<?>, Map<String, Structural>> structuralMap = collectStructuralMetadata(type);
Map<Class<?>, Map<String, UiEnabledWhen>> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type);
Map<Class<?>, Map<String, UiOrder>> uiOrderMap = collectUiOrderMetadata(type);
Map<Class<?>, Map<String, UiOptionsFromNode>> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type);
Map<Class<?>, Map<String, UiRequiredWhen>> uiRequiredWhenMap = collectUiRequiredWhenMetadata(type);
Map<Class<?>, Map<String, UiUniqueItemsBy>> uiUniqueItemsByMap = collectUiUniqueItemsByMetadata(type);
@ -70,6 +76,7 @@ public class JsonSchemaProducer {
applyLongTextMetadata(root, getMergedMetadata(longTextMap, type));
applyStructuralMetadata(root, getMergedMetadata(structuralMap, type));
applyUiEnabledWhenMetadata(root, getMergedMetadata(uiEnabledWhenMap, type));
applyUiOrderMetadata(root, type, getMergedMetadata(uiOrderMap, type));
applyUiOptionsFromNodeMetadata(root, getMergedMetadata(uiOptionsFromNodeMap, type));
applyUiRequiredWhenMetadata(root, getMergedMetadata(uiRequiredWhenMap, type));
applyUiUniqueItemsByMetadata(root, getMergedMetadata(uiUniqueItemsByMap, type));
@ -86,6 +93,7 @@ public class JsonSchemaProducer {
metadataClasses.addAll(longTextMap.keySet());
metadataClasses.addAll(structuralMap.keySet());
metadataClasses.addAll(uiEnabledWhenMap.keySet());
metadataClasses.addAll(uiOrderMap.keySet());
metadataClasses.addAll(uiOptionsFromNodeMap.keySet());
metadataClasses.addAll(uiRequiredWhenMap.keySet());
metadataClasses.addAll(uiUniqueItemsByMap.keySet());
@ -105,6 +113,7 @@ public class JsonSchemaProducer {
applyLongTextMetadata(classSchema, getMergedMetadata(longTextMap, matchedClass));
applyStructuralMetadata(classSchema, getMergedMetadata(structuralMap, matchedClass));
applyUiEnabledWhenMetadata(classSchema, getMergedMetadata(uiEnabledWhenMap, matchedClass));
applyUiOrderMetadata(classSchema, matchedClass, getMergedMetadata(uiOrderMap, matchedClass));
applyUiOptionsFromNodeMetadata(classSchema, getMergedMetadata(uiOptionsFromNodeMap, matchedClass));
applyUiRequiredWhenMetadata(classSchema, getMergedMetadata(uiRequiredWhenMap, matchedClass));
applyUiUniqueItemsByMetadata(classSchema, getMergedMetadata(uiUniqueItemsByMap, matchedClass));
@ -859,6 +868,90 @@ public class JsonSchemaProducer {
return result;
}
private Map<Class<?>, Map<String, UiOrder>> collectUiOrderMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, UiOrder>> 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, UiOrder> metadata = new LinkedHashMap<>();
for (Field field : current.getDeclaredFields()) {
UiOrder annotation = field.getAnnotation(UiOrder.class);
if (annotation != null) {
metadata.put(field.getName(), annotation);
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
UiOrder annotation = component.getAnnotation(UiOrder.class);
if (annotation != null) {
metadata.put(component.getName(), annotation);
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (current.getSuperclass() != null) {
queue.add(current.getSuperclass());
}
if (!metadata.isEmpty()) {
result.put(current, metadata);
}
}
return result;
}
private void applyUiOrderMetadata(ObjectNode classSchema, Class<?> ownerClass, Map<String, UiOrder> metadata) {
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
List<String> propertyNames = new ArrayList<>();
for (String propertyName : iterable(properties.fieldNames())) {
propertyNames.add(propertyName);
}
if (propertyNames.isEmpty()) {
return;
}
Map<String, Integer> declarationIndex = new LinkedHashMap<>();
List<String> declaredNames = collectDeclaredPropertyNames(ownerClass);
for (int i = 0; i < declaredNames.size(); i++) {
declarationIndex.put(declaredNames.get(i), i);
}
propertyNames.sort(Comparator
.comparingInt((String name) -> metadata != null && metadata.containsKey(name)
? metadata.get(name).value()
: Integer.MAX_VALUE)
.thenComparingInt(name -> declarationIndex.getOrDefault(name, Integer.MAX_VALUE))
.thenComparing(Comparator.naturalOrder()));
ObjectNode reorderedProperties = properties.objectNode();
ArrayNode propertyOrder = classSchema.putArray("x-ui-property-order");
for (int i = 0; i < propertyNames.size(); i++) {
String propertyName = propertyNames.get(i);
JsonNode propertyNode = properties.get(propertyName);
if (propertyNode instanceof ObjectNode propertySchema) {
propertySchema.put("x-ui-order", i + 1);
}
reorderedProperties.set(propertyName, propertyNode);
propertyOrder.add(propertyName);
}
classSchema.set("properties", reorderedProperties);
}
private void applyUiEnabledWhenMetadata(ObjectNode classSchema, Map<String, UiEnabledWhen> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
@ -895,6 +988,32 @@ public class JsonSchemaProducer {
}
}
private List<String> collectDeclaredPropertyNames(Class<?> type) {
if (type == null) {
return List.of();
}
Deque<Class<?>> hierarchy = new ArrayDeque<>();
Class<?> current = type;
while (current != null && current != Object.class) {
hierarchy.addFirst(current);
current = current.getSuperclass();
}
Set<String> ordered = new LinkedHashSet<>();
for (Class<?> clazz : hierarchy) {
for (Field field : clazz.getDeclaredFields()) {
ordered.add(field.getName());
}
if (clazz.isRecord()) {
for (RecordComponent component : clazz.getRecordComponents()) {
ordered.add(component.getName());
}
}
}
return new ArrayList<>(ordered);
}
private void applyUiRequiredWhenMetadata(ObjectNode classSchema, Map<String, UiRequiredWhen> metadata) {
if (metadata == null || metadata.isEmpty()) {
return;
@ -922,6 +1041,9 @@ public class JsonSchemaProducer {
equalsAny.add(value);
}
}
if (dependency.present()) {
requiredWhen.put("present", true);
}
}
}
@ -981,6 +1103,8 @@ public class JsonSchemaProducer {
ConfigurableAsInput annotation = entry.getValue();
propertySchema.put("x-ui-bindable-as-input", true);
propertySchema.put("x-ui-structural", true);
propertySchema.put("x-ui-binding-static-label", "static value");
propertySchema.put("x-ui-binding-input-label", "node input");
propertySchema.put("x-ui-input-name", annotation.inputName().isBlank() ? entry.getKey() : annotation.inputName());
propertySchema.put("x-ui-input-type", annotation.type().name());
propertySchema.put("x-ui-input-multiple", annotation.multiple());

View File

@ -1,13 +1,20 @@
package it.cnr.isti.workflow.manager.blocks.configurations;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType;
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.skills.SkillBinding;
import it.cnr.isti.workflow.manager.skills.SkillsCatalogService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@ -31,11 +38,20 @@ public class LLMBlockConfiguration extends BlockConfiguration<LLMBlockType> {
@JsonProperty(required = false)
String prompt;
@Structural
@Valid
@Size(max = SkillsCatalogService.MAX_SKILLS_PER_BLOCK)
@UiUniqueItemsBy("skillId")
@JsonProperty(required = false)
List<SkillBinding> skills = List.of();
@Builder
public LLMBlockConfiguration(@NonNull String name, @NonNull LLMDescriptor llmDescriptor, String prompt) {
public LLMBlockConfiguration(@NonNull String name, @NonNull LLMDescriptor llmDescriptor, String prompt,
List<SkillBinding> skills) {
super(name);
this.llmDescriptor = llmDescriptor;
this.prompt = prompt;
this.skills = skills == null ? List.of() : List.copyOf(skills);
}
@Override
@ -46,7 +62,23 @@ public class LLMBlockConfiguration extends BlockConfiguration<LLMBlockType> {
public static LLMBlockConfiguration empty() {
LLMBlockConfiguration configuration = new LLMBlockConfiguration();
configuration.name = LLMBlockType.TYPE;
configuration.skills = List.of();
return configuration;
}
@AssertTrue(message = "skills must have unique skillId values")
boolean areSkillIdsUnique() {
if (skills == null || skills.isEmpty()) {
return true;
}
return skills.stream()
.map(SkillBinding::skillId)
.filter(skillId -> skillId != null && !skillId.isBlank())
.distinct()
.count() == skills.stream()
.map(SkillBinding::skillId)
.filter(skillId -> skillId != null && !skillId.isBlank())
.count();
}
}

View File

@ -34,6 +34,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration<MCPAgentBlock
@Structural
@ConfigurableAsInput
@UiEnabledWhen(field = "useSharedSession", equals = "false")
@UiRequiredWhen(field = "useSharedSession", equals = "false")
@FieldRetriever(name = "LLM", url = "/retriever/LLM/models?provider=InternalOllama")
@JsonProperty(required = false)
private String model;
@ -96,6 +97,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration<MCPAgentBlock
public static MCPAgentBlockConfiguration empty() {
MCPAgentBlockConfiguration configuration = new MCPAgentBlockConfiguration();
configuration.name = MCPAgentBlockType.TYPE;
configuration.model = "";
configuration.mcpServers = List.of();
configuration.shareSession = false;
configuration.useSharedSession = false;

View File

@ -42,6 +42,10 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
@Valid
private List<ChatInteractionInput> inputs = List.of();
@Structural
@JsonProperty(required = true)
private boolean exposeHistory = true;
@NotBlank
@LongText(placeholder = "Describe the goal of the simulated chat")
@JsonProperty(required = true)
@ -78,12 +82,14 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
@Builder
public MCPAgentChatBlockConfiguration(@NonNull String name, String model, List<ChatInteractionInput> inputs,
Boolean exposeHistory,
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.exposeHistory = exposeHistory == null ? true : exposeHistory;
this.goalDescription = goalDescription;
this.mcpServers = mcpServers == null ? List.of() : List.copyOf(mcpServers);
this.shareSession = shareSession;
@ -100,7 +106,9 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
public static MCPAgentChatBlockConfiguration empty() {
MCPAgentChatBlockConfiguration configuration = new MCPAgentChatBlockConfiguration();
configuration.name = MCPAgentChatBlockType.TYPE;
configuration.model = "";
configuration.inputs = List.of();
configuration.exposeHistory = true;
configuration.mcpServers = List.of();
configuration.shareSession = false;
configuration.useSharedSession = false;

View File

@ -9,6 +9,7 @@ import it.cnr.isti.workflow.manager.blocks.types.SwitchBlockType;
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.UiOrder;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiUniqueItemsBy;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
@ -26,6 +27,7 @@ import lombok.NonNull;
@EqualsAndHashCode(callSuper = true)
public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType> {
@UiOrder(30)
@Structural
@UiUniqueItemsBy("name")
@Size(max = 10)
@ -33,6 +35,12 @@ public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType
@JsonProperty(required = false)
private List<SwitchCase> cases = List.of();
@UiOrder(40)
@JsonProperty(required = true)
@Structural
private boolean useLlm;
@UiOrder(50)
@UiEnabledWhen(field = "useLlm", equals = "false")
@Structural
@LongText(
@ -41,15 +49,13 @@ public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType
acceptVariableAsPlaceholder = true)
private String condition;
@JsonProperty(required = true)
@Structural
private boolean useLlm;
@Valid
@UiOrder(60)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
private LLMDescriptor llmDescriptor;
@UiOrder(70)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@ -59,6 +65,7 @@ public class SwitchBlockConfiguration extends BlockConfiguration<SwitchBlockType
acceptVariableAsPlaceholder = true)
private String prompt;
@UiOrder(80)
@Structural
@LongText(
placeholder = "Define the output payload to propagate on the selected branch",

View File

@ -36,13 +36,15 @@ public class ChatInteractionBlockFactory
@Override
public Block<ChatInteractionBlockType> create(ChatInteractionBlockConfiguration configuration) {
validateConfiguration(configuration);
return Block.<ChatInteractionBlockType>builder()
Block.BlockBuilder<ChatInteractionBlockType> builder = Block.<ChatInteractionBlockType>builder()
.inputs(resolveInputs(configuration))
.output(IODescriptor.output(RESPONSE_OUTPUT, IOType.TEXT, false, RESPONSE_CAPABILITIES))
.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES))
.specificConfiguration(configuration)
.type(blockType)
.build();
.type(blockType);
if (configuration.isExposeHistory()) {
builder.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES));
}
return builder.build();
}
@Override

View File

@ -52,7 +52,13 @@ public class MCPAgentBlockFactory implements BlockFactory<MCPAgentBlockType, MCP
@Override
public Block<MCPAgentBlockType> createEmpty() {
return create(MCPAgentBlockConfiguration.empty());
MCPAgentBlockConfiguration configuration = MCPAgentBlockConfiguration.empty();
return Block.<MCPAgentBlockType>builder()
.inputs(retrieveInputs(configuration.getPrompt()))
.output(IODescriptor.output(OUTPUT_NAME, IOType.TEXT, false, OUTPUT_CAPABILITIES))
.specificConfiguration(configuration)
.type(blockType)
.build();
}
@Override

View File

@ -37,18 +37,29 @@ public class MCPAgentChatBlockFactory
@Override
public Block<MCPAgentChatBlockType> create(MCPAgentChatBlockConfiguration configuration) {
validateConfiguration(configuration);
return Block.<MCPAgentChatBlockType>builder()
Block.BlockBuilder<MCPAgentChatBlockType> builder = Block.<MCPAgentChatBlockType>builder()
.inputs(resolveInputs(configuration))
.output(IODescriptor.output(RESPONSE_OUTPUT, IOType.TEXT, false, RESPONSE_CAPABILITIES))
.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES))
.specificConfiguration(configuration)
.type(blockType)
.build();
.type(blockType);
if (configuration.isExposeHistory()) {
builder.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES));
}
return builder.build();
}
@Override
public Block<MCPAgentChatBlockType> createEmpty() {
return create(MCPAgentChatBlockConfiguration.empty());
MCPAgentChatBlockConfiguration configuration = MCPAgentChatBlockConfiguration.empty();
Block.BlockBuilder<MCPAgentChatBlockType> builder = Block.<MCPAgentChatBlockType>builder()
.inputs(List.of())
.output(IODescriptor.output(RESPONSE_OUTPUT, IOType.TEXT, false, RESPONSE_CAPABILITIES))
.specificConfiguration(configuration)
.type(blockType);
if (configuration.isExposeHistory()) {
builder.output(IODescriptor.output(HISTORY_OUTPUT, IOType.TEXT, true, HISTORY_CAPABILITIES));
}
return builder.build();
}
@Override

View File

@ -0,0 +1,12 @@
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 UiOrder {
int value();
}

View File

@ -13,4 +13,6 @@ public @interface UiRequiredWhen {
String equals() default "";
String[] equalsAny() default {};
boolean present() default false;
}

View File

@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver;
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
import it.cnr.isti.workflow.manager.containers.types.ContainerType;
import it.cnr.isti.workflow.manager.flows.model.FlowData;
import jakarta.validation.Valid;
@ -26,9 +27,11 @@ import lombok.NoArgsConstructor;
public abstract class ContainerConfiguration<T extends ContainerType> {
@NotBlank
@UiOrder(10)
@JsonProperty(required = true)
String name;
@UiOrder(20)
@Structural
@Valid
@FieldRetriever(

View File

@ -1,11 +1,14 @@
package it.cnr.isti.workflow.manager.containers.configurations;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
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.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionsFromNode;
import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen;
import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterfaceResolver;
import it.cnr.isti.workflow.manager.containers.types.LoopContainerType;
@ -25,50 +28,89 @@ import lombok.NonNull;
@EqualsAndHashCode(callSuper = true)
public class LoopContainerConfiguration extends ContainerConfiguration<LoopContainerType> {
@UiEnabledWhen(field = "useLlm", equals = "false")
@Structural
@LongText(
placeholder = "Add a deterministic stop condition, for example: ${{response}} == 'done' or #iteration >= 3",
tip = "Supports SpEL. Inputs and latest exposed outputs are available through placeholders ${{}}. The variable #iteration contains the current 1-based iteration number.",
acceptVariableAsPlaceholder = true)
private String condition;
@JsonProperty(required = true)
@Structural
private boolean useLlm;
@Valid
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
private LLMDescriptor llmDescriptor;
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@LongText(
placeholder = "Add the prompt the LLM should use to decide whether the loop should stop",
tip = "The LLM must answer true or false. Inputs, outputs and iteration are included in the evaluation context.",
acceptVariableAsPlaceholder = true)
private String prompt;
@UiOrder(30)
@Structural
@Min(1)
@JsonProperty(required = true)
private Integer maxIterations;
@UiOrder(40)
@JsonProperty(required = true)
@Structural
private boolean useLlm;
@UiOrder(50)
@UiEnabledWhen(field = "useLlm", equals = "false")
@Structural
@JsonAlias("condition")
@JsonProperty("guardCondition")
@LongText(
placeholder = "Add a deterministic guard condition, for example: ${{response}} == 'done' or ${{outputs.response}} == 'done' or #iteration >= 3",
tip = "Supports SpEL. Inputs and latest exposed outputs are available through placeholders ${{}}. Use outputs.* to reference exposed subflow outputs explicitly. The variable #iteration contains the current 1-based iteration number.",
acceptVariableAsPlaceholder = true)
private String guardCondition;
@Valid
@UiOrder(60)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
private LLMDescriptor llmDescriptor;
@UiOrder(70)
@UiEnabledWhen(field = "useLlm", equals = "true", group = "llm")
@UiRequiredWhen(field = "useLlm", equals = "true")
@Structural
@JsonAlias("prompt")
@JsonProperty("guardPrompt")
@LongText(
placeholder = "Add the guard prompt the LLM should use to decide whether the loop should stop",
tip = "The LLM must answer true or false. Inputs, exposed outputs and iteration are included in the evaluation context. Use outputs.* to reference exposed subflow outputs explicitly.",
acceptVariableAsPlaceholder = true)
private String guardPrompt;
@UiOrder(80)
@Structural
@UiEnabledWhen(field = "subFlow", present = true)
@UiOptionsFromNode(collection = "inputs", valueField = "name", labelField = "name")
@JsonProperty(required = false)
private String feedbackInput;
@Valid
@UiOrder(90)
@UiEnabledWhen(field = "feedbackPrompt", present = true, group = "feedback")
@UiRequiredWhen(field = "feedbackPrompt", present = true)
private LLMDescriptor feedbackLlmDescriptor;
@UiOrder(100)
@UiEnabledWhen(field = "feedbackInput", present = true, group = "feedback")
@UiRequiredWhen(field = "feedbackInput", present = true)
@Structural
@JsonProperty(required = false)
@LongText(
placeholder = "Add the prompt that constructs the next value for the selected input when the guard is false",
tip = "Executed only when the loop continues. Use inputs.*, outputs.* and iteration to build the next input value. The LLM response becomes the next value of the selected input.",
acceptVariableAsPlaceholder = true)
private String feedbackPrompt;
@Builder
public LoopContainerConfiguration(@NonNull String name,
FlowData subFlow,
String condition,
String guardCondition,
boolean useLlm,
LLMDescriptor llmDescriptor,
String prompt,
String guardPrompt,
String feedbackInput,
LLMDescriptor feedbackLlmDescriptor,
String feedbackPrompt,
Integer maxIterations) {
super(name, subFlow);
this.condition = condition;
this.guardCondition = guardCondition;
this.useLlm = useLlm;
this.llmDescriptor = llmDescriptor;
this.prompt = prompt;
this.guardPrompt = guardPrompt;
this.feedbackInput = feedbackInput;
this.feedbackLlmDescriptor = feedbackLlmDescriptor;
this.feedbackPrompt = feedbackPrompt;
this.maxIterations = maxIterations == null ? 10 : maxIterations;
}
@ -85,13 +127,16 @@ public class LoopContainerConfiguration extends ContainerConfiguration<LoopConta
false,
null,
"",
null,
null,
null,
10);
}
@AssertTrue(message = "condition is required when useLlm is false")
@AssertTrue(message = "guardCondition is required when useLlm is false")
@JsonIgnore
boolean isDeterministicConfigurationValid() {
return useLlm || (condition != null && !condition.isBlank());
return useLlm || (guardCondition != null && !guardCondition.isBlank());
}
@AssertTrue(message = "llmDescriptor is required when useLlm is true")
@ -100,10 +145,40 @@ public class LoopContainerConfiguration extends ContainerConfiguration<LoopConta
return !useLlm || llmDescriptor != null;
}
@AssertTrue(message = "prompt is required when useLlm is true")
@AssertTrue(message = "guardPrompt is required when useLlm is true")
@JsonIgnore
boolean isLlmPromptConfigurationValid() {
return !useLlm || (prompt != null && !prompt.isBlank());
return !useLlm || (guardPrompt != null && !guardPrompt.isBlank());
}
@AssertTrue(message = "feedbackPrompt requires feedbackInput")
@JsonIgnore
boolean isFeedbackInputPresentWhenFeedbackPromptConfigured() {
return feedbackPrompt == null || feedbackPrompt.isBlank()
|| (feedbackInput != null && !feedbackInput.isBlank());
}
@AssertTrue(message = "feedbackInput requires feedbackPrompt")
@JsonIgnore
boolean isFeedbackPromptPresentWhenFeedbackInputConfigured() {
return feedbackInput == null || feedbackInput.isBlank()
|| (feedbackPrompt != null && !feedbackPrompt.isBlank());
}
@AssertTrue(message = "feedbackLlmDescriptor is required when feedbackPrompt is configured")
@JsonIgnore
boolean isFeedbackLlmConfigurationValid() {
return feedbackPrompt == null || feedbackPrompt.isBlank() || feedbackLlmDescriptor != null;
}
@AssertTrue(message = "feedbackInput must target an open non-multiple input of the subFlow")
@JsonIgnore
boolean isFeedbackInputValid() {
if (!hasSubFlowNodes() || feedbackInput == null || feedbackInput.isBlank()) {
return true;
}
return ContainerFlowInterfaceResolver.getExposedInputs(getSubFlow()).stream()
.anyMatch(handle -> handle.publicName().equals(feedbackInput) && !handle.handle().io().isMultiple());
}
@AssertTrue(message = "maxIterations must be greater than zero")
@ -121,4 +196,16 @@ public class LoopContainerConfiguration extends ContainerConfiguration<LoopConta
private boolean hasSubFlowNodes() {
return getSubFlow() != null && !getSubFlow().getNodes().isEmpty();
}
@Deprecated
@JsonIgnore
public String getCondition() {
return guardCondition;
}
@Deprecated
@JsonIgnore
public String getPrompt() {
return guardPrompt;
}
}

View File

@ -17,7 +17,7 @@ public class LoopContainerType implements ContainerType {
@Override
public String getDescription() {
return "A container node that repeatedly executes a subflow until a stop condition becomes true. The stop condition can be deterministic (SpEL) or evaluated by an LLM.";
return "A container node that repeatedly executes a subflow until its guard evaluates to true. The guard can be deterministic (SpEL) or evaluated by an LLM.";
}
@Override

View File

@ -83,9 +83,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
String finalResponse = generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor, simulatorAuthorization,
resolvedGoal, inputs, history);
return Map.of(
ChatInteractionBlockFactory.RESPONSE_OUTPUT, finalResponse,
ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(history));
return buildResult(configuration, finalResponse, List.copyOf(history));
}
@Override
@ -140,9 +138,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
updatedHistory.add(formatConversationLine(ChatMessage.Role.USER, resolvedMessage));
updatedHistory.add(formatConversationLine(ChatMessage.Role.ASSISTANT, assistantResponse));
return InteractionResult.partial(Map.of(
ChatInteractionBlockFactory.RESPONSE_OUTPUT, assistantResponse,
ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(updatedHistory)));
return InteractionResult.partial(buildResult(configuration, assistantResponse, List.copyOf(updatedHistory)));
}
if (interaction.containsKey(ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD)) {
@ -152,9 +148,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
+ ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD);
}
List<String> history = existingHistory(partialResults);
return InteractionResult.completed(Map.of(
ChatInteractionBlockFactory.RESPONSE_OUTPUT, response,
ChatInteractionBlockFactory.HISTORY_OUTPUT, List.copyOf(history)));
return InteractionResult.completed(buildResult(configuration, response, List.copyOf(history)));
}
throw new IllegalArgumentException("Unsupported interaction field for ChatInteraction");
@ -247,6 +241,16 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
return "[" + role.name() + "] " + content;
}
private Map<String, Object> buildResult(ChatInteractionBlockConfiguration configuration, String response,
List<String> history) {
Map<String, Object> result = new LinkedHashMap<>();
result.put(ChatInteractionBlockFactory.RESPONSE_OUTPUT, response);
if (configuration.isExposeHistory()) {
result.put(ChatInteractionBlockFactory.HISTORY_OUTPUT, history);
}
return result;
}
@SuppressWarnings("unchecked")
private List<String> existingHistory(Map<String, Object> partialResults) {
Object value = partialResults.get(ChatInteractionBlockFactory.HISTORY_OUTPUT);

View File

@ -20,6 +20,7 @@ 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;
import it.cnr.isti.workflow.manager.skills.SkillPromptService;
@Component
public class LLMExecutor implements BlockExecutor<LLMBlockType> {
@ -29,6 +30,9 @@ public class LLMExecutor implements BlockExecutor<LLMBlockType> {
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private SkillPromptService skillPromptService;
@Override
public Map<String, Object> execute(Block<LLMBlockType> block, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors,
@ -46,6 +50,11 @@ public class LLMExecutor implements BlockExecutor<LLMBlockType> {
prompt = ExecutionTemplateResolver.formatValue(inputs.getFirst().getValue());
}
String skillsInstructions = skillPromptService.renderSkillsInstructions(config.getSkills());
if (StringUtils.hasText(skillsInstructions)) {
prompt = skillsInstructions + System.lineSeparator() + System.lineSeparator() + prompt;
}
LLMDescriptor llmDescriptor = config.getLlmDescriptor();
LLMProvider llmProvider = llmProviders.get(llmDescriptor.provider());
if (llmProvider == null) {

View File

@ -90,9 +90,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
String finalResponse = generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor, simulatorAuthorization,
resolvedGoal, inputs, history);
return Map.of(
MCPAgentChatBlockFactory.RESPONSE_OUTPUT, finalResponse,
MCPAgentChatBlockFactory.HISTORY_OUTPUT, List.copyOf(history));
return buildResult(configuration, finalResponse, List.copyOf(history), null);
} finally {
if (!managedSharedSession) {
mcpAgentService.closeSessionQuietly(sessionId);
@ -157,10 +155,8 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
List<String> updatedHistory = new ArrayList<>(history);
updatedHistory.add(formatConversationLine("USER", resolvedMessage));
updatedHistory.add(formatConversationLine("ASSISTANT", assistantResponse));
return InteractionResult.partial(Map.of(
MCPAgentChatBlockFactory.RESPONSE_OUTPUT, assistantResponse,
MCPAgentChatBlockFactory.HISTORY_OUTPUT, List.copyOf(updatedHistory),
SESSION_ID_STATE, existingSessionId));
return InteractionResult.partial(buildResult(configuration, assistantResponse, List.copyOf(updatedHistory),
existingSessionId));
} catch (RuntimeException ex) {
if (createdSession && !isManagedSharedSession(configuration, executionVariableDescriptors)) {
mcpAgentService.closeSessionQuietly(existingSessionId);
@ -188,9 +184,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
}
}
List<String> history = existingHistory(partialResults);
return InteractionResult.completed(Map.of(
MCPAgentChatBlockFactory.RESPONSE_OUTPUT, response,
MCPAgentChatBlockFactory.HISTORY_OUTPUT, List.copyOf(history)));
return InteractionResult.completed(buildResult(configuration, response, List.copyOf(history), null));
}
throw new IllegalArgumentException("Unsupported interaction field for MCPAgentChat");
@ -291,6 +285,19 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
return normalized;
}
private Map<String, Object> buildResult(MCPAgentChatBlockConfiguration configuration, String response,
List<String> history, String sessionId) {
Map<String, Object> result = new LinkedHashMap<>();
result.put(MCPAgentChatBlockFactory.RESPONSE_OUTPUT, response);
if (configuration.isExposeHistory()) {
result.put(MCPAgentChatBlockFactory.HISTORY_OUTPUT, history);
}
if (StringUtils.hasText(sessionId)) {
result.put(SESSION_ID_STATE, sessionId);
}
return result;
}
@SuppressWarnings("unchecked")
private List<String> existingHistory(Map<String, Object> partialResults) {
Object value = partialResults.get(MCPAgentChatBlockFactory.HISTORY_OUTPUT);

View File

@ -46,6 +46,12 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
Reply strictly as JSON in the form {"result":true} or {"result":false}.
Do not add any extra text.
""";
private static final String FEEDBACK_SYSTEM_PROMPT = """
You are a workflow loop feedback constructor.
Generate the next value for the requested input so the next loop iteration can improve the previous result.
Reply only with the raw replacement value for that input.
Do not add markdown, labels, or explanations.
""";
private final ExecutionsService executionsService;
private final Map<String, LLMProvider> llmProviders;
@ -127,12 +133,13 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
}
}
Map<String, Object> iterationInputs = new LinkedHashMap<>(currentInputs);
updateInputsForNextIteration(currentInputs, latestOutputs, inputPortsByName);
boolean shouldStop = shouldStop(configuration, currentInputs, latestOutputs, authorizations, executionVariables, iteration);
if (eventLogger != null) {
eventLogger.info(ExecutionEventType.CONTAINER_CONDITION_EVALUATED,
"Evaluated loop stop condition",
"Evaluated loop guard",
Map.of("containerType", LoopContainerType.TYPE, "iteration", iteration, "stop", shouldStop));
eventLogger.info(ExecutionEventType.CONTAINER_ITERATION_COMPLETED,
shouldStop ? "Loop completed at iteration " + iteration : "Completed loop iteration " + iteration,
@ -141,10 +148,13 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
if (shouldStop) {
return new LinkedHashMap<>(latestOutputs);
}
applyFeedbackInputForNextIteration(configuration, currentInputs, iterationInputs, latestOutputs,
authorizations, executionVariables, iteration, eventLogger);
}
throw new IllegalStateException(
"LoopContainer condition was not satisfied within maxIterations=" + configuration.getMaxIterations());
"LoopContainer guard was not satisfied within maxIterations=" + configuration.getMaxIterations());
}
@Override
@ -164,20 +174,23 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
private boolean shouldStop(LoopContainerConfiguration configuration, Map<String, Object> currentInputs,
Map<String, Object> latestOutputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, int iteration) {
Map<String, Object> contextValues = new LinkedHashMap<>(currentInputs);
contextValues.putAll(latestOutputs);
contextValues.put("iteration", iteration);
Map<String, Object> contextValues = buildGuardTemplateValues(currentInputs, latestOutputs, iteration);
return configuration.isUseLlm()
? evaluateWithLlm(configuration, contextValues, authorizations, executionVariables, iteration)
: evaluateWithExpression(configuration.getCondition(), contextValues, executionVariables, iteration);
? evaluateWithLlm(configuration, contextValues, currentInputs, latestOutputs, authorizations,
executionVariables, iteration)
: evaluateWithExpression(configuration.getCondition(), contextValues, currentInputs, latestOutputs,
executionVariables, iteration);
}
private boolean evaluateWithExpression(String expression, Map<String, Object> values,
Map<String, Object> currentInputs, Map<String, Object> latestOutputs,
Map<String, Object> executionVariables, int iteration) {
String normalizedExpression = normalizeExpression(expression);
StandardEvaluationContext context = new StandardEvaluationContext(values);
context.addPropertyAccessor(new MapAccessor());
values.forEach(context::setVariable);
context.setVariable("inputs", currentInputs == null ? Map.of() : currentInputs);
context.setVariable("outputs", latestOutputs == null ? Map.of() : latestOutputs);
context.setVariable("vars", executionVariables == null ? Map.of() : executionVariables);
context.setVariable("global", ExecutionRuntimeContextSupport.globalView(executionVariables));
context.setVariable("context", ExecutionRuntimeContextSupport.contextView(executionVariables));
@ -190,37 +203,100 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
}
private boolean evaluateWithLlm(LoopContainerConfiguration configuration, Map<String, Object> values,
Map<String, Object> currentInputs, Map<String, Object> latestOutputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables, int iteration) {
LLMDescriptor llmDescriptor = configuration.getLlmDescriptor();
LLMProvider llmProvider = resolveProvider(llmDescriptor.provider());
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey))))) {
throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider());
}
ensureAuthorization(llmProvider, llmDescriptor.provider(), authorizations);
String prompt = buildLlmPrompt(configuration, values, executionVariables, iteration);
String response = llmProvider.requiresAuthorization()
? llmProvider.generate(llmDescriptor.model(), prompt, String.valueOf(authorizations.get(authKey)))
: llmProvider.generate(llmDescriptor.model(), prompt);
String prompt = buildLlmPrompt(configuration, values, currentInputs, latestOutputs, executionVariables, iteration);
String response = callLlmProvider(llmProvider, llmDescriptor, prompt, authorizations);
return parseBooleanResponse(response);
}
private void applyFeedbackInputForNextIteration(LoopContainerConfiguration configuration, Map<String, Object> nextInputs,
Map<String, Object> iterationInputs, Map<String, Object> latestOutputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, int iteration, ExecutionEventLogger eventLogger) {
if (!StringUtils.hasText(configuration.getFeedbackPrompt()) || !StringUtils.hasText(configuration.getFeedbackInput())) {
return;
}
LLMDescriptor llmDescriptor = configuration.getFeedbackLlmDescriptor();
if (llmDescriptor == null) {
throw new IllegalArgumentException("feedbackLlmDescriptor is required when feedbackPrompt is configured");
}
Map<String, Object> values = buildGuardTemplateValues(iterationInputs, latestOutputs, iteration);
String prompt = buildFeedbackPrompt(configuration, values, iterationInputs, latestOutputs, executionVariables, iteration);
LLMProvider llmProvider = resolveProvider(llmDescriptor.provider());
ensureAuthorization(llmProvider, llmDescriptor.provider(), authorizations);
String response = callLlmProvider(llmProvider, llmDescriptor, prompt, authorizations);
nextInputs.put(configuration.getFeedbackInput(), response);
if (eventLogger != null) {
eventLogger.info(ExecutionEventType.LLM_REQUEST,
"Constructed next loop input " + configuration.getFeedbackInput(),
Map.of(
"containerType", LoopContainerType.TYPE,
"iteration", iteration,
"purpose", "feedbackInput",
"inputName", configuration.getFeedbackInput(),
"provider", llmDescriptor.provider(),
"model", llmDescriptor.model()));
}
}
private String buildLlmPrompt(LoopContainerConfiguration configuration, Map<String, Object> values,
Map<String, Object> currentInputs, Map<String, Object> latestOutputs,
Map<String, Object> executionVariables, int iteration) {
String resolvedPrompt = ExecutionTemplateResolver.resolve(
configuration.getPrompt(),
configuration.getGuardPrompt(),
values,
executionVariables == null ? Map.of() : executionVariables);
StringBuilder builder = new StringBuilder();
builder.append(LLM_SYSTEM_PROMPT).append("\n");
builder.append("Decision prompt: ").append(resolvedPrompt).append("\n");
builder.append("Guard prompt: ").append(resolvedPrompt).append("\n");
builder.append("Inputs: ").append(currentInputs == null ? Map.of() : currentInputs).append("\n");
builder.append("Exposed outputs: ").append(latestOutputs == null ? Map.of() : latestOutputs).append("\n");
builder.append("Current values: ").append(values).append("\n");
builder.append("Execution variables: ").append(executionVariables == null ? Map.of() : executionVariables).append("\n");
builder.append("Iteration: ").append(iteration).append("\n");
return builder.toString();
}
private String buildFeedbackPrompt(LoopContainerConfiguration configuration, Map<String, Object> values,
Map<String, Object> iterationInputs, Map<String, Object> latestOutputs,
Map<String, Object> executionVariables, int iteration) {
String resolvedPrompt = ExecutionTemplateResolver.resolve(
configuration.getFeedbackPrompt(),
values,
executionVariables == null ? Map.of() : executionVariables);
StringBuilder builder = new StringBuilder();
builder.append(FEEDBACK_SYSTEM_PROMPT).append("\n");
builder.append("Target input: ").append(configuration.getFeedbackInput()).append("\n");
builder.append("Feedback prompt: ").append(resolvedPrompt).append("\n");
builder.append("Current iteration inputs: ").append(iterationInputs == null ? Map.of() : iterationInputs).append("\n");
builder.append("Current iteration exposed outputs: ").append(latestOutputs == null ? Map.of() : latestOutputs).append("\n");
builder.append("Execution variables: ").append(executionVariables == null ? Map.of() : executionVariables).append("\n");
builder.append("Iteration: ").append(iteration).append("\n");
return builder.toString();
}
private Map<String, Object> buildGuardTemplateValues(Map<String, Object> currentInputs,
Map<String, Object> latestOutputs, int iteration) {
Map<String, Object> values = new LinkedHashMap<>();
if (currentInputs != null) {
values.putAll(currentInputs);
currentInputs.forEach((key, value) -> values.put("inputs." + key, value));
}
if (latestOutputs != null) {
values.putAll(latestOutputs);
latestOutputs.forEach((key, value) -> values.put("outputs." + key, value));
}
values.put("iteration", iteration);
return values;
}
private LLMProvider resolveProvider(String providerName) {
LLMProvider provider = llmProviders.get(providerName);
if (provider != null) {
@ -232,6 +308,22 @@ public class LoopContainerExecutor implements ContainerExecutor<LoopContainerTyp
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
}
private void ensureAuthorization(LLMProvider llmProvider, String providerName, Map<String, Object> authorizations) {
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey))))) {
throw new IllegalArgumentException("Missing authorization for provider: " + providerName);
}
}
private String callLlmProvider(LLMProvider llmProvider, LLMDescriptor llmDescriptor, String prompt,
Map<String, Object> authorizations) {
String authKey = llmProvider.authorizationKey();
return llmProvider.requiresAuthorization()
? llmProvider.generate(llmDescriptor.model(), prompt, String.valueOf(authorizations.get(authKey)))
: llmProvider.generate(llmDescriptor.model(), prompt);
}
private String normalizeExpression(String expression) {
Matcher matcher = PLACEHOLDER_PATTERN.matcher(expression);
StringBuffer buffer = new StringBuffer();

View File

@ -114,6 +114,8 @@
"writeOnly": true,
"minLength": 1,
"x-ui-bindable-as-input": true,
"x-ui-binding-static-label": "static value",
"x-ui-binding-input-label": "node input",
"x-ui-input-name": "key",
"x-ui-input-type": "TEXT",
"x-ui-input-multiple": false

View File

@ -1,5 +1,5 @@
---
name: java-springboot
name: springboot-development
description: 'Get best practices for developing applications with Spring Boot.'
---

View File

@ -173,6 +173,25 @@ public class BlocksControllerTest {
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history")));
}
@Test
public void createChatInteractionBlockCanHideHistoryOutput() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
.name("Recruiter chat")
.llmDescriptor(LLMDescriptor.builder()
.provider("testProvider")
.model("testModel")
.build())
.goalDescription("Assess the candidate")
.exposeHistory(false)
.build();
Block<ChatInteractionBlockType> block = blocksController.create(config);
assertNotNull(block);
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
assertFalse(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history")));
}
@Test
public void createChatInteractionBlockDefaultsMissingInputTypeToText() {
ChatInteractionBlockConfiguration config = ChatInteractionBlockConfiguration.builder()
@ -308,6 +327,22 @@ public class BlocksControllerTest {
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple()));
}
@Test
public void createMcpAgentChatBlockCanHideHistoryOutput() {
MCPAgentChatBlockConfiguration config = MCPAgentChatBlockConfiguration.builder()
.name("Agent chat")
.model("llama3.1:8b")
.goalDescription("Assess the candidate")
.exposeHistory(false)
.build();
Block<MCPAgentChatBlockType> block = blocksController.create(config);
assertNotNull(block);
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response") && !output.isMultiple()));
assertFalse(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history")));
}
@Test
public void executionVariablesRetrieverReturnsSharedMcpSessionsForFlow() {
Block<MCPAgentBlockType> block = blocksController.create(MCPAgentBlockConfiguration.builder()
@ -456,6 +491,8 @@ public class BlocksControllerTest {
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());
assertEquals("useSharedSession", properties.path("model").path("x-ui-required-when").path("field").asText());
assertEquals("false", properties.path("model").path("x-ui-required-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());
@ -623,7 +660,8 @@ public class BlocksControllerTest {
assertEquals(MCPAgentChatBlockType.TYPE, block.getName());
assertNotNull(block.getSpecificConfiguration());
assertTrue(((MCPAgentChatBlockConfiguration) block.getSpecificConfiguration()).getMcpServers().isEmpty());
assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("model")));
assertEquals("", ((MCPAgentChatBlockConfiguration) block.getSpecificConfiguration()).getModel());
assertFalse(block.getInputs().stream().anyMatch(input -> input.getName().equals("model")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("history") && output.isMultiple()));
}
@ -783,6 +821,24 @@ public class BlocksControllerTest {
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
}
@Test
public void conditionalBlockSchemaContainsLogicalPropertyOrderMetadata() {
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(ConditionalBlockType.TYPE);
assertNotNull(descriptor.schema());
JsonNode schema = (JsonNode) descriptor.schema();
assertEquals(
List.of("name", "useLlm", "condition", "llmDescriptor", "prompt", "outputTemplate", "type"),
propertyNames(schema.path("properties")));
assertEquals(
List.of("name", "useLlm", "condition", "llmDescriptor", "prompt", "outputTemplate", "type"),
arrayValues(schema.path("x-ui-property-order")));
assertEquals(2, schema.path("properties").path("useLlm").path("x-ui-order").asInt());
assertEquals(3, schema.path("properties").path("condition").path("x-ui-order").asInt());
}
@Test
public void switchBlockSchemaContainsConditionalRequiredMetadata() {
BlockConfigurationDescriptor descriptor = blocksController
@ -826,7 +882,8 @@ public class BlocksControllerTest {
assertEquals(MCPAgentBlockType.TYPE, block.getName());
assertNotNull(block.getSpecificConfiguration());
assertTrue(((MCPAgentBlockConfiguration) block.getSpecificConfiguration()).getMcpServers().isEmpty());
assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("model")));
assertEquals("", ((MCPAgentBlockConfiguration) block.getSpecificConfiguration()).getModel());
assertFalse(block.getInputs().stream().anyMatch(input -> input.getName().equals("model")));
assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("prompt")));
assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
}
@ -856,10 +913,24 @@ public class BlocksControllerTest {
JsonNode model = schema.path("properties").path("model");
assertTrue(model.path("x-ui-bindable-as-input").asBoolean());
assertTrue(model.path("x-ui-structural").asBoolean());
assertEquals("static value", model.path("x-ui-binding-static-label").asText());
assertEquals("node input", model.path("x-ui-binding-input-label").asText());
assertEquals("model", model.path("x-ui-input-name").asText());
assertEquals("TEXT", model.path("x-ui-input-type").asText());
assertFalse(model.path("x-ui-input-multiple").asBoolean());
assertFalse(model.has("x-ui-input-description"));
}
private List<String> propertyNames(JsonNode objectNode) {
List<String> names = new java.util.ArrayList<>();
objectNode.fieldNames().forEachRemaining(names::add);
return names;
}
private List<String> arrayValues(JsonNode arrayNode) {
List<String> values = new java.util.ArrayList<>();
arrayNode.forEach(node -> values.add(node.asText()));
return values;
}
}

View File

@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandl
import com.fasterxml.jackson.databind.JsonNode;
import it.cnr.isti.workflow.manager.app.ObjectMapperHolder;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.blocks.Position;
import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration;
@ -201,9 +202,27 @@ public class ContainersControllerTest {
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode useLlm = schema.path("properties").path("useLlm");
JsonNode llmDescriptor = schema.path("properties").path("llmDescriptor");
JsonNode prompt = schema.path("properties").path("prompt");
JsonNode guardPrompt = schema.path("properties").path("guardPrompt");
JsonNode guardCondition = schema.path("properties").path("guardCondition");
JsonNode feedbackInput = schema.path("properties").path("feedbackInput");
JsonNode feedbackPrompt = schema.path("properties").path("feedbackPrompt");
JsonNode feedbackLlmDescriptor = schema.path("properties").path("feedbackLlmDescriptor");
assertEquals(
List.of("name", "subFlow", "maxIterations", "useLlm", "guardCondition", "llmDescriptor",
"guardPrompt", "feedbackInput", "feedbackLlmDescriptor", "feedbackPrompt", "containerType",
"type"),
propertyNames(schema.path("properties")));
assertEquals(
List.of("name", "subFlow", "maxIterations", "useLlm", "guardCondition", "llmDescriptor",
"guardPrompt", "feedbackInput", "feedbackLlmDescriptor", "feedbackPrompt", "containerType",
"type"),
arrayValues(schema.path("x-ui-property-order")));
assertEquals(4, useLlm.path("x-ui-order").asInt());
assertEquals(5, guardCondition.path("x-ui-order").asInt());
assertTrue(useLlm.path("x-ui-structural").asBoolean());
assertEquals("textarea", guardCondition.path("x-ui-widget").asText());
assertEquals("useLlm", llmDescriptor.path("x-ui-enabled-when").path("field").asText());
assertEquals("true", llmDescriptor.path("x-ui-enabled-when").path("equals").asText());
@ -211,13 +230,50 @@ public class ContainersControllerTest {
assertEquals("true", llmDescriptor.path("x-ui-required-when").path("equals").asText());
assertEquals("llm", llmDescriptor.path("x-ui-group").asText());
assertTrue(prompt.path("x-ui-structural").asBoolean());
assertEquals("textarea", prompt.path("x-ui-widget").asText());
assertEquals("useLlm", prompt.path("x-ui-enabled-when").path("field").asText());
assertEquals("true", prompt.path("x-ui-enabled-when").path("equals").asText());
assertEquals("useLlm", prompt.path("x-ui-required-when").path("field").asText());
assertEquals("true", prompt.path("x-ui-required-when").path("equals").asText());
assertEquals("llm", prompt.path("x-ui-group").asText());
assertTrue(guardPrompt.path("x-ui-structural").asBoolean());
assertEquals("textarea", guardPrompt.path("x-ui-widget").asText());
assertEquals("useLlm", guardPrompt.path("x-ui-enabled-when").path("field").asText());
assertEquals("true", guardPrompt.path("x-ui-enabled-when").path("equals").asText());
assertEquals("useLlm", guardPrompt.path("x-ui-required-when").path("field").asText());
assertEquals("true", guardPrompt.path("x-ui-required-when").path("equals").asText());
assertEquals("llm", guardPrompt.path("x-ui-group").asText());
assertEquals("inputs", feedbackInput.path("x-ui-options-from-node").path("collection").asText());
assertEquals("name", feedbackInput.path("x-ui-options-from-node").path("valueField").asText());
assertEquals("name", feedbackInput.path("x-ui-options-from-node").path("labelField").asText());
assertEquals("feedbackInput", feedbackPrompt.path("x-ui-enabled-when").path("field").asText());
assertTrue(feedbackPrompt.path("x-ui-required-when").path("present").asBoolean());
assertEquals("feedback", feedbackPrompt.path("x-ui-group").asText());
assertEquals("feedbackPrompt", feedbackLlmDescriptor.path("x-ui-enabled-when").path("field").asText());
assertTrue(feedbackLlmDescriptor.path("x-ui-required-when").path("present").asBoolean());
assertEquals("feedback", feedbackLlmDescriptor.path("x-ui-group").asText());
}
@Test
public void loopContainerAcceptsLegacyConditionAndPromptAliases() throws Exception {
String payload = """
{
"type": "LoopContainerConfiguration",
"name": "Loop",
"subFlow": {},
"condition": "${{response}} == 'done'",
"useLlm": true,
"llmDescriptor": {
"provider": "testProvider",
"model": "testModel"
},
"prompt": "Stop when ${{outputs.response}} is done",
"maxIterations": 3
}
""";
LoopContainerConfiguration configuration = ObjectMapperHolder.mapper.readValue(payload,
LoopContainerConfiguration.class);
assertEquals("${{response}} == 'done'", configuration.getGuardCondition());
assertEquals("Stop when ${{outputs.response}} is done", configuration.getGuardPrompt());
}
@Test
@ -460,7 +516,7 @@ public class ContainersControllerTest {
Container<LoopContainerType> container = containersController.create(LoopContainerConfiguration.builder()
.name("Loop")
.subFlow(FlowData.builder().block(internalBlock).build())
.condition("${{response}} == 'done'")
.guardCondition("${{response}} == 'done'")
.useLlm(false)
.maxIterations(3)
.build());
@ -469,4 +525,16 @@ public class ContainersControllerTest {
assertTrue(container.getInputs().stream().anyMatch(input -> input.getName().equals("candidate")));
assertTrue(container.getOutputs().stream().anyMatch(output -> output.getName().equals("response")));
}
private List<String> propertyNames(JsonNode objectNode) {
List<String> names = new java.util.ArrayList<>();
objectNode.fieldNames().forEachRemaining(names::add);
return names;
}
private List<String> arrayValues(JsonNode arrayNode) {
List<String> values = new java.util.ArrayList<>();
arrayNode.forEach(node -> values.add(node.asText()));
return values;
}
}

View File

@ -74,6 +74,7 @@ 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;
import it.cnr.isti.workflow.manager.skills.SkillBinding;
@SpringBootTest
@TestPropertySource(locations = "classpath:test.properties")
@ -109,6 +110,15 @@ public class ExecutionTest {
if (model.contains("simulate") && prompt.contains("###SIMULATED_CHAT_FINAL###")) {
return "FINAL: Candidate approved";
}
if (prompt.contains("Build function for request:")) {
return prompt.contains("add null validation") ? "function v2" : "function v1";
}
if (prompt.contains("Construct the next request for the function loop")) {
return "Implement the same function and add null validation";
}
if (prompt.contains("Loop guard via exposed outputs")) {
return prompt.contains("Hello, Alice!") ? "{\"result\":true}" : "{\"result\":false}";
}
if (prompt.contains("You are a workflow switch evaluator.")) {
return "{\"result\":\"approved\"}";
}
@ -393,6 +403,45 @@ public class ExecutionTest {
assertEquals(4, ((List<?>) partialConversation).size());
}
@Test
public void chatInteractionExecutionCanHideHistoryOutput() {
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)))
.exposeHistory(false)
.build());
Flow flow = Flow.builder()
.name("Chat flow")
.description("Single chat block without history output")
.block(chatBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
execObject.setInteractionValue(chatBlock.getId(), ChatInteractionBlockFactory.INTERACTION_FIELD, "Hello ${{cand}}");
execObject = executionsService.getExecution(execObject.getId());
assertFalse(execObject.getContext().getPartialResult()
.containsKey(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.HISTORY_OUTPUT)));
execObject.setInteractionValue(chatBlock.getId(), ChatInteractionBlockFactory.FINAL_RESPONSE_FIELD,
"Candidate approved");
execObject = executionsService.getExecution(execObject.getId());
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertFalse(execObject.getContext().getResult()
.containsKey(new FieldKey(chatBlock.getId(), ChatInteractionBlockFactory.HISTORY_OUTPUT)));
}
@Test
public void createExecutionRejectsChatInteractionWithoutLlmDescriptor() {
Block<ChatInteractionBlockType> chatBlock = chatInteractionBlockFactory
@ -556,6 +605,51 @@ public class ExecutionTest {
assertEquals(5, queryCount.get());
}
@Test
public void mcpAgentChatExecutionCanHideHistoryOutput() {
Mockito.when(mcpAgentService.openSession(Mockito.eq("llama3.1:8b"), Mockito.anyList(), Mockito.anyMap()))
.thenReturn("session-no-history");
Mockito.when(mcpAgentService.querySession(Mockito.eq("session-no-history"), Mockito.anyString()))
.thenReturn("MCP answer");
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())
.exposeHistory(false)
.build());
Flow flow = Flow.builder()
.name("MCP Chat flow")
.description("Single MCP chat block without history output")
.block(chatBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
execObject = executionsService.prepareInput(execObject.getId(), chatBlock.getId(), "cand", "John Doe");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
execObject.setInteractionValue(chatBlock.getId(), MCPAgentChatBlockFactory.INTERACTION_FIELD, "Hello ${{cand}}");
execObject = executionsService.getExecution(execObject.getId());
assertFalse(execObject.getContext().getPartialResult()
.containsKey(new FieldKey(chatBlock.getId(), MCPAgentChatBlockFactory.HISTORY_OUTPUT)));
execObject.setInteractionValue(chatBlock.getId(), MCPAgentChatBlockFactory.FINAL_RESPONSE_FIELD,
"Candidate approved");
execObject = executionsService.getExecution(execObject.getId());
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
assertFalse(execObject.getContext().getResult()
.containsKey(new FieldKey(chatBlock.getId(), MCPAgentChatBlockFactory.HISTORY_OUTPUT)));
Mockito.verify(mcpAgentService).closeSessionQuietly("session-no-history");
}
@Test
public void executionVariablesResolveInLlmPromptAndPersistAcrossReload() {
Block<LLMBlockType> llmBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
@ -589,6 +683,38 @@ public class ExecutionTest {
assertEquals("Ada Lovelace", restored.getContext().getExecutionVariables().get("person"));
}
@Test
public void llmExecutionPrependsSelectedSkillsToPrompt() {
Block<LLMBlockType> llmBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
.name("Skillful prompt")
.prompt("Implement a REST endpoint for ${{resource}}")
.llmDescriptor(llmBrick)
.skills(List.of(SkillBinding.builder()
.skillId("springboot-development")
.build()))
.build());
Flow flow = Flow.builder()
.name("Skill flow")
.description("Single LLM block with skills")
.block(llmBlock)
.build();
ExecutionObject execObject = executionsService.createExecution(flow);
executionsService.prepareInput(execObject.getId(), llmBlock.getId(), "resource", "orders");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
String response = (String) execObject.getContext().getResult()
.get(new FieldKey(llmBlock.getId(), LLMBlockFactory.OUTPUT_NAME));
assertTrue(response.contains("Apply the following local skills as mandatory operating instructions."));
assertTrue(response.contains("[SKILL springboot-development]"));
assertTrue(response.contains("Implement a REST endpoint for orders"));
}
@Test
public void switchExecutionCanReadGlobalVariableInSpelContext() {
Block<SwitchBlockType> switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder()
@ -1157,7 +1283,7 @@ public class ExecutionTest {
Container<LoopContainerType> container = loopContainerFactory.create(LoopContainerConfiguration.builder()
.name("Loop")
.subFlow(FlowData.builder().block(internalBlock).build())
.condition("${{response}} == 'Hello, Alice!'")
.guardCondition("${{response}} == 'Hello, Alice!'")
.useLlm(false)
.maxIterations(3)
.build());
@ -1188,6 +1314,106 @@ public class ExecutionTest {
&& event.getDetails().get("iterationIndex") != null));
}
@Test
public void loopConditionCanReferenceExposedOutputsExplicitly() {
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
.name("Internal LLM")
.llmDescriptor(llmBrick)
.prompt("Hello, ${{name}}!")
.build());
Container<LoopContainerType> container = loopContainerFactory.create(LoopContainerConfiguration.builder()
.name("Loop")
.subFlow(FlowData.builder().block(internalBlock).build())
.guardCondition("${{outputs.response}} == 'Hello, Alice!'")
.useLlm(false)
.maxIterations(3)
.build());
FlowData flow = FlowData.builder().container(container).build();
ExecutionObject execObject = executionsService.createExecution("Loop outputs flow", flow);
executionsService.prepareInput(execObject.getId(), container.getId(), "name", "Alice");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
Object output = execObject.getContext().getResult().values().stream().findFirst().orElseThrow();
assertEquals("Hello, Alice!", output);
}
@Test
public void loopLlmGuardPromptCanReferenceExposedOutputsExplicitly() {
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
.name("Internal LLM")
.llmDescriptor(llmBrick)
.prompt("Hello, ${{name}}!")
.build());
Container<LoopContainerType> container = loopContainerFactory.create(LoopContainerConfiguration.builder()
.name("Loop")
.subFlow(FlowData.builder().block(internalBlock).build())
.useLlm(true)
.llmDescriptor(llmBrick)
.guardPrompt("Loop guard via exposed outputs: stop when ${{outputs.response}} is exactly Hello, Alice!")
.maxIterations(3)
.build());
FlowData flow = FlowData.builder().container(container).build();
ExecutionObject execObject = executionsService.createExecution("Loop llm outputs flow", flow);
executionsService.prepareInput(execObject.getId(), container.getId(), "name", "Alice");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
Object output = execObject.getContext().getResult().values().stream().findFirst().orElseThrow();
assertEquals("Hello, Alice!", output);
}
@Test
public void loopCanInjectFeedbackIntoNextIterationInput() {
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
.name("Function Builder")
.llmDescriptor(llmBrick)
.prompt("Build function for request: ${{request}}")
.build());
Container<LoopContainerType> container = loopContainerFactory.create(LoopContainerConfiguration.builder()
.name("Loop")
.subFlow(FlowData.builder().block(internalBlock).build())
.guardCondition("${{outputs.response}} == 'function v2'")
.feedbackInput("request")
.feedbackLlmDescriptor(llmBrick)
.feedbackPrompt(
"Construct the next request for the function loop. Previous request: ${{inputs.request}} Previous output: ${{outputs.response}} Iteration: ${{iteration}}")
.useLlm(false)
.maxIterations(3)
.build());
FlowData flow = FlowData.builder().container(container).build();
ExecutionObject execObject = executionsService.createExecution("Loop feedback flow", flow);
executionsService.prepareInput(execObject.getId(), container.getId(), "request", "Implement the same function");
execObject = executionsService.startExecution(execObject.getId());
while (execObject.getContext().getStatus() == ExecutionStatus.RUNNING) {
execObject = executionsService.getExecution(execObject.getId());
}
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
Object output = execObject.getContext().getResult().values().stream().findFirst().orElseThrow();
assertEquals("function v2", output);
assertTrue(execObject.getContext().getEvents().stream()
.anyMatch(event -> event.getType() == ExecutionEventType.LLM_REQUEST
&& "LoopContainer".equals(event.getDetails().get("containerType"))
&& "feedbackInput".equals(event.getDetails().get("purpose"))
&& "request".equals(event.getDetails().get("inputName"))));
}
@Test
public void singleTextInputRejectsMultipleValues() {
Flow flow = flowTestCreator.createFlowwithLLMUnpromptedWithConnection(llmBrick);

View File

@ -111,6 +111,8 @@ class MCPAgentServiceTest {
"format": "password",
"writeOnly": true,
"x-ui-bindable-as-input": true,
"x-ui-binding-static-label": "static value",
"x-ui-binding-input-label": "node input",
"x-ui-input-name": "key",
"x-ui-input-type": "TEXT",
"x-ui-input-multiple": false
@ -298,6 +300,8 @@ class MCPAgentServiceTest {
"format": "password",
"writeOnly": true,
"x-ui-bindable-as-input": true,
"x-ui-binding-static-label": "static value",
"x-ui-binding-input-label": "node input",
"x-ui-input-name": "key",
"x-ui-input-type": "TEXT",
"x-ui-input-multiple": false
@ -318,6 +322,10 @@ class MCPAgentServiceTest {
assertEquals("password", definition.configurationSchema().path("properties").path("key").path("format").asText());
assertTrue(definition.configurationSchema().path("properties").path("key").path("writeOnly").asBoolean());
assertTrue(definition.configurationSchema().path("properties").path("key").path("x-ui-bindable-as-input").asBoolean());
assertEquals("static value",
definition.configurationSchema().path("properties").path("key").path("x-ui-binding-static-label").asText());
assertEquals("node input",
definition.configurationSchema().path("properties").path("key").path("x-ui-binding-input-label").asText());
}
private MCPServersProvider providerFor(String json) throws Exception {