From c88875c3ba4c5db03fa83e736d4f9102fbf3ba18 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 24 Apr 2026 12:23:43 +0200 Subject: [PATCH] Add JsonSchemaProducer, UiOrder annotation, and enhance LoopContainer and block configurations --- .../configurations/BlockConfiguration.java | 2 + .../ChatInteractionBlockConfiguration.java | 9 +- .../ConditionalBlockConfiguration.java | 14 +- .../configurations/JsonSchemaProducer.java | 124 ++++++++++ .../configurations/LLMBlockConfiguration.java | 36 ++- .../MCPAgentBlockConfiguration.java | 2 + .../MCPAgentChatBlockConfiguration.java | 8 + .../SwitchBlockConfiguration.java | 15 +- .../ChatInteractionBlockFactory.java | 10 +- .../factories/MCPAgentBlockFactory.java | 8 +- .../factories/MCPAgentChatBlockFactory.java | 21 +- .../configurations/annotations/UiOrder.java | 12 + .../annotations/UiRequiredWhen.java | 2 + .../ContainerConfiguration.java | 3 + .../LoopContainerConfiguration.java | 155 +++++++++--- .../containers/types/LoopContainerType.java | 2 +- .../blocks/ChatInteractionExecutor.java | 22 +- .../executors/blocks/LLMExecutor.java | 9 + .../blocks/MCPAgentChatExecutor.java | 27 ++- .../containers/LoopContainerExecutor.java | 128 ++++++++-- src/main/resources/mcp-servers.json | 2 + .../skills/springboot-development/SKILL.md | 2 +- .../controllers/BlocksControllerTest.java | 75 +++++- .../controllers/ContainersControllerTest.java | 86 ++++++- .../manager/executions/ExecutionTest.java | 228 +++++++++++++++++- .../manager/mcp/MCPAgentServiceTest.java | 8 + 26 files changed, 904 insertions(+), 106 deletions(-) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiOrder.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfiguration.java index bfdc78c..6236c45 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/BlockConfiguration.java @@ -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 { @NotBlank + @UiOrder(10) @JsonProperty(required = true) String name; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java index 15fcd9b..23a1046 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ChatInteractionBlockConfiguration.java @@ -39,6 +39,10 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration inputs = List.of(); + @Structural + @JsonProperty(required = true) + private boolean exposeHistory = true; + @Override public Class getBlockType() { return ChatInteractionBlockType.class; @@ -47,17 +51,20 @@ public class ChatInteractionBlockConfiguration extends BlockConfiguration inputs) { + List 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; } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java index a8d4129..4e46160 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/ConditionalBlockConfiguration.java @@ -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 { + @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, Map> longTextMap = collectLongTextMetadata(type); Map, Map> structuralMap = collectStructuralMetadata(type); Map, Map> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type); + Map, Map> uiOrderMap = collectUiOrderMetadata(type); Map, Map> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type); Map, Map> uiRequiredWhenMap = collectUiRequiredWhenMetadata(type); Map, Map> 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, Map> collectUiOrderMetadata(Class rootClass) { + Map, Map> result = new HashMap<>(); + Set> visited = new HashSet<>(); + Queue> queue = new ArrayDeque<>(); + queue.add(rootClass); + + while (!queue.isEmpty()) { + Class current = queue.poll(); + if (current == null || !visited.add(current) || isTerminalType(current)) { + continue; + } + + Map metadata = new LinkedHashMap<>(); + for (Field field : current.getDeclaredFields()) { + 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 metadata) { + JsonNode propsNode = classSchema.get("properties"); + if (!(propsNode instanceof ObjectNode properties)) { + return; + } + + List propertyNames = new ArrayList<>(); + for (String propertyName : iterable(properties.fieldNames())) { + propertyNames.add(propertyName); + } + if (propertyNames.isEmpty()) { + return; + } + + Map declarationIndex = new LinkedHashMap<>(); + List 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 metadata) { if (metadata == null || metadata.isEmpty()) { return; @@ -895,6 +988,32 @@ public class JsonSchemaProducer { } } + private List collectDeclaredPropertyNames(Class type) { + if (type == null) { + return List.of(); + } + + Deque> hierarchy = new ArrayDeque<>(); + Class current = type; + while (current != null && current != Object.class) { + hierarchy.addFirst(current); + current = current.getSuperclass(); + } + + Set 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 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()); diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/LLMBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/LLMBlockConfiguration.java index 55150f3..233626a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/LLMBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/LLMBlockConfiguration.java @@ -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 { @JsonProperty(required = false) String prompt; + @Structural + @Valid + @Size(max = SkillsCatalogService.MAX_SKILLS_PER_BLOCK) + @UiUniqueItemsBy("skillId") + @JsonProperty(required = false) + List 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 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 { 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(); + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java index bbec7df..f922407 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java @@ -34,6 +34,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration 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 inputs, + Boolean exposeHistory, String goalDescription, List mcpServers, Boolean shareSession, String sharedSessionName, Boolean useSharedSession, String sharedSessionRef) { super(name); this.model = model; this.inputs = inputs == null ? List.of() : List.copyOf(inputs); + this.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 { + @UiOrder(30) @Structural @UiUniqueItemsBy("name") @Size(max = 10) @@ -33,6 +35,12 @@ public class SwitchBlockConfiguration extends BlockConfiguration 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 create(ChatInteractionBlockConfiguration configuration) { validateConfiguration(configuration); - return Block.builder() + Block.BlockBuilder builder = Block.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 diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java index fbc3daf..ba2d556 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java @@ -52,7 +52,13 @@ public class MCPAgentBlockFactory implements BlockFactory createEmpty() { - return create(MCPAgentBlockConfiguration.empty()); + MCPAgentBlockConfiguration configuration = MCPAgentBlockConfiguration.empty(); + return Block.builder() + .inputs(retrieveInputs(configuration.getPrompt())) + .output(IODescriptor.output(OUTPUT_NAME, IOType.TEXT, false, OUTPUT_CAPABILITIES)) + .specificConfiguration(configuration) + .type(blockType) + .build(); } @Override diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java index ca6591e..8ba5980 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentChatBlockFactory.java @@ -37,18 +37,29 @@ public class MCPAgentChatBlockFactory @Override public Block create(MCPAgentChatBlockConfiguration configuration) { validateConfiguration(configuration); - return Block.builder() + Block.BlockBuilder builder = Block.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 createEmpty() { - return create(MCPAgentChatBlockConfiguration.empty()); + MCPAgentChatBlockConfiguration configuration = MCPAgentChatBlockConfiguration.empty(); + Block.BlockBuilder builder = Block.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 diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiOrder.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiOrder.java new file mode 100644 index 0000000..36d391c --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiOrder.java @@ -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(); +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiRequiredWhen.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiRequiredWhen.java index f420866..b200a60 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiRequiredWhen.java +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/annotations/UiRequiredWhen.java @@ -13,4 +13,6 @@ public @interface UiRequiredWhen { String equals() default ""; String[] equalsAny() default {}; + + boolean present() default false; } diff --git a/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/ContainerConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/ContainerConfiguration.java index f14c207..febcf1c 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/ContainerConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/ContainerConfiguration.java @@ -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 { @NotBlank + @UiOrder(10) @JsonProperty(required = true) String name; + @UiOrder(20) @Structural @Valid @FieldRetriever( diff --git a/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/LoopContainerConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/LoopContainerConfiguration.java index ad0ae40..a241405 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/LoopContainerConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/containers/configurations/LoopContainerConfiguration.java @@ -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 { - @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 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 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 buildResult(ChatInteractionBlockConfiguration configuration, String response, + List history) { + Map result = new LinkedHashMap<>(); + result.put(ChatInteractionBlockFactory.RESPONSE_OUTPUT, response); + if (configuration.isExposeHistory()) { + result.put(ChatInteractionBlockFactory.HISTORY_OUTPUT, history); + } + return result; + } + @SuppressWarnings("unchecked") private List existingHistory(Map partialResults) { Object value = partialResults.get(ChatInteractionBlockFactory.HISTORY_OUTPUT); diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java index 2ecd6ab..6a0a21d 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/LLMExecutor.java @@ -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 { @@ -29,6 +30,9 @@ public class LLMExecutor implements BlockExecutor { @Autowired private Map llmProviders; + @Autowired + private SkillPromptService skillPromptService; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, Map executionVariableDescriptors, @@ -46,6 +50,11 @@ public class LLMExecutor implements BlockExecutor { 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) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java index 07b1ae1..8e97c95 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java @@ -90,9 +90,7 @@ public class MCPAgentChatExecutor implements BlockExecutor 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 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 buildResult(MCPAgentChatBlockConfiguration configuration, String response, + List history, String sessionId) { + Map 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 existingHistory(Map partialResults) { Object value = partialResults.get(MCPAgentChatBlockFactory.HISTORY_OUTPUT); diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/LoopContainerExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/LoopContainerExecutor.java index 074e897..b29cb07 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/LoopContainerExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/containers/LoopContainerExecutor.java @@ -46,6 +46,12 @@ public class LoopContainerExecutor implements ContainerExecutor llmProviders; @@ -127,12 +133,13 @@ public class LoopContainerExecutor implements ContainerExecutor 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(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 currentInputs, Map latestOutputs, Map authorizations, Map executionVariables, int iteration) { - Map contextValues = new LinkedHashMap<>(currentInputs); - contextValues.putAll(latestOutputs); - contextValues.put("iteration", iteration); + Map 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 values, + Map currentInputs, Map latestOutputs, Map 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 values, + Map currentInputs, Map latestOutputs, Map authorizations, Map 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 nextInputs, + Map iterationInputs, Map latestOutputs, Map authorizations, + Map 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 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 values, + Map currentInputs, Map latestOutputs, Map 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 values, + Map iterationInputs, Map latestOutputs, + Map 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 buildGuardTemplateValues(Map currentInputs, + Map latestOutputs, int iteration) { + Map 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 new IllegalArgumentException("Provider not found: " + providerName)); } + private void ensureAuthorization(LLMProvider llmProvider, String providerName, Map 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 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(); diff --git a/src/main/resources/mcp-servers.json b/src/main/resources/mcp-servers.json index 753ac8d..dcce7f5 100644 --- a/src/main/resources/mcp-servers.json +++ b/src/main/resources/mcp-servers.json @@ -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 diff --git a/src/main/resources/skills/springboot-development/SKILL.md b/src/main/resources/skills/springboot-development/SKILL.md index 8e14f63..30e8c48 100644 --- a/src/main/resources/skills/springboot-development/SKILL.md +++ b/src/main/resources/skills/springboot-development/SKILL.md @@ -1,5 +1,5 @@ --- -name: java-springboot +name: springboot-development description: 'Get best practices for developing applications with Spring Boot.' --- diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index 123400f..cfb294f 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -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 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 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 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 propertyNames(JsonNode objectNode) { + List names = new java.util.ArrayList<>(); + objectNode.fieldNames().forEachRemaining(names::add); + return names; + } + + private List arrayValues(JsonNode arrayNode) { + List values = new java.util.ArrayList<>(); + arrayNode.forEach(node -> values.add(node.asText())); + return values; + } + } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java index 0a5e740..64f7c95 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java @@ -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 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 propertyNames(JsonNode objectNode) { + List names = new java.util.ArrayList<>(); + objectNode.fieldNames().forEachRemaining(names::add); + return names; + } + + private List arrayValues(JsonNode arrayNode) { + List values = new java.util.ArrayList<>(); + arrayNode.forEach(node -> values.add(node.asText())); + return values; + } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index 8f61b26..c0275e1 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -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 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 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 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 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 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 switchBlock = switchBlockFactory.create(SwitchBlockConfiguration.builder() @@ -1157,7 +1283,7 @@ public class ExecutionTest { Container 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 internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Internal LLM") + .llmDescriptor(llmBrick) + .prompt("Hello, ${{name}}!") + .build()); + + Container 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 internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Internal LLM") + .llmDescriptor(llmBrick) + .prompt("Hello, ${{name}}!") + .build()); + + Container 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 internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Function Builder") + .llmDescriptor(llmBrick) + .prompt("Build function for request: ${{request}}") + .build()); + + Container 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); diff --git a/src/test/java/it/cnr/isti/workflow/manager/mcp/MCPAgentServiceTest.java b/src/test/java/it/cnr/isti/workflow/manager/mcp/MCPAgentServiceTest.java index 36f0ed8..9e0519e 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/mcp/MCPAgentServiceTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/mcp/MCPAgentServiceTest.java @@ -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 {