Skip to content

[Java] @CopilotTool ergonomics 4.4: ToolDefinition.fromObject(Object) registration API #1761

Description

@edburns

Overview

Add static methods ToolDefinition.fromObject(Object) and ToolDefinition.fromClass(Class<?>) that load processor-generated $$CopilotToolMeta classes and return List<ToolDefinition> with fully working tool definitions (schema + invocation handlers).

Branch: edburns/1682-java-tool-ergonomics on upstream (⚠️ NOT main — PRs must target this branch)

Prerequisites

  • Tasks 4.1 (annotations), 4.2 (SchemaGenerator), and 4.3 (CopilotToolProcessor) must be complete and merged to the branch.
  • Before writing any code, read the entire implementation plan at:
    1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md

Relevant plan sections to carefully re-read

  • Section 3.6 — ToolDefinition.fromObject(Object) registration API (Resolution: processor-only approach, no reflection fallback)
  • Section 3.7 — module-info.java impact (Resolution: Class.forName works within same module/classloader)
  • Section 4.4 — ToolDefinition.fromObject(Object) (the primary task description)

Deliverables

Files to modify

  1. java/src/main/java/com/github/copilot/rpc/ToolDefinition.java — add fromObject(Object) and fromClass(Class<?>) static methods.

⚠️ Critical: ObjectMapper contract with generated code

The generated $$CopilotToolMeta.definitions() method accepts an ObjectMapper as its second parameter. This is an internal contract established in PR #1777 (task 4.3, CopilotToolProcessor).

Why: The generated code uses the ObjectMapper for:

  1. Argument coercion — converting complex types (enums, records, POJOs, java.time.*) from the args Map
  2. Result serialization — converting non-String return values to JSON

The mapper must be configured identically to JsonRpcClient.createObjectMapper():

  • JavaTimeModule registered
  • DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES = false
  • SerializationFeature.WRITE_DATES_AS_TIMESTAMPS = false
  • JsonInclude.Include.NON_NULL

Generated method signature (internal contract):

// Generated $$CopilotToolMeta class (package-private)
static List<ToolDefinition> definitions(MyTools instance, ObjectMapper mapper)

fromObject() must pass a properly configured mapper when reflectively invoking definitions():

Method defs = metaClass.getDeclaredMethod("definitions", clazz, ObjectMapper.class);
defs.setAccessible(true);
List<ToolDefinition> result = (List<ToolDefinition>) defs.invoke(null, instance, getConfiguredMapper());

How to obtain the configured mapper (without exposing it as public API): fromObject() is in com.github.copilot.rpc.ToolDefinition. The canonical mapper config lives in com.github.copilot.JsonRpcClient.createObjectMapper() (package-private class). Options for bridging:

  • Add a package-private static ObjectMapper field on ToolDefinition that is initialized by RpcHandlerDispatcher (in com.github.copilot) at startup via a package-private setter or static initializer block.
  • Or replicate the 4-line configuration inline in ToolDefinition with a comment linking to JsonRpcClient.createObjectMapper() as the canonical source.
  • The choice is left to the implementer, but the mapper must not be exposed as a public API return value.

Implementation specification

/**
 * Discovers tool definitions from an object whose methods are annotated with @CopilotTool.
 * Requires that the CopilotToolProcessor annotation processor ran at compile time
 * (generating the $$CopilotToolMeta companion class).
 *
 * @param instance the object containing @CopilotTool-annotated methods
 * @return list of tool definitions with working invocation handlers
 * @throws IllegalStateException if the generated $$CopilotToolMeta class is not found
 *         (annotation processor did not run)
 */
@CopilotExperimental
public static List<ToolDefinition> fromObject(Object instance) {
    Class<?> clazz = instance.getClass();
    String metaClassName = clazz.getName() + "$$CopilotToolMeta";
    try {
        Class<?> metaClass = Class.forName(metaClassName, true, clazz.getClassLoader());
        Method defs = metaClass.getDeclaredMethod("definitions", clazz, ObjectMapper.class);
        defs.setAccessible(true);
        @SuppressWarnings("unchecked")
        List<ToolDefinition> result = (List<ToolDefinition>) defs.invoke(null, instance, getConfiguredMapper());
        return result;
    } catch (ClassNotFoundException e) {
        throw new IllegalStateException(
            "Generated class " + metaClassName + " not found. "
            + "Ensure the CopilotToolProcessor annotation processor ran during compilation. "
            + "Add the copilot-sdk-java dependency to your annotation processor path.",
            e);
    } catch (ReflectiveOperationException e) {
        throw new IllegalStateException("Failed to invoke " + metaClassName + ".definitions()", e);
    }
}

/**
 * Returns the SDK-configured ObjectMapper for tool argument/result serialization.
 * Package-private — not exposed as public API.
 */
private static ObjectMapper getConfiguredMapper() {
    // Implementation options (choose one):
    // Option A: Static field initialized by RpcHandlerDispatcher at startup
    // Option B: Inline configuration matching JsonRpcClient.createObjectMapper()
    // The mapper MUST have JavaTimeModule, NON_NULL, lenient unknown-properties.
}

/**
 * Discovers tool definitions from a class with static @CopilotTool methods.
 * Requires that the CopilotToolProcessor annotation processor ran at compile time.
 */
@CopilotExperimental
public static List<ToolDefinition> fromClass(Class<?> clazz) {
    // For static tool methods — generates with null instance
    // Implementation detail: the generated $$CopilotToolMeta has a definitions(null) overload
    // or the method handles null instance for static methods.
    ...
}

Key design decisions (from Resolution 3.6)

  • No reflection fallback. If $$CopilotToolMeta is not found, throw IllegalStateException with a helpful message. Do NOT fall back to runtime reflection scanning.
  • Use clazz.getClassLoader() in Class.forName() to handle named JPMS modules correctly.
  • The generated definitions() method is package-private, so use setAccessible(true) since ToolDefinition is in a different package (com.github.copilot.rpc) than the generated class.
  • The generated definitions() method takes TWO parameters: the instance AND an ObjectMapper. This is the internal contract from PR feat(java): Add CopilotToolProcessor annotation processor (task 4.3) #1777.

Gating tests and criteria

All of the following must pass before this task is considered complete:

  1. End-to-end unit test: Create java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java that:

    • Defines a test class with @CopilotTool methods (the annotation processor will generate $$CopilotToolMeta during test compilation).
    • Calls ToolDefinition.fromObject(instance) and verifies the returned list has the correct number of tools.
    • Verifies each tool's name, description, and parameter schema.
    • Invokes each tool's handler with test arguments and verifies the correct method was called with correct arguments and the return value is correct.
  2. Handler invocation tests: For each return type pattern (String, void, CompletableFuture), verify the handler produces the expected result:

    • String method → handler returns the string value.
    • void method → handler returns "Success".
    • CompletableFuture<String> method → handler returns the async result.
  3. Argument coercion end-to-end test: Define a tool method with parameters of type String, int, boolean, and an enum. Call fromObject(), then invoke the handler with a Map<String, Object> containing appropriate values. Verify the method receives correctly typed arguments.

  4. Default value test: Define a tool method with @Param(defaultValue="42") int count. Invoke the handler with arguments that OMIT the count key. Verify the method receives 42.

  5. Error case test: Verify that calling fromObject() on an object whose class was NOT processed (no $$CopilotToolMeta exists) throws IllegalStateException with a message mentioning the annotation processor.

  6. java.time argument test: Define a tool method with a java.time.LocalDateTime parameter. Call fromObject(), invoke the handler with an ISO-8601 string argument. Verify the method receives a correctly parsed LocalDateTime. This validates that the ObjectMapper contract (with JavaTimeModule) is working end-to-end.

  7. Override tool test: Define a method with @CopilotTool(value="...", name="grep", overridesBuiltInTool=true). Verify fromObject() returns a ToolDefinition that is properly configured as an override.

  8. Spotless format check: mvn spotless:check passes.

  9. Full test suite: mvn clean verify passes (existing tests and tasks 4.1–4.3 tests not broken).

⚠️ Critical: ToolDefer.NONEnull mapping

When the @CopilotTool annotation has defer = ToolDefer.NONE (the default), fromObject() must pass null for the defer parameter to ToolDefinition.create()not ToolDefer.NONE itself. A non-null NONE reference would leak "defer": null onto the JSON-RPC wire payload because Jackson's @JsonInclude(NON_NULL) checks the field reference, not the @JsonValue return. See the Javadoc on ToolDefer.NONE for the full explanation.

Add a gating test: call fromObject() on a class with @CopilotTool that does NOT set defer (uses the default NONE), serialize the resulting ToolDefinition to JSON, and assert the "defer" key is absent from the output.

Constraints

  • ✅✅ YOU MUST run mvn spotless:apply before every commit.

  • Do NOT implement a reflection fallback. Throw on missing generated class.

  • Do NOT modify any files outside the java/ directory.

  • The fromObject() method must be annotated with @CopilotExperimental.

  • Follow existing code style (4-space indent, Javadoc on public APIs).

  • Do NOT expose ObjectMapper as a public API return type. The mapper is an internal implementation detail passed through the definitions() contract.

Metadata

Metadata

Labels

No labels
No labels

Type

No fields configured for Task.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions