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 0b667d9d7..144ea2e61 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 03af4a7cd..ead3c6021 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 extends TypeElement> 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,
@@ -118,6 +132,11 @@ public boolean process(Set extends TypeElement> 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,
@@ -338,8 +357,19 @@ private String generateSchemaWithParamMetadata(List extends VariableElement> 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 +882,186 @@ 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) {
+ 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;
+ }
+
+ /**
+ * 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 bbe188ce0..376f59097 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 574d3acaa..19c3ad7ad 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