Let a binding declare where its chosen value can be read in full

A skill on a node is only an id, and the editor grew a rule of its own to
show the instructions behind it: it looked for the literal retriever name
"Skills" and built the endpoint by hand. That is a special case inside
machinery that is otherwise entirely schema-driven, and the next binding
that wanted the same view would have needed another one.

@FieldRetriever now takes a definitionUrl, emitted as
x-retriever-definition-url, and the editor offers the view wherever it is
declared. The MCP server binding deliberately declares none: its
definitions endpoint answers with a JSON schema rather than readable text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-20 20:58:07 +02:00
parent c044d78a5a
commit 52b02ce349
4 changed files with 37 additions and 1 deletions

View File

@ -459,6 +459,9 @@ public class JsonSchemaProducer {
if (!retriever.validationUrl().isBlank()) {
propertySchema.put("x-retriever-validation-url", retriever.validationUrl());
}
if (!retriever.definitionUrl().isBlank()) {
propertySchema.put("x-retriever-definition-url", retriever.definitionUrl());
}
applySubFlowValidationMetadata(propertySchema, ownerClass, entry.getKey());
}
}

View File

@ -20,4 +20,14 @@ public @interface FieldRetriever {
boolean structuredData() default false;
boolean requiresAuth() default false;
String validationUrl() default "";
/**
* Where the editor can read the full definition behind a chosen value, as {@code <url>/<value>}.
*
* <p>Set it only when that endpoint answers with an id, a name, an optional description and the
* content to show: the editor renders it as read-only text, so a payload of another shape (a
* JSON schema, say) would be displayed as gibberish. Left empty, the editor offers no such view,
* which is the right answer for a value that is already all there is to know about it.
*/
String definitionUrl() default "";
}

View File

@ -18,7 +18,8 @@ import lombok.Builder;
public record SkillBinding(
@NotBlank
@JsonProperty(required = false)
@FieldRetriever(name = "Skills", url = "/retriever/Skills/items")
@FieldRetriever(name = "Skills", url = "/retriever/Skills/items",
definitionUrl = "/retriever/Skills/definitions")
@UiLabel("skill")
@UiDescription("Local skill loaded from the static skill catalog.")
String skillId) {

View File

@ -651,6 +651,28 @@ public class BlocksControllerTest {
assertFalse(schema.path("properties").path("skills").has("x-ui-defaults-when-empty"));
}
@Test
public void aSkillSaysWhereItsFullDefinitionCanBeRead() {
// A chosen skill is only an id on the node, and the instructions it carries are the whole
// reason to pick one. The editor offers to show them wherever a retriever-backed value
// declares this URL, so it stays a property of the binding rather than a rule about skills
// written into the editor.
BlockConfigurationDescriptor descriptor = blocksController
.getConfigurationDescriptorForType(LLMBlockType.TYPE);
JsonNode schema = (JsonNode) descriptor.schema();
JsonNode skillBinding = findDefinition(schema, "SkillBinding");
assertNotNull(skillBinding, "SkillBinding should be a schema definition");
assertEquals("/retriever/Skills/definitions",
skillBinding.path("properties").path("skillId").path("x-retriever-definition-url").asText());
// The MCP binding's own endpoint answers with a JSON schema, not a readable definition, so
// it must not claim one: the editor would render it as text.
JsonNode mcpBinding = findDefinition(schema, "MCPToolServerBinding");
assertNotNull(mcpBinding, "MCPToolServerBinding should be a schema definition");
assertFalse(mcpBinding.path("properties").path("serverName").has("x-retriever-definition-url"));
}
private JsonNode findDefinition(JsonNode schema, String name) {
for (String container : new String[] { "definitions", "$defs", "sharedDefinitions" }) {
JsonNode found = schema.path(container).path(name);