java: add schema attribute to @CopilotToolParam and lambda for custom type schema override#2069
Conversation
…a 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
```text java: honor schema overrides in lambda tool definitions Parse explicit Param schemas when building runtime tool metadata instead of always deriving a schema from the Java parameter type. Validate fluent schema overrides consistently, preserve parameter metadata, and report malformed JSON with tool and parameter context. Add focused coverage for direct schema construction, custom-type lambda tools, and Param validation and copying. Stabilize processor tests across line endings and make Java test setup quieter and easier to troubleshoot. ``` # Per-file change manifest - `.vscode/settings.json`: Disable Java autobuild for the workspace to avoid unsolicited background builds. - `java/pom.xml`: Default npm setup output to `notice` and allow the log level to be overridden with `-Dnpm.loglevel` for troubleshooting. - `java/src/main/java/com/github/copilot/rpc/ParamSchema.java`: Parse a non-empty `Param.schema()` with the configured `ObjectMapper`, use it as the parameter schema, retain description and required metadata, fall back to type-derived schemas when absent, and include tool and parameter names in invalid-JSON errors. - `java/src/main/java/com/github/copilot/tool/Param.java`: Validate schema overrides during fluent Param construction, require object-shaped JSON text, and reject combining an explicit schema with `defaultValue`. - `java/src/test/java/com/github/copilot/rpc/ParamSchemaTest.java`: Cover explicit schema use, description and required metadata preservation, and mixed explicit and type-derived parameters. - `java/src/test/java/com/github/copilot/rpc/ToolDefinitionLambdaTest.java`: Verify that `ToolDefinition.from(...)` publishes an explicit schema for a custom Java type. - `java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java`: Normalize generated source line endings before asserting absent metadata, making the test portable across Windows and Unix. - `java/src/test/java/com/github/copilot/tool/ParamTest.java`: Cover malformed schema text, schema/default conflicts, empty and valid schemas, and schema preservation across fluent copies.
There was a problem hiding this comment.
Pull request overview
Adds explicit JSON Schema overrides for Java annotation- and lambda-based tool parameters.
Changes:
- Adds schema metadata to
CopilotToolParamandParam. - Parses overrides in annotation processing and lambda schema generation.
- Adds validation/tests plus npm and editor configuration updates.
Show a summary per file
| File | Description |
|---|---|
.vscode/settings.json |
Disables Java autobuild. |
java/pom.xml |
Configures npm log level. |
java/src/main/java/com/github/copilot/rpc/ParamSchema.java |
Parses lambda schema overrides. |
java/src/main/java/com/github/copilot/tool/CopilotToolParam.java |
Adds annotation schema attribute. |
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java |
Validates and generates override schemas. |
java/src/main/java/com/github/copilot/tool/Param.java |
Adds fluent schema metadata. |
java/src/test/java/com/github/copilot/rpc/ParamSchemaTest.java |
Tests runtime schema construction. |
java/src/test/java/com/github/copilot/rpc/ToolDefinitionLambdaTest.java |
Tests lambda custom-type schemas. |
java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java |
Tests processor validation and generation. |
java/src/test/java/com/github/copilot/tool/ParamTest.java |
Tests Param schema behavior. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 5
- Review effort level: Medium
…eyuan-pr-1999 # Conflicts: # java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (5)
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1008
- Escapes are skipped rather than decoded or validated. Valid JSON escapes such as
\n,\t,\b,\f, and\uXXXXsilently become ordinary letters, while an invalid escape such as\qis accepted. This can alter schema patterns/descriptions; use a real JSON decoder or implement the complete JSON escape rules.
if (input.charAt(pos) == '\\') {
pos++;
if (pos >= input.length()) {
throw new IllegalArgumentException("Unterminated string escape at position " + pos);
}
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1032
- Emitting Java
nullhere feeds it intoMap.oforList.of, both of which reject null and throw during generated metadata initialization. JSON Schema validly uses null inconst,default, andenum, and this method explicitly claims null support. Generate null-tolerant collection expressions or report a compile-time diagnostic instead.
return "null";
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1045
- This scanner does not implement JSON number grammar and emits the token as a Java expression. For example, invalid JSON
1+2is accepted and becomes schema value3, while valid JSON2147483648fails generated-source compilation as an oversized Java int literal. Parse JSON numbers strictly and emit correctly typed Java literals.
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++;
java/src/test/java/com/github/copilot/rpc/ToolDefinitionLambdaTest.java:495
- This test only inspects the schema and never invokes the handler. Issue #1794 explicitly requires proving that ObjectMapper deserializes an argument into the custom type; with a string schema and this property-based record, that path is not exercised. Invoke
tool.handler()with a matching argument and assert the handler receives the expectedCustomDateTime, adding an appropriate creator/deserializer if needed.
Param<CustomDateTime> p = Param.of(CustomDateTime.class, "when", "Meeting time")
.schema("{\"type\":\"string\",\"format\":\"date-time\"}");
ToolDefinition tool = ToolDefinition.from("schedule", "Schedules a meeting", p, when -> "scheduled");
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:958
Map.ofonly supports up to 10 key/value pairs. A valid custom schema object with 11 or more keywords/properties therefore produces generated Java that does not compile. EmitMap.ofEntries/Map.entry, as the existingSchemaGeneratordoes.
return "Map.of(" + String.join(", ", entries) + ")";
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (5)
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:958
Map.ofhas key/value overloads only through 10 entries. A valid schema object with 11 keywords—or a nestedpropertiesobject with 11 fields—therefore makes the generated source fail to compile. Emit an arity-independent map expression or builder instead.
return "Map.of(" + String.join(", ", entries) + ")";
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1008
- This drops the backslash without decoding or validating the JSON escape. Valid escapes such as
\nand\u0061becomenandu0061, while an invalid escape such as\qis accepted and silently altered. Decode every JSON escape and reject unknown escapes/control characters, or use a standards-compliant parser before generating source.
if (input.charAt(pos) == '\\') {
pos++;
if (pos >= input.length()) {
throw new IllegalArgumentException("Unterminated string escape at position " + pos);
}
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1032
- Emitting Java
nullhere inserts it into the surroundingMap.oforList.of, both of which reject null values. Valid schemas such as{"const":null}or{"enum":[null]}will compile but throwNullPointerExceptionwhen tool definitions are constructed. Generate null-tolerant collection expressions for parsed JSON.
private String parseNull() {
if (input.startsWith("null", pos)) {
pos += 4;
return "null";
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1046
- This accepts an arbitrary run of numeric/operator characters and emits it as a Java expression rather than validating a JSON number. For example, invalid JSON
1+2becomes the schema value3, and010becomes octal8; conversely, a valid integer outside Java'sintliteral range can fail generated-code compilation. Parse the exact JSON number grammar and emit an appropriately typed numeric source expression.
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++;
}
java/src/test/java/com/github/copilot/rpc/ToolDefinitionLambdaTest.java:495
- The linked issue's gating integration test requires invoking a handler with a custom type, but this test only inspects the generated schema. It never exercises the distinct
ParamCoercion.coercedeserialization path, so schema generation can pass while custom-type invocation still fails. Invoketool.handler()with the schema-shaped value and assert that the lambda receives the expected custom value, using a custom/delegating deserialization fixture.
Param<CustomDateTime> p = Param.of(CustomDateTime.class, "when", "Meeting time")
.schema("{\"type\":\"string\",\"format\":\"date-time\"}");
ToolDefinition tool = ToolDefinition.from("schedule", "Schedules a meeting", p, when -> "scheduled");
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (5)
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:958
Map.ofhas overloads for at most ten entries, so an otherwise valid schema object with 11 or more keywords (including a nested object) makes the generated Java fail to compile. Emit an unbounded map expression such asMap.ofEntriesinstead.
return "Map.of(" + String.join(", ", entries) + ")";
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1032
- Emitting the Java
nullliteral intoMap.of(...)orList.of(...)breaks valid schemas containing JSON null (for example{"default":null}or{"enum":[null,"x"]}), because both factories reject null at runtime. Use a null-tolerant generated collection representation.
return "null";
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1008
- This drops the backslash without decoding or validating the JSON escape. Consequently
\nbecomes the lettern,\u0061becomesu0061, and invalid escapes such as\qare accepted, so the generated schema differs from the supplied valid JSON and malformed JSON can pass validation. Decode all RFC 8259 escapes and reject unknown ones.
if (input.charAt(pos) == '\\') {
pos++;
if (pos >= input.length()) {
throw new IllegalArgumentException("Unterminated string escape at position " + pos);
}
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1045
- This is not the JSON number grammar: values such as
+1,1., and1-2are accepted, with1-2even emitted as a Java subtraction expression. Conversely, valid large JSON numbers can become uncompilable Java literals. Parse JSON numbers strictly and emit a range-independent numeric representation such asBigDecimal.
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++;
java/src/test/java/com/github/copilot/rpc/ToolDefinitionLambdaTest.java:495
- The linked #1794 gating criterion requires invoking a generated
fromObjecthandler with a custom type to verify ObjectMapper deserialization. This test only inspects lambda schema metadata, while the processor test usesString, so that required end-to-end path remains untested. Add a custom-type fixture with the annotation override and invoke its generated tool handler.
ToolDefinition tool = ToolDefinition.from("schedule", "Schedules a meeting", p, when -> "scheduled");
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
java/src/main/java/com/github/copilot/rpc/ParamSchema.java:91
ObjectMapper.readValuedoes not reject trailing tokens by default, so a value such as{"type":"string"} {"type":"integer"}passesParam's brace check and is silently accepted as the first object even though the public API requires one valid JSON object. EnableFAIL_ON_TRAILING_TOKENSfor this parse so the lambda API enforces the documented contract consistently with the annotation processor.
Map<String, Object> parsed = mapper.readValue(param.schema(), Map.class);
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1140
- JSON permits only space, tab, CR, and LF between tokens, while
Character.isWhitespacealso accepts characters such as form feed and vertical tab. Consequently an invalid annotation value like"{\f\"type\":\"string\"}"passes the promised compile-time JSON validation and is emitted as a schema. Restrict this loop to the four JSON whitespace characters.
while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) {
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (3)
java/src/main/java/com/github/copilot/rpc/ParamSchema.java:92
- The default untyped
readValueis not strict or lossless enough for a schema override: it ignores trailing JSON values unlessFAIL_ON_TRAILING_TOKENSis enabled (so{"type":"string"}{}is accepted), and decimal constraints are coerced toDouble, which can round or overflow values such as1e400. Use a strict reader that preserves decimal values.
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed = mapper.readValue(param.schema(), Map.class);
typeSchema = parsed;
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1065
Character.digit(..., 16)also recognizes non-ASCII characters such as full-width hex digits, although JSON\uescapes permit only ASCII0-9,a-f, andA-F. Such malformed escapes currently pass the promised compile-time JSON validation; restrict this conversion to the JSON hex alphabet.
for (int i = 0; i < 4; i++) {
int digit = Character.digit(input.charAt(pos++), 16);
if (digit < 0) {
throw new IllegalArgumentException("Invalid Unicode escape at position " + (pos - 1));
java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java:1125
- JSON numbers permit only ASCII digits, but
Character.isDigitaccepts many Unicode numerals. For example, an annotation schema containing{"minimum":1١}can pass this parser and be emitted as aBigDecimalinstead of producing the required compile-time error. Use an ASCII digit predicate for the fraction, exponent, and remaining integer digits.
private void requireDigit(String part) {
if (pos >= input.length() || !Character.isDigit(input.charAt(pos))) {
throw new IllegalArgumentException("Expected digit in number " + part + " at position " + pos);
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
java/src/main/java/com/github/copilot/rpc/ParamSchema.java:93
- Untyped Jackson parsing converts JSON decimals to
Double, so an explicit schema can be changed before it is sent. For example,{"maximum":1e400}becomesDouble.POSITIVE_INFINITY(and Jackson later serializes that as the string"Infinity"), while ordinary high-precision decimals are rounded. Parse floating-point values asBigDecimal, matching the annotation-processor path, so valid schema numbers retain their JSON value.
Map<String, Object> parsed = mapper.readerFor(Map.class)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readValue(param.schema());
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
java/src/main/java/com/github/copilot/rpc/ParamSchema.java:93
- Untyped floating-point values are deserialized as
Doublehere, so valid schema numbers outside the double range are not preserved. For example,schema("{\"maximum\":1e400}")becomes positive infinity; when the tool definition is serialized, Jackson emits a string such as"Infinity"(or invalid non-finite JSON), changing the numeric schema keyword. Parse floating values asBigDecimal, matching the annotation-processor path, so explicit schemas retain valid large/exact JSON numbers.
Map<String, Object> parsed = mapper.readerFor(Map.class)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readValue(param.schema());
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Cross-SDK Consistency Review ✅PR #2069 adds per-parameter JSON Schema override support to the Java SDK only ( Cross-SDK parity assessment
FindingsNo new inconsistencies introduced by this PR. The annotation-processor approach is Java-specific and does not require parallel syntax in other SDKs. The minor gaps in .NET and Go are pre-existing — this PR did not create them. They may be worth separate follow-up issues if explicit schema override convenience APIs are desired there. Optional follow-upThe new public API surface ( Automated cross-SDK consistency review Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Supercedes #1999
Closes #1794
Summary
Adds an optional
schemaattribute to@CopilotToolParamthat allows users to explicitly specify the JSON Schema for a tool parameter. This enables support for custom/third-party types that theSchemaGeneratorcannot automatically map.Example
Changes
CopilotToolParam.javaString schema() default ""annotation attribute with JavadocCopilotToolProcessor.javagenerateSchemaWithParamMetadata(): whenschemais non-empty, use it instead of auto-generated schemaschema+defaultValueconflict emits errorjsonToMapOfSource()— a minimal recursive-descent JSON parser that converts JSON strings toMap.of(...)source code expressions at compile time (no external JSON library needed)Param.javaschemafield, fluentschema()mutator, and getter for lambda API parityequals(),hashCode()to includeschemaCopilotToolProcessorTest.java(4 new tests)Testing
generatesNullMetadata_whenAbsent) is a pre-existing failure onmainunrelated to this PR