Skip to content

Java: Add schema attribute to @Param for custom type schema override #1794

Description

@edburns

Overview

Add an optional schema attribute to the @Param annotation that allows users to explicitly specify the JSON Schema for a tool parameter. This enables support for custom/third-party types (e.g., internal date libraries, domain objects) that the SchemaGenerator cannot automatically map to a correct JSON Schema.

Parent Epic: #1682

Motivation

The SchemaGenerator handles a bounded set of well-known types (java.time.*, primitives, records, enums, etc.), but users may have internal libraries with custom types that:

  1. Fall through to the "class/object" schema path, exposing meaningless internal fields
  2. Cannot be automatically mapped to a meaningful JSON Schema for the LLM

Currently, the only workarounds are:

  • Use String as the parameter type and parse manually
  • Use a java.time or primitive type at the tool boundary and convert internally

A schema attribute on @Param gives users an explicit escape hatch.

Deliverables

Files to modify

  1. java/src/main/java/com/github/copilot/tool/Param.java — add String schema() default "";
  2. java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java — in generateSchemaWithParamMetadata(), check paramAnnotation.schema() before calling schemaGenerator.generateSchemaSource(). If non-empty, use the raw schema string directly.
  3. java/src/main/java/com/github/copilot/tool/SchemaGenerator.java — no changes needed (schema override bypasses the generator entirely).

Implementation specification

1. @Param annotation change

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Param {

    /** Parameter description (sent to the model). */
    String value() default "";

    /** Parameter name override. Defaults to the actual parameter name. */
    String name() default "";

    /** Whether this parameter is required. Default true. */
    boolean required() default true;

    /** 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;Param(value = "When to meet",
     *            schema = "{\"type\":\"string\",\"format\":\"date-time\"}")
     *     MyCustomDateTime when) {
     *     // ...
     * }
     * </pre>
     */
    String schema() default "";
}

2. CopilotToolProcessor.generateSchemaWithParamMetadata() change

In the loop over parameters, before calling schemaGenerator.generateSchemaSource():

String typeSchema;
if (paramAnnotation != null && !paramAnnotation.schema().isEmpty()) {
    // User-provided explicit schema — validate it is parseable JSON at compile time
    try {
        // Parse to validate — use javax.json or a simple brace-matching check
        typeSchema = paramAnnotation.schema();
    } catch (Exception e) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
                "@Param schema must be valid JSON: " + e.getMessage(), param);
        continue;
    }
} else {
    typeSchema = schemaGenerator.generateSchemaSource(paramType,
            processingEnv.getTypeUtils(), processingEnv.getElementUtils());
}

Important: The schema value is a JSON string that will be emitted as a Map.of(...) expression in generated code. The processor must convert the JSON string to a Map.of(...) source code literal at compile time. One approach:

  • Parse the JSON at compile time
  • Generate nested Map.of(key, value, ...) source expressions from the parsed structure

Alternatively, the generated code can embed the JSON and parse it at runtime:

// Generated code approach (simpler, slight runtime cost):
new com.fasterxml.jackson.databind.ObjectMapper().readValue(
    "{\"type\":\"string\",\"format\":\"date-time\"}",
    new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {})

The implementer should choose the approach that balances code complexity vs. runtime cost. The Map.of(...) compile-time approach is preferred for consistency with existing generated code.

3. Compile-time validation

The processor MUST emit a Diagnostic.Kind.ERROR if:

  • @Param(schema = "...") is non-empty AND the value is not valid JSON (missing braces, malformed)
  • @Param(schema = "...") is used together with defaultValue (conflicting metadata — the schema should contain the default if needed)

4. Interaction with description

When both schema and value (description) are specified, the description should still be added via withMeta(...) wrapping, just as it is today for auto-generated schemas.

Gating tests and criteria

All of the following must pass:

Unit tests (in SchemaGeneratorTest.java or new ParamSchemaOverrideTest.java)

  1. Explicit schema override: A @CopilotTool method with @Param(schema = "{\"type\":\"string\",\"format\":\"date-time\"}") on a custom type produces the expected schema in generated code.
  2. Schema with description: @Param(value = "When to meet", schema = "{\"type\":\"string\",\"format\":\"date-time\"}") produces schema with both format and description.
  3. Invalid schema compile error: @Param(schema = "not json") emits a compile error.
  4. Schema + defaultValue conflict: @Param(schema = "...", defaultValue = "x") emits a compile error.
  5. Empty schema falls through: @Param(schema = "") (or omitted) uses normal type-based generation.

Integration test (in ToolDefinitionFromObjectTest.java)

  1. Handler invocation with custom type: Define a fixture with a custom type parameter, explicit schema, and verify the handler can be invoked and the ObjectMapper deserializes the argument correctly.

Existing tests must continue to pass

  1. mvn clean verify passes with no regressions.

Design decisions

  • No reflection fallback. The schema override is purely compile-time metadata.
  • JSON validation at compile time. Invalid schema strings fail the build, not at runtime.
  • Composable with value/required. The schema attribute only overrides the type-derived schema; description, required, and name metadata still apply independently.
  • Does NOT affect defaultValue semantics. If the user needs a default, it should be expressed in the schema itself or via required = false.

Example usage

// Internal enterprise date library
@CopilotTool("Schedule a deployment")
public String scheduleDeployment(
        @Param(value = "Deployment time in ISO-8601",
               schema = "{\"type\":\"string\",\"format\":\"date-time\"}")
        com.acme.internal.AcmeDateTime deployTime,
        @Param("Target environment") String environment) {
    // ObjectMapper with custom deserializer handles AcmeDateTime
    return "Scheduled for " + deployTime + " on " + environment;
}

Branch and PR conventions

  • Branch: create from main on upstream
  • PR target: main
  • Ensure mvn spotless:apply passes before commit
  • Follow existing Javadoc conventions (required on public API additions)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementjavaPull requests that update java code

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions