|
| 1 | +# ADR-005: Ergonomic tool definition API — annotation-on-method approach |
| 2 | + |
| 3 | +## Context and Problem Statement |
| 4 | + |
| 5 | +The Java SDK's current tool definition API requires developers to manually provide every piece of tool metadata: name, description, JSON Schema (as a `Map<String, Object>`), and a handler lambda. This results in highly verbose, error-prone code: |
| 6 | + |
| 7 | +```java |
| 8 | +ToolDefinition.create("set_current_phase", |
| 9 | + "Sets the current phase of the agent. Use this to report progress.", |
| 10 | + Map.of("type", "object", |
| 11 | + "properties", Map.of("phase", Map.of("type", "string", "enum", |
| 12 | + List.of("searching", "analyzing", "done"))), |
| 13 | + "required", List.of("phase")), |
| 14 | + invocation -> { |
| 15 | + Phase phase = invocation.getArgumentsAs(PhaseArgs.class).phase(); |
| 16 | + this.phase = phase; |
| 17 | + updateUi(); |
| 18 | + return CompletableFuture.completedFuture("Phase set to " + phase); |
| 19 | + }) |
| 20 | +``` |
| 21 | + |
| 22 | +Compare this with the C# SDK where reflection on `[DisplayName]`, `[Description]`, and method parameters auto-generates everything: |
| 23 | + |
| 24 | +```csharp |
| 25 | +CopilotTool.DefineTool(SetCurrentPhase) |
| 26 | +``` |
| 27 | + |
| 28 | +Or with Go, where generics derive the schema from the input type: |
| 29 | + |
| 30 | +```go |
| 31 | +DefineTool[PhaseArgs, string]("set_current_phase", "Sets phase", handler) |
| 32 | +``` |
| 33 | + |
| 34 | +The Java SDK needs a higher-level API that is idiomatic Java while dramatically reducing boilerplate. |
| 35 | + |
| 36 | +## Considered Options |
| 37 | + |
| 38 | +### Option 1: Current API (status quo) |
| 39 | + |
| 40 | +Explicit `ToolDefinition.create(name, description, schema, handler)` with a hand-written `Map<String, Object>` JSON Schema and a `ToolHandler` lambda. |
| 41 | + |
| 42 | +**Advantages:** |
| 43 | +- No reflection or annotation processing at runtime. |
| 44 | +- Full explicit control over every aspect of the tool spec. |
| 45 | + |
| 46 | +**Drawbacks:** |
| 47 | +- Extremely verbose — a single tool definition can span 10+ lines. |
| 48 | +- Error-prone — typos in schema keys (`"tpye"` instead of `"type"`) produce runtime failures, not compile-time errors. |
| 49 | +- No type safety on arguments — developers must call `invocation.getArgumentsAs(T.class)` manually inside the handler. |
| 50 | +- Inconsistent with every other SDK in the mono-repo, all of which offer a higher-level path. |
| 51 | + |
| 52 | +### Option 2: Record-as-schema with generic factory |
| 53 | + |
| 54 | +Define a record for the tool's arguments, annotate its components with `@Param`, and use a generic factory method to auto-generate the schema from the record's `RecordComponent[]` metadata: |
| 55 | + |
| 56 | +```java |
| 57 | +record PhaseArgs(@Param("The phase to transition to") Phase phase) {} |
| 58 | + |
| 59 | +ToolDefinition.define("set_current_phase", |
| 60 | + "Sets the current phase of the agent.", |
| 61 | + PhaseArgs.class, |
| 62 | + (args, invocation) -> { |
| 63 | + this.phase = args.phase(); |
| 64 | + updateUi(); |
| 65 | + return CompletableFuture.completedFuture("Phase set to " + args.phase()); |
| 66 | + }); |
| 67 | +``` |
| 68 | + |
| 69 | +**Advantages:** |
| 70 | +- Schema is auto-generated from the record — no hand-written `Map`. |
| 71 | +- Type-safe handler — the lambda receives the deserialized record directly. |
| 72 | +- Closest analog to Go's `DefineTool[T, U]`. |
| 73 | +- No classpath scanning or special framework plumbing. |
| 74 | + |
| 75 | +**Drawbacks:** |
| 76 | +- Tool name and description are still explicit string arguments. |
| 77 | +- Requires a separate record class for every tool's args (even trivial single-param tools). |
| 78 | +- The handler is still an explicit lambda — the "tool" is not the method itself. |
| 79 | +- Nested or complex schemas (arrays of objects, polymorphic types) need additional mapping logic. |
| 80 | +- No analog in the broader Java ecosystem; Java developers are not accustomed to defining a record per function call. |
| 81 | + |
| 82 | +### Option 3: Annotation-on-method (langchain4j-style) |
| 83 | + |
| 84 | +Annotate existing Java methods with `@Tool` (or a Copilot-specific equivalent) and annotate parameters with `@P`/`@Param`. The framework discovers tools by scanning methods on a given object, auto-generates `ToolSpecification` / `ToolDefinition` from the method signature, and dispatches invocations directly to the annotated method. |
| 85 | + |
| 86 | +```java |
| 87 | +class MyTools { |
| 88 | + |
| 89 | + @CopilotTool("Sets the current phase of the agent. Use this to report progress.") |
| 90 | + String setCurrentPhase(@Param("The phase to transition to") Phase phase) { |
| 91 | + this.phase = phase; |
| 92 | + updateUi(); |
| 93 | + return "Phase set to " + phase; |
| 94 | + } |
| 95 | + |
| 96 | + @CopilotTool(name = "report_intent", value = "Reports the agent's intent", |
| 97 | + overridesBuiltInTool = true) |
| 98 | + String reportIntent(@Param("The intent") String intent) { |
| 99 | + // ... |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +// Registration: |
| 104 | +var tools = ToolDefinition.fromObject(myToolsInstance); |
| 105 | +// → List<ToolDefinition> with schema, description, and handler wired automatically. |
| 106 | +``` |
| 107 | + |
| 108 | +This is the approach used by [langchain4j](https://github.com/langchain4j/langchain4j) (see [High Level Tool API](https://github.com/langchain4j/langchain4j/blob/main/docs/docs/tutorials/tools.md#high-level-tool-api)), which is the most widely adopted Java AI framework. |
| 109 | + |
| 110 | +**What the framework does automatically:** |
| 111 | +1. **Name** — derived from `@CopilotTool(name=...)` or the method name (converted to snake_case). |
| 112 | +2. **Description** — from `@CopilotTool("...")` or `@CopilotTool(value="...")`. |
| 113 | +3. **Parameter schema** — generated by reflecting on method parameters: types map to JSON Schema types; `@Param` provides descriptions; `Optional<T>` or `@Param(required=false)` marks optional params. |
| 114 | +4. **Handler** — the method itself. The framework deserializes JSON arguments into the method's parameter types and invokes the method reflectively. The return value is serialized back to a string result. |
| 115 | + |
| 116 | +**Advantages:** |
| 117 | +- **Minimal boilerplate** — a tool is just an annotated method. No records, no lambdas, no schema maps. |
| 118 | +- **Idiomatic Java** — this pattern is familiar from JAX-RS (`@Path`/`@GET`), Spring MVC (`@RequestMapping`), and CDI (`@Inject`). Java developers are accustomed to annotation-driven frameworks. |
| 119 | +- **The method IS the handler** — no separation between "tool definition" and "tool implementation". Everything is co-located. |
| 120 | +- **Proven at scale** — langchain4j has validated this design across thousands of production deployments. |
| 121 | +- **Inheritance and discovery** — tools can be inherited from superclasses, composed from multiple objects, and discovered dynamically. |
| 122 | +- **Ecosystem alignment** — closest to what C#'s `CopilotTool.DefineTool(MethodGroup)` achieves via reflection, adapted to Java idioms. |
| 123 | +- **Parameter-level type safety** — each parameter is a method argument with its own Java type. No single "args" record needed. |
| 124 | + |
| 125 | +**Drawbacks:** |
| 126 | +- Requires runtime reflection for method invocation and schema generation. |
| 127 | +- One-time scanning cost at registration time (negligible for typical tool counts). |
| 128 | +- Return type handling needs a policy: `String` → sent as-is; `void` → "Success"; other types → JSON-serialized. |
| 129 | +- Async story: methods could return `CompletableFuture<T>` for async tools, or the framework could invoke synchronous methods on a configurable executor. |
| 130 | +- New annotation(s) added to the public API surface (`@CopilotTool`, `@Param`). |
| 131 | +- Requires `-parameters` javac flag for parameter name preservation (or explicit `@Param(name=...)` — same constraint as langchain4j). |
| 132 | + |
| 133 | +## Decision Outcome |
| 134 | + |
| 135 | +**Chosen: Option 3 — Annotation-on-method (langchain4j-style).** |
| 136 | + |
| 137 | +### Rationale |
| 138 | + |
| 139 | +1. **Java developers expect annotation-driven APIs.** Every major Java framework (Spring, Jakarta EE, Quarkus, Micronaut, langchain4j) uses annotations on methods/parameters as the primary developer-facing abstraction. This is idiomatic Java; records-as-schema is not. |
| 140 | + |
| 141 | +2. **Minimum viable tool is one annotated method.** With Option 3, the absolute minimum code to define a tool is: |
| 142 | + ```java |
| 143 | + @CopilotTool("Gets the weather") |
| 144 | + String getWeather(@Param("City") String city) { return weatherApi.get(city); } |
| 145 | + ``` |
| 146 | + With Option 2, you need a record class *and* a lambda. With Option 1, you need a record class, a Map schema, *and* a lambda. |
| 147 | + |
| 148 | +3. **The method IS the tool.** Co-locating metadata (name, description, parameter descriptions) with implementation eliminates drift between the spec and the code. When someone adds a parameter, the schema updates automatically. |
| 149 | + |
| 150 | +4. **Proven design.** langchain4j's `@Tool` / `@P` design has been adopted by thousands of Java projects and validated against real LLM providers. We can learn from their design decisions (handling of `Optional`, `void` returns, `@Description` on nested types, inheritance rules) rather than inventing from scratch. |
| 151 | + |
| 152 | +5. **Closes the ergonomics gap with C# and Go.** The C# SDK's `CopilotTool.DefineTool(SetCurrentPhase)` achieves one-line tool definition via reflection. Option 3 is the Java equivalent — the annotation-on-method pattern is Java's analog to C#'s attribute-on-method + method-group-to-delegate pattern. |
| 153 | + |
| 154 | +6. **Option 1 remains available as the low-level API.** Users who need full control (dynamic tools, computed schemas, tools from external config) can still use `ToolDefinition.create(...)`. Option 3 is a higher-level convenience that delegates to Option 1 under the hood — the same two-level architecture langchain4j uses (Low Level Tool API vs High Level Tool API). |
| 155 | + |
| 156 | +## Implementation: JSR 269 annotation processor for compile-time metadata generation |
| 157 | + |
| 158 | +A key improvement over langchain4j's pure-runtime-reflection approach: we will use a **JSR 269 annotation processor** (the same mechanism used for `@CopilotExperimental`) to generate tool metadata at compile time. This eliminates the `-parameters` javac flag requirement entirely. |
| 159 | + |
| 160 | +### Why this works |
| 161 | + |
| 162 | +`javax.lang.model.element.VariableElement.getSimpleName()` always returns the real parameter name at compile time, regardless of whether `-parameters` is passed to `javac`. The `-parameters` flag only controls whether those names survive into `.class` bytecode for runtime reflection. An annotation processor sees the source-level names unconditionally. |
| 163 | + |
| 164 | +### How it works |
| 165 | + |
| 166 | +The processor runs at compile time, finds all `@CopilotTool`-annotated methods, and generates a companion metadata class per tool-bearing class: |
| 167 | + |
| 168 | +```java |
| 169 | +// GENERATED — do not edit |
| 170 | +final class MyTools$$CopilotToolMeta { |
| 171 | + static List<ToolDefinition> definitions(MyTools instance) { |
| 172 | + return List.of( |
| 173 | + new ToolDefinition("set_current_phase", |
| 174 | + "Sets the current phase of the agent.", |
| 175 | + Map.of("type", "object", |
| 176 | + "properties", Map.of("phase", Map.of("type", "string", |
| 177 | + "description", "The phase to transition to")), |
| 178 | + "required", List.of("phase")), |
| 179 | + invocation -> { |
| 180 | + Phase phase = invocation.getArgumentsAs(Phase.class); |
| 181 | + return CompletableFuture.completedFuture( |
| 182 | + instance.setCurrentPhase(phase)); |
| 183 | + }, null, null, null) |
| 184 | + ); |
| 185 | + } |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +At runtime, `ToolDefinition.fromObject(myTools)` loads the generated `$$CopilotToolMeta` class — zero reflection, zero dependency on `-parameters`. |
| 190 | + |
| 191 | +### Compile-time validation |
| 192 | + |
| 193 | +Because the processor has full access to the source AST, it can emit compile errors for: |
| 194 | +- Missing `@Param` on parameters (when descriptions are required by policy). |
| 195 | +- Unsupported parameter types (types without a clear JSON Schema mapping). |
| 196 | +- Duplicate tool names within the same class hierarchy. |
| 197 | +- Invalid annotation combinations (e.g., `overridesBuiltInTool` on a tool with `skipPermission`). |
| 198 | + |
| 199 | +### Precedent |
| 200 | + |
| 201 | +| Framework | Approach | |
| 202 | +|-----------|----------| |
| 203 | +| **Micronaut** | Annotation processor generates all DI metadata at compile time — no runtime reflection, no `-parameters` needed | |
| 204 | +| **Dagger 2** | Processor generates `_Factory` / `_MembersInjector` classes | |
| 205 | +| **MapStruct** | Processor generates mapper implementations from interface method signatures | |
| 206 | +| **Our own `@CopilotExperimental`** | Processor walks declared elements via JSR 269 (see ADR-004) | |
| 207 | + |
| 208 | +### Comparison: annotation processor vs. runtime reflection |
| 209 | + |
| 210 | +| | Annotation processor (our approach) | Runtime reflection (langchain4j default) | |
| 211 | +|---|---|---| |
| 212 | +| Requires `-parameters`? | **No** | Yes (or `@P(name=...)`) | |
| 213 | +| GraalVM native-image friendly? | **Yes** | Needs reflection config | |
| 214 | +| Compile-time error checking? | **Yes** | Fails at runtime | |
| 215 | +| Extra generated source files? | Yes | None | |
| 216 | +| Works without running the processor? | No — but fails loudly at compile time | Yes (degraded) | |
| 217 | + |
| 218 | +## Consequences |
| 219 | + |
| 220 | +- New public annotations: `@CopilotTool` and `@Param` (in `com.github.copilot.rpc` or a new `com.github.copilot.tool` package). |
| 221 | +- New JSR 269 annotation processor that generates `$$CopilotToolMeta` companion classes at compile time. |
| 222 | +- New utility: `ToolDefinition.fromObject(Object)` / `ToolDefinition.fromClass(Class<?>)` that loads the generated metadata class (falling back to runtime reflection if the processor was not run). |
| 223 | +- The existing `ToolDefinition.create(...)` / `ToolDefinition.createOverride(...)` APIs remain unchanged — they become the "low-level" path. |
| 224 | +- No `-parameters` javac flag requirement for users who run the annotation processor (which happens automatically when the SDK is on the compile classpath). |
| 225 | +- Async support: methods returning `CompletableFuture<T>` are handled natively; synchronous methods are wrapped in `CompletableFuture.completedFuture(...)` (or dispatched to an executor, TBD). |
| 226 | +- GraalVM native-image compatibility without additional reflection configuration. |
| 227 | +- **Experimental designation:** `@CopilotTool`, `@Param`, `ToolDefinition.fromObject(Object)`, and `ToolDefinition.fromClass(Class<?>)` will all be annotated with `@CopilotExperimental`. This gates adoption behind an explicit opt-in (`-Acopilot.experimental.allowed=true`) until the API surface stabilizes, consistent with the policy established in ADR-004. |
| 228 | + |
| 229 | +## Related work items |
| 230 | + |
| 231 | +- https://github.com/github/copilot-sdk/issues/1682 |
| 232 | +- langchain4j reference: https://github.com/langchain4j/langchain4j/blob/main/docs/docs/tutorials/tools.md#high-level-tool-api |
| 233 | +- langchain4j `@Tool` source: https://github.com/langchain4j/langchain4j/blob/main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java |
| 234 | +- langchain4j `@P` source: https://github.com/langchain4j/langchain4j/blob/main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java |
| 235 | +- langchain4j `ToolSpecifications` (schema generation from methods): https://github.com/langchain4j/langchain4j/blob/main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java |
0 commit comments