Skip to content

Commit f8b2eee

Browse files
committed
Fix and restore bytes
1 parent cf88dcf commit f8b2eee

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)