Offer "Use default" only where a field declares one

The editor derived the control from the field being optional, which put it on
nearly every field in every dialog. "You may leave this blank" and "leaving this
blank means something specific" are different claims, and only the second is
worth a control.

The claim is now made per field with @DefaultsWhenEmpty, published as
x-ui-defaults-when-empty. It goes on the five sampling parameters - where empty
means the provider decides, and no typed number gets that state back - and on the
three fields that declare a concrete default, which the editor already names
alongside. The value itself still comes from JSON Schema's own `default`: a
parameter has no value to name, only an absence to return to, so declaring
`default: null` would have said something false to every other reader of the
schema.

Providers also now report which sampling parameters they actually apply. All five
were offered to every provider and the unsupported ones were dropped at run time,
reported in a warning on an execution that had already happened - Gemini applies
no seed, the OpenAI-protocol providers no top_k.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-18 11:52:00 +02:00
parent f4dbb866c0
commit 4eb962ed9b
10 changed files with 216 additions and 2 deletions

View File

@ -40,6 +40,7 @@ import com.github.victools.jsonschema.module.jackson.JacksonSchemaModule;
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
import it.cnr.isti.workflow.manager.configurations.annotations.DefaultsWhenEmpty;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup;
import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema;
@ -101,6 +102,7 @@ public class JsonSchemaProducer {
Map<Class<?>, Map<String, DynamicSchema>> dynamicSchemaMap = collectDynamicSchemaMetadata(type);
Map<Class<?>, Map<String, LongText>> longTextMap = collectLongTextMetadata(type);
Map<Class<?>, Set<String>> acceptsPlaceholderMap = collectAcceptsVariablePlaceholderMetadata(type);
Map<Class<?>, Set<String>> defaultsWhenEmptyMap = collectDefaultsWhenEmptyMetadata(type);
Map<Class<?>, Map<String, Structural>> structuralMap = collectStructuralMetadata(type);
Map<Class<?>, Map<String, UiOptionalGroup>> uiOptionalGroupMap = collectUiOptionalGroupMetadata(type);
Map<Class<?>, Map<String, UiEnabledWhen>> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type);
@ -119,6 +121,7 @@ public class JsonSchemaProducer {
applyDynamicSchemaMetadata(root, getMergedMetadata(dynamicSchemaMap, type));
applyLongTextMetadata(root, getMergedMetadata(longTextMap, type));
applyAcceptsVariablePlaceholderMetadata(root, mergedNames(acceptsPlaceholderMap, type));
applyDefaultsWhenEmptyMetadata(root, mergedNames(defaultsWhenEmptyMap, type));
applyStructuralMetadata(root, getMergedMetadata(structuralMap, type));
applyUiOptionalGroupMetadata(root, getMergedMetadata(uiOptionalGroupMap, type));
applyUiEnabledWhenMetadata(root, getMergedMetadata(uiEnabledWhenMap, type));
@ -153,6 +156,7 @@ public class JsonSchemaProducer {
metadataClasses.addAll(schemaAllowedValuesMap.keySet());
metadataClasses.addAll(configurableAsInputMap.keySet());
metadataClasses.addAll(acceptsPlaceholderMap.keySet());
metadataClasses.addAll(defaultsWhenEmptyMap.keySet());
for (Entry<String, JsonNode> entry : definitions.properties()) {
if (!(entry.getValue() instanceof ObjectNode classSchema)) {
continue;
@ -166,6 +170,7 @@ public class JsonSchemaProducer {
applyDynamicSchemaMetadata(classSchema, getMergedMetadata(dynamicSchemaMap, matchedClass));
applyLongTextMetadata(classSchema, getMergedMetadata(longTextMap, matchedClass));
applyAcceptsVariablePlaceholderMetadata(classSchema, mergedNames(acceptsPlaceholderMap, matchedClass));
applyDefaultsWhenEmptyMetadata(classSchema, mergedNames(defaultsWhenEmptyMap, matchedClass));
applyStructuralMetadata(classSchema, getMergedMetadata(structuralMap, matchedClass));
applyUiOptionalGroupMetadata(classSchema, getMergedMetadata(uiOptionalGroupMap, matchedClass));
applyUiEnabledWhenMetadata(classSchema, getMergedMetadata(uiEnabledWhenMap, matchedClass));
@ -831,6 +836,64 @@ public class JsonSchemaProducer {
}
}
private Map<Class<?>, Set<String>> collectDefaultsWhenEmptyMetadata(Class<?> rootClass) {
Map<Class<?>, Set<String>> 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;
}
Set<String> names = new LinkedHashSet<>();
for (Field field : current.getDeclaredFields()) {
if (field.getAnnotation(DefaultsWhenEmpty.class) != null) {
names.add(field.getName());
}
enqueueRelatedTypes(queue, field.getGenericType(), field.getType());
}
if (current.isRecord()) {
for (RecordComponent component : current.getRecordComponents()) {
if (component.getAnnotation(DefaultsWhenEmpty.class) != null) {
names.add(component.getName());
}
enqueueRelatedTypes(queue, component.getGenericType(), component.getType());
}
}
if (current.getSuperclass() != null) {
queue.add(current.getSuperclass());
}
if (!names.isEmpty()) {
result.put(current, names);
}
}
return result;
}
private void applyDefaultsWhenEmptyMetadata(ObjectNode classSchema, Set<String> names) {
if (names == null || names.isEmpty()) {
return;
}
JsonNode propsNode = classSchema.get("properties");
if (!(propsNode instanceof ObjectNode properties)) {
return;
}
for (String name : names) {
JsonNode propNode = properties.get(name);
if (propNode instanceof ObjectNode propertySchema) {
propertySchema.put("x-ui-defaults-when-empty", true);
}
}
}
private Map<Class<?>, Map<String, Structural>> collectStructuralMetadata(Class<?> rootClass) {
Map<Class<?>, Map<String, Structural>> result = new HashMap<>();
Set<Class<?>> visited = new HashSet<>();

View File

@ -18,6 +18,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema;
import it.cnr.isti.workflow.manager.configurations.annotations.AcceptsVariablePlaceholder;
import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput;
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
import it.cnr.isti.workflow.manager.configurations.annotations.DefaultsWhenEmpty;
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup;
@ -160,6 +161,7 @@ public class MCPAgentBlockConfiguration extends BlockConfiguration<MCPAgentBlock
// The default lives in effectiveSourceType() below; declared here so the editor can say
// which one an empty field is using instead of only that it is using one.
@SchemaAllowedValues(value = { "CATALOG", "CUSTOM" }, defaultValue = "CATALOG")
@DefaultsWhenEmpty
String sourceType,
@JsonProperty(required = false)
@UiEnabledWhen(field = "sourceType", equalsAny = { "CATALOG", "" })

View File

@ -21,6 +21,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInp
import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema;
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
import it.cnr.isti.workflow.manager.configurations.annotations.LongText;
import it.cnr.isti.workflow.manager.configurations.annotations.DefaultsWhenEmpty;
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
import it.cnr.isti.workflow.manager.configurations.annotations.Structural;
import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen;
@ -144,6 +145,7 @@ public class MCPAgentChatBlockConfiguration extends BlockConfiguration<MCPAgentC
// Same default as effectiveSourceType() below, declared for the editor - and the same
// one the agent block declares, so the two dialogs read alike.
@SchemaAllowedValues(value = { "CATALOG", "CUSTOM" }, defaultValue = "CATALOG")
@DefaultsWhenEmpty
String sourceType,
@JsonProperty(required = false)
@UiEnabledWhen(field = "sourceType", equalsAny = { "CATALOG", "" })

View File

@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever;
import it.cnr.isti.workflow.manager.configurations.annotations.DefaultsWhenEmpty;
import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues;
import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys;
import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription;
@ -33,6 +34,7 @@ public record MCPAgentUploadInput(
* than only that it is using one.
*/
@SchemaAllowedValues(value = { SOURCE_INPUT, SOURCE_GLOBAL }, defaultValue = SOURCE_INPUT)
@DefaultsWhenEmpty
@UiLabel("File comes from")
@UiDescription("Uploaded to an input of this step, or taken from a global input of the flow.")
@JsonProperty(required = false) String source,

View File

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
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;
/**
* Marks a field whose empty state is a decision rather than an omission, so the editor offers a way
* back to it once the field holds a value.
*
* <p>Declared per field instead of inferred from the field being optional, which is what the editor
* used to do: "you may leave this blank" and "leaving this blank means something specific" are
* different claims, and only the second is worth a control. Most optional fields are simply blank.
*
* <p>The model sampling parameters are the case this exists for: empty means the provider picks, a
* value cannot be un-typed back into that state by clearing the box - that reads as an unfinished
* edit - and nobody knows what number to type to get the provider's own choice back.
*
* <p>Says nothing about what the default is. When a concrete one is known it belongs in JSON
* Schema's own {@code default}, which the editor already shows alongside; here there is often no
* value to name, only an absence to return to.
*/
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface DefaultsWhenEmpty {
}

View File

@ -24,7 +24,8 @@ public class LLMProviderCatalogService {
public List<LLMProviderMetadata> list() {
return providers.values().stream()
.map(provider -> new LLMProviderMetadata(provider.getName(), provider.requiresAuthorization(),
provider.requiresEndpoint()))
provider.requiresEndpoint(),
provider.supportedParameters().stream().map(Enum::name).sorted().toList()))
.filter(provider -> StringUtils.hasText(provider.name()))
.distinct()
.sorted(java.util.Comparator.comparing(LLMProviderMetadata::name, String.CASE_INSENSITIVE_ORDER))

View File

@ -4,11 +4,22 @@
package it.cnr.isti.workflow.manager.llms;
import java.util.List;
/**
* Public, non-sensitive capabilities used by every LLM selection UI.
*
* @param requiresEndpoint whether choosing this provider means the credential must also carry a
* base URL - see {@link it.cnr.isti.workflow.manager.llms.providers.LLMProvider#requiresEndpoint()}.
* @param supportedParameters which sampling knobs this provider actually applies, by
* {@link ModelParameter} name. Every provider is offered the same five, so
* without this the editor lets a value be set where it does nothing and the
* run only says so afterwards, in a warning nobody was waiting for.
*/
public record LLMProviderMetadata(String name, boolean requiresCredential, boolean requiresEndpoint) {
public record LLMProviderMetadata(String name, boolean requiresCredential, boolean requiresEndpoint,
List<String> supportedParameters) {
public LLMProviderMetadata {
supportedParameters = supportedParameters == null ? List.of() : List.copyOf(supportedParameters);
}
}

View File

@ -10,6 +10,7 @@ import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import it.cnr.isti.workflow.manager.configurations.annotations.DefaultsWhenEmpty;
import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription;
import it.cnr.isti.workflow.manager.configurations.annotations.UiLabel;
import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder;
@ -43,6 +44,7 @@ public record ModelParameters(
@UiDescription("Higher values make the output more varied. 0 makes it as repeatable as the model allows.")
@DecimalMin("0.0") @DecimalMax("1.0")
@JsonProperty(required = false)
@DefaultsWhenEmpty
Double temperature,
@UiOrder(20)
@ -50,6 +52,7 @@ public record ModelParameters(
@UiDescription("Nucleus sampling: consider only the most likely tokens adding up to this probability.")
@DecimalMin("0.0") @DecimalMax("1.0")
@JsonProperty(required = false)
@DefaultsWhenEmpty
Double topP,
@UiOrder(30)
@ -57,6 +60,7 @@ public record ModelParameters(
@UiDescription("Consider only this many candidate tokens at each step.")
@Min(1)
@JsonProperty(required = false)
@DefaultsWhenEmpty
Integer topK,
@UiOrder(40)
@ -64,12 +68,14 @@ public record ModelParameters(
@UiDescription("Upper bound on the length of the generated answer.")
@Min(1)
@JsonProperty(required = false)
@DefaultsWhenEmpty
Integer maxTokens,
@UiOrder(50)
@UiLabel("Seed")
@UiDescription("Fixes the randomness, so the same inputs give the same answer. Needed to tell a real change from model noise.")
@JsonProperty(required = false)
@DefaultsWhenEmpty
Long seed) {
/** Which knobs this object actually sets. Empty when it asks for nothing. */

View File

@ -629,6 +629,38 @@ public class BlocksControllerTest {
assertFalse(prompt.has("x-ui-rows"));
}
@Test
public void onlyTheSamplingParametersDeclareTheyDefaultWhenEmpty() {
// The editor used to offer "Use default" on every optional field, inferring it from the
// field not being required - which says you may leave it blank, not that blank means
// something. The claim is now made per field, and the sampling parameters are what it is
// for: blank means the provider picks, and no typed number gets that state back.
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(LLMBlockType.TYPE);
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode parameters = findDefinition(schema, "ModelParameters");
assertNotNull(parameters, "ModelParameters should be a schema definition");
for (String knob : new String[] { "temperature", "topP", "topK", "maxTokens", "seed" }) {
assertTrue(parameters.path("properties").path(knob).path("x-ui-defaults-when-empty").asBoolean(),
knob + " should declare that empty means the provider decides");
}
// prompt is optional too, and must no longer claim a default just for being optional.
assertFalse(schema.path("properties").path("prompt").has("x-ui-defaults-when-empty"));
assertFalse(schema.path("properties").path("skills").has("x-ui-defaults-when-empty"));
}
private JsonNode findDefinition(JsonNode schema, String name) {
for (String container : new String[] { "definitions", "$defs", "sharedDefinitions" }) {
JsonNode found = schema.path(container).path(name);
if (!found.isMissingNode() && found.has("properties")) {
return found;
}
}
return null;
}
@Test
public void chatInteractionSchemaDeclaresUniqueInputNames() {
BlockConfigurationDescriptor descriptor = blocksController

View File

@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
/**
* What the editor is told about a provider before anyone runs anything.
*
* <p>The capabilities exist so a choice that cannot work is not offered: every provider is handed
* the same five sampling parameters, and the ones it ignores were only ever reported afterwards, in
* an execution warning.
*/
class LLMProviderCatalogServiceTest {
private static LLMProvider provider(String name, Set<ModelParameter> parameters) {
return new LLMProvider() {
@Override
public String getName() {
return name;
}
@Override
public List<String> getRegisteredModels() {
return List.of();
}
@Override
public String generate(String model, String prompt) {
throw new UnsupportedOperationException("not exercised by this test");
}
@Override
public Set<ModelParameter> supportedParameters() {
return parameters;
}
};
}
@Test
void reportsWhichKnobsEachProviderActuallyApplies() {
LLMProviderCatalogService catalog = new LLMProviderCatalogService(Map.of(
"a", provider("Everything", EnumSet.allOf(ModelParameter.class)),
"b", provider("NoSeed", EnumSet.of(ModelParameter.TEMPERATURE, ModelParameter.TOP_K))));
Map<String, LLMProviderMetadata> byName = catalog.list().stream()
.collect(java.util.stream.Collectors.toMap(LLMProviderMetadata::name, metadata -> metadata));
assertEquals(List.of("MAX_TOKENS", "SEED", "TEMPERATURE", "TOP_K", "TOP_P"),
byName.get("Everything").supportedParameters(), "sorted, so the payload is stable");
assertEquals(List.of("TEMPERATURE", "TOP_K"), byName.get("NoSeed").supportedParameters());
}
}