Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* Example:
*
* <pre>
* &#64;CopilotTool("Schedule meeting")
* public String schedule(
* &#64;CopilotToolParam(value = "When to meet", schema = "{\"type\":\"string\",\"format\":\"date-time\"}") MyCustomDateTime when) {
* // ...
* }
* </pre>
*/
String schema() default "";
}
214 changes: 212 additions & 2 deletions java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String> 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<String> 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 "";
Expand Down
40 changes: 31 additions & 9 deletions java/src/main/java/com/github/copilot/tool/Param.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ public final class Param<T> {
private final String description;
private final boolean required;
private final String defaultValue;
private final String schema;

private Param(Class<T> type, String name, String description, boolean required, String defaultValue) {
private Param(Class<T> 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()) {
Expand Down Expand Up @@ -70,7 +73,7 @@ private Param(Class<T> type, String name, String description, boolean required,
* if {@code name} or {@code description} is blank
*/
public static <T> Param<T> of(Class<T> type, String name, String description) {
return new Param<>(type, name, description, true, "");
return new Param<>(type, name, description, true, "", "");
}

/**
Expand All @@ -96,7 +99,7 @@ public static <T> Param<T> of(Class<T> type, String name, String description) {
*/
public static <T> Param<T> of(Class<T> 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, "");
}

/**
Expand All @@ -107,7 +110,7 @@ public static <T> Param<T> of(Class<T> type, String name, String description, bo
* @return a new {@code Param} with the updated name
*/
public Param<T> 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);
}

/**
Expand All @@ -118,7 +121,7 @@ public Param<T> name(String name) {
* @return a new {@code Param} with the updated description
*/
public Param<T> 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);
}

/**
Expand All @@ -129,7 +132,7 @@ public Param<T> description(String description) {
* @return a new {@code Param} with the updated required flag
*/
public Param<T> 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);
}

/**
Expand All @@ -142,7 +145,7 @@ public Param<T> required(boolean required) {
* false
*/
public Param<T> 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. */
Expand Down Expand Up @@ -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<T> 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
Expand Down
Loading