From 2b4c8c4e49c997bd7c9de715b4a5d7f8b7ae227c Mon Sep 17 00:00:00 2001 From: j-zhangyiyuan Date: Thu, 16 Jul 2026 10:07:18 +0800 Subject: [PATCH 1/2] java: add schema attribute to @CopilotToolParam for custom type schema override Closes #1794 Add an optional schema attribute to @CopilotToolParam that allows users to explicitly specify the JSON Schema for a tool parameter, bypassing automatic type-based schema generation. Changes: - CopilotToolParam.java: add String schema() default "" - CopilotToolProcessor.java: use custom schema when provided, with compile-time JSON validation and schema+defaultValue conflict check. Includes a minimal recursive-descent JSON-to-Map.of() converter. - Param.java: add schema field + fluent mutator for lambda API parity - CopilotToolProcessorTest.java: 4 new tests --- .../github/copilot/tool/CopilotToolParam.java | 18 ++ .../copilot/tool/CopilotToolProcessor.java | 202 +++++++++++++++++- .../java/com/github/copilot/tool/Param.java | 40 +++- .../tool/CopilotToolProcessorTest.java | 88 ++++++++ 4 files changed, 337 insertions(+), 11 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolParam.java b/java/src/main/java/com/github/copilot/tool/CopilotToolParam.java index 0b667d9d71..144ea2e613 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolParam.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolParam.java @@ -47,4 +47,22 @@ /** Optional default value when the argument is omitted. */ String defaultValue() default ""; + + /** + * Optional explicit JSON Schema for this parameter as a JSON string literal. + * When non-empty, bypasses automatic schema generation from the parameter type. + * The value must be a valid JSON object string. + * + *

+ * Example: + * + *

+     * @CopilotTool("Schedule meeting")
+     * public String schedule(
+     * 		@CopilotToolParam(value = "When to meet", schema = "{\"type\":\"string\",\"format\":\"date-time\"}") MyCustomDateTime when) {
+     * 	// ...
+     * }
+     * 
+ */ + String schema() default ""; } diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 03af4a7cd9..52ccc1ef5f 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -99,6 +99,20 @@ public boolean process(Set annotations, RoundEnvironment "@CopilotToolParam(required=false) primitive parameters must provide defaultValue or use a boxed/Optional type", param); } + if (paramAnnotation != null && !paramAnnotation.schema().isEmpty() + && !paramAnnotation.defaultValue().isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotToolParam cannot have both schema and defaultValue — express defaults inside the schema if needed", + param); + } + if (paramAnnotation != null && !paramAnnotation.schema().isEmpty()) { + String schemaJson = paramAnnotation.schema().trim(); + if (!schemaJson.startsWith("{") || !schemaJson.endsWith("}")) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotToolParam schema must be a valid JSON object string (must start with '{' and end with '}')", + param); + } + } } if (toolInvocationParamCount > 1) { processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, @@ -338,8 +352,19 @@ private String generateSchemaWithParamMetadata(List p CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class); // Generate the type schema for this parameter - String typeSchema = schemaGenerator.generateSchemaSource(paramType, processingEnv.getTypeUtils(), - processingEnv.getElementUtils()); + String typeSchema; + if (paramAnnotation != null && !paramAnnotation.schema().isEmpty()) { + try { + typeSchema = jsonToMapOfSource(paramAnnotation.schema().trim()); + } catch (IllegalArgumentException e) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotToolParam schema is not valid JSON: " + e.getMessage(), param); + continue; + } + } else { + typeSchema = schemaGenerator.generateSchemaSource(paramType, processingEnv.getTypeUtils(), + processingEnv.getElementUtils()); + } // Build property schema with description and default if present String propertySchema = buildPropertySchema(typeSchema, paramAnnotation, paramType); @@ -852,6 +877,179 @@ static String toSnakeCase(String name) { return sb.toString(); } + // ------------------------------------------------------------------ + // JSON-to-Map.of() source code conversion + // ------------------------------------------------------------------ + + /** + * Converts a JSON object string to a {@code Map.of(...)} Java source literal. + * Supports nested objects, arrays, strings, numbers, booleans, and null. + */ + static String jsonToMapOfSource(String json) { + return new JsonToSourceConverter(json).parseObject(); + } + + /** + * Minimal recursive-descent JSON parser that produces {@code Map.of(...)}, + * {@code List.of(...)}, and literal Java source expressions from a JSON string. + * Only used at compile time by the annotation processor. + */ + private static final class JsonToSourceConverter { + + private final String input; + private int pos; + + JsonToSourceConverter(String input) { + this.input = input; + this.pos = 0; + } + + String parseObject() { + skipWhitespace(); + expect('{'); + skipWhitespace(); + List entries = new ArrayList<>(); + if (peek() != '}') { + do { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + String value = parseValue(); + entries.add("\"" + escapeJava(key) + "\", " + value); + skipWhitespace(); + } while (tryConsume(',')); + } + expect('}'); + if (entries.isEmpty()) { + return "Map.of()"; + } + return "Map.of(" + String.join(", ", entries) + ")"; + } + + private String parseArray() { + expect('['); + skipWhitespace(); + List items = new ArrayList<>(); + if (peek() != ']') { + do { + skipWhitespace(); + items.add(parseValue()); + skipWhitespace(); + } while (tryConsume(',')); + } + expect(']'); + if (items.isEmpty()) { + return "List.of()"; + } + return "List.of(" + String.join(", ", items) + ")"; + } + + private String parseValue() { + skipWhitespace(); + char c = peek(); + if (c == '{') { + return parseObject(); + } + if (c == '[') { + return parseArray(); + } + if (c == '"') { + return "\"" + escapeJava(parseString()) + "\""; + } + if (c == 't' || c == 'f') { + return parseBoolean(); + } + if (c == 'n') { + return parseNull(); + } + return parseNumber(); + } + + private String parseString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (pos < input.length() && input.charAt(pos) != '"') { + if (input.charAt(pos) == '\\') { + pos++; + if (pos >= input.length()) { + throw new IllegalArgumentException("Unterminated string escape at position " + pos); + } + } + sb.append(input.charAt(pos)); + pos++; + } + expect('"'); + return sb.toString(); + } + + private String parseBoolean() { + if (input.startsWith("true", pos)) { + pos += 4; + return "true"; + } + if (input.startsWith("false", pos)) { + pos += 5; + return "false"; + } + throw new IllegalArgumentException("Expected boolean at position " + pos); + } + + private String parseNull() { + if (input.startsWith("null", pos)) { + pos += 4; + return "null"; + } + throw new IllegalArgumentException("Expected null at position " + pos); + } + + private String parseNumber() { + int start = pos; + if (pos < input.length() && input.charAt(pos) == '-') { + pos++; + } + while (pos < input.length() + && (Character.isDigit(input.charAt(pos)) || input.charAt(pos) == '.' || input.charAt(pos) == 'e' + || input.charAt(pos) == 'E' || input.charAt(pos) == '+' || input.charAt(pos) == '-')) { + pos++; + } + if (pos == start) { + throw new IllegalArgumentException("Expected number at position " + pos); + } + return input.substring(start, pos); + } + + private void skipWhitespace() { + while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) { + pos++; + } + } + + private char peek() { + if (pos >= input.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + return input.charAt(pos); + } + + private void expect(char c) { + if (pos >= input.length() || input.charAt(pos) != c) { + throw new IllegalArgumentException("Expected '" + c + "' at position " + pos + " but got '" + + (pos < input.length() ? input.charAt(pos) : "EOF") + "'"); + } + pos++; + } + + private boolean tryConsume(char c) { + if (pos < input.length() && input.charAt(pos) == c) { + pos++; + return true; + } + return false; + } + } + private static String escapeJava(String s) { if (s == null) { return ""; diff --git a/java/src/main/java/com/github/copilot/tool/Param.java b/java/src/main/java/com/github/copilot/tool/Param.java index bbe188ce05..376f59097a 100644 --- a/java/src/main/java/com/github/copilot/tool/Param.java +++ b/java/src/main/java/com/github/copilot/tool/Param.java @@ -37,12 +37,15 @@ public final class Param { private final String description; private final boolean required; private final String defaultValue; + private final String schema; - private Param(Class type, String name, String description, boolean required, String defaultValue) { + private Param(Class type, String name, String description, boolean required, String defaultValue, + String schema) { this.type = Objects.requireNonNull(type, "type"); this.name = requireNonBlank(name, "name"); this.description = requireNonBlank(description, "description"); this.defaultValue = defaultValue == null ? "" : defaultValue; + this.schema = schema == null ? "" : schema; this.required = required; if (this.required && !this.defaultValue.isEmpty()) { @@ -70,7 +73,7 @@ private Param(Class type, String name, String description, boolean required, * if {@code name} or {@code description} is blank */ public static Param of(Class type, String name, String description) { - return new Param<>(type, name, description, true, ""); + return new Param<>(type, name, description, true, "", ""); } /** @@ -96,7 +99,7 @@ public static Param of(Class type, String name, String description) { */ public static Param of(Class type, String name, String description, boolean required, String defaultValue) { - return new Param<>(type, name, description, required, defaultValue); + return new Param<>(type, name, description, required, defaultValue, ""); } /** @@ -107,7 +110,7 @@ public static Param of(Class type, String name, String description, bo * @return a new {@code Param} with the updated name */ public Param name(String name) { - return new Param<>(this.type, name, this.description, this.required, this.defaultValue); + return new Param<>(this.type, name, this.description, this.required, this.defaultValue, this.schema); } /** @@ -118,7 +121,7 @@ public Param name(String name) { * @return a new {@code Param} with the updated description */ public Param description(String description) { - return new Param<>(this.type, this.name, description, this.required, this.defaultValue); + return new Param<>(this.type, this.name, description, this.required, this.defaultValue, this.schema); } /** @@ -129,7 +132,7 @@ public Param description(String description) { * @return a new {@code Param} with the updated required flag */ public Param required(boolean required) { - return new Param<>(this.type, this.name, this.description, required, this.defaultValue); + return new Param<>(this.type, this.name, this.description, required, this.defaultValue, this.schema); } /** @@ -142,7 +145,7 @@ public Param required(boolean required) { * false */ public Param defaultValue(String defaultValue) { - return new Param<>(this.type, this.name, this.description, false, defaultValue); + return new Param<>(this.type, this.name, this.description, false, defaultValue, this.schema); } /** Returns the Java type of this parameter. */ @@ -175,18 +178,37 @@ public boolean hasDefaultValue() { return !defaultValue.isEmpty(); } + /** + * Returns a copy with an explicit JSON Schema override. When set, bypasses + * automatic schema generation from the parameter type. + * + * @param schema + * a JSON object string (e.g., + * {@code "{\"type\":\"string\",\"format\":\"date-time\"}"} ) + * @return a new {@code Param} with the schema override + */ + public Param schema(String schema) { + return new Param<>(this.type, this.name, this.description, this.required, this.defaultValue, schema); + } + + /** Returns the explicit JSON Schema override, or empty if none. */ + public String schema() { + return schema; + } + @Override public boolean equals(Object o) { if (!(o instanceof Param other)) { return false; } return required == other.required && Objects.equals(type, other.type) && Objects.equals(name, other.name) - && Objects.equals(description, other.description) && Objects.equals(defaultValue, other.defaultValue); + && Objects.equals(description, other.description) && Objects.equals(defaultValue, other.defaultValue) + && Objects.equals(schema, other.schema); } @Override public int hashCode() { - return Objects.hash(type, name, description, required, defaultValue); + return Objects.hash(type, name, description, required, defaultValue, schema); } @Override diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 574d3acaa0..19c3ad7ad7 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -189,6 +189,94 @@ public String search(@CopilotToolParam(value = "Search input", required = false, "Expected compile error for single-record wrapper metadata overrides, got: " + result.diagnostics); } + // ── Test: @CopilotToolParam schema override ───────────────────────────────── + + @Test + void generatesCorrectSchema_forExplicitSchemaOverride() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class SchemaOverrideTools { + @CopilotTool("Schedule meeting") + public String schedule( + @CopilotToolParam(value = "When to meet", + schema = "{\\"type\\":\\"string\\",\\"format\\":\\"date-time\\"}") String when) { + return "scheduled " + when; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.SchemaOverrideTools", source))); + + assertNoErrors(result); + assertTrue(result.generatedSources.stream().anyMatch(s -> s.contains("date-time")), + "Expected generated code to contain the custom schema format, got: " + result.generatedSources); + } + + @Test + void emitsError_forSchemaWithDefaultValue() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class SchemaDefaultConflict { + @CopilotTool("Do something") + public String doIt( + @CopilotToolParam(value = "Input", + schema = "{\\"type\\":\\"string\\"}", + defaultValue = "hello") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.SchemaDefaultConflict", source))); + + assertTrue(hasErrorContaining(result, "schema and defaultValue"), + "Expected compile error for schema + defaultValue conflict, got: " + result.diagnostics); + } + + @Test + void emitsError_forInvalidSchemaJson() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class InvalidSchemaTools { + @CopilotTool("Do something") + public String doIt( + @CopilotToolParam(value = "Input", schema = "not json") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.InvalidSchemaTools", source))); + + assertTrue(hasErrorContaining(result, "valid JSON object string"), + "Expected compile error for invalid schema JSON, got: " + result.diagnostics); + } + + @Test + void compilesSuccessfully_forEmptySchemaFallsThrough() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class EmptySchemaTools { + @CopilotTool("Search") + public String search(@CopilotToolParam(value = "Query", schema = "") String query) { + return query; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.EmptySchemaTools", source))); + + assertNoErrors(result); + } + // ── Test: Return type handling ────────────────────────────────────────────── @Test From 9eb4ce10aff23a3fc7a24ebe6c2d6ee2182f1d33 Mon Sep 17 00:00:00 2001 From: j-zhangyiyuan Date: Thu, 16 Jul 2026 10:16:01 +0800 Subject: [PATCH 2/2] fix: validate schema on single-record wrappers and reject trailing JSON content --- .../github/copilot/tool/CopilotToolProcessor.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 52ccc1ef5f..ead3c6021c 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -132,6 +132,11 @@ public boolean process(Set annotations, RoundEnvironment "@CopilotToolParam(defaultValue=...) is not supported on single-record tool parameters; use record component defaults or a non-record parameter", singleParam); } + if (!paramAnnotation.schema().isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotToolParam(schema=...) is not supported on single-record tool parameters; annotate record components instead", + singleParam); + } if (!paramAnnotation.name().isEmpty() || !paramAnnotation.value().isEmpty() || !paramAnnotation.required()) { processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, @@ -886,7 +891,14 @@ static String toSnakeCase(String name) { * Supports nested objects, arrays, strings, numbers, booleans, and null. */ static String jsonToMapOfSource(String json) { - return new JsonToSourceConverter(json).parseObject(); + JsonToSourceConverter converter = new JsonToSourceConverter(json); + String result = converter.parseObject(); + converter.skipWhitespace(); + if (converter.pos < json.length()) { + throw new IllegalArgumentException("Unexpected trailing content at position " + converter.pos + ": '" + + json.substring(converter.pos) + "'"); + } + return result; } /**