Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ When you define tools with `@CopilotTool`, parameters of type `ToolInvocation` a
```java
import com.github.copilot.rpc.ToolInvocation;
import com.github.copilot.tool.CopilotTool;
import com.github.copilot.tool.Param;
import com.github.copilot.tool.CopilotToolParam;

class ProgressTools {
@CopilotTool("Reports the current phase and session")
public String reportProgress(
@Param("Current phase") String phase,
@CopilotToolParam("Current phase") String phase,
ToolInvocation invocation) {
return "phase=" + phase + ", sessionId=" + invocation.getSessionId();
}
Expand All @@ -156,13 +156,13 @@ Position examples:

```java
@CopilotTool("Invocation first")
public String report(ToolInvocation invocation, @Param("Phase") String phase) { ... }
public String report(ToolInvocation invocation, @CopilotToolParam("Phase") String phase) { ... }

@CopilotTool("Invocation only")
public String onlyContext(ToolInvocation invocation) { ... }

@CopilotTool("Invocation middle")
public String report(@Param("Phase") String phase, ToolInvocation invocation, @Param("Limit") int limit) { ... }
public String report(@CopilotToolParam("Phase") String phase, ToolInvocation invocation, @CopilotToolParam("Limit") int limit) { ... }
```

## Memory
Expand Down
25 changes: 13 additions & 12 deletions java/docs/adr/adr-005-tool-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ Explicit `ToolDefinition.create(name, description, schema, handler)` with a hand

### Option 2: Record-as-schema with generic factory

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:
Define a record for the tool's arguments and use a generic factory method to auto-generate the schema from the record's `RecordComponent[]` metadata. Because `@CopilotToolParam` targets `ElementType.PARAMETER` (method parameters only), it cannot be placed on record components; per-field descriptions are not supported in this option:

```java
record PhaseArgs(@Param("The phase to transition to") Phase phase) {}
record PhaseArgs(Phase phase) {}

ToolDefinition.define("set_current_phase",
"Sets the current phase of the agent.",
Expand All @@ -76,26 +76,27 @@ ToolDefinition.define("set_current_phase",
- Tool name and description are still explicit string arguments.
- Requires a separate record class for every tool's args (even trivial single-param tools).
- The handler is still an explicit lambda — the "tool" is not the method itself.
- Per-field descriptions cannot be provided: `@CopilotToolParam` targets method parameters only, not record components.
- Nested or complex schemas (arrays of objects, polymorphic types) need additional mapping logic.
- No analog in the broader Java ecosystem; Java developers are not accustomed to defining a record per function call.

### Option 3: Annotation-on-method (langchain4j-style)

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.
Annotate existing Java methods with `@Tool` (or a Copilot-specific equivalent) and annotate parameters with `@P`/`@CopilotToolParam`. 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.

```java
class MyTools {

@CopilotTool("Sets the current phase of the agent. Use this to report progress.")
String setCurrentPhase(@Param("The phase to transition to") Phase phase) {
String setCurrentPhase(@CopilotToolParam("The phase to transition to") Phase phase) {
this.phase = phase;
updateUi();
return "Phase set to " + phase;
}

@CopilotTool(name = "report_intent", value = "Reports the agent's intent",
overridesBuiltInTool = true)
String reportIntent(@Param("The intent") String intent) {
String reportIntent(@CopilotToolParam("The intent") String intent) {
// ...
}
}
Expand All @@ -110,7 +111,7 @@ This is the approach used by [langchain4j](https://github.com/langchain4j/langch
**What the framework does automatically:**
1. **Name** — derived from `@CopilotTool(name=...)` or the method name (converted to snake_case).
2. **Description** — from `@CopilotTool("...")` or `@CopilotTool(value="...")`.
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.
3. **Parameter schema** — generated by reflecting on method parameters: types map to JSON Schema types; `@CopilotToolParam` provides descriptions; `Optional<T>` or `@CopilotToolParam(required=false)` marks optional params.
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.

**Advantages:**
Expand All @@ -127,8 +128,8 @@ This is the approach used by [langchain4j](https://github.com/langchain4j/langch
- One-time scanning cost at registration time (negligible for typical tool counts).
- Return type handling needs a policy: `String` → sent as-is; `void` → "Success"; other types → JSON-serialized.
- Async story: methods could return `CompletableFuture<T>` for async tools, or the framework could invoke synchronous methods on a configurable executor.
- New annotation(s) added to the public API surface (`@CopilotTool`, `@Param`).
- Requires `-parameters` javac flag for parameter name preservation (or explicit `@Param(name=...)` — same constraint as langchain4j).
- New annotation(s) added to the public API surface (`@CopilotTool`, `@CopilotToolParam`).
- Requires `-parameters` javac flag for parameter name preservation (or explicit `@CopilotToolParam(name=...)` — same constraint as langchain4j).

## Decision Outcome

Expand All @@ -141,7 +142,7 @@ This is the approach used by [langchain4j](https://github.com/langchain4j/langch
2. **Minimum viable tool is one annotated method.** With Option 3, the absolute minimum code to define a tool is:
```java
@CopilotTool("Gets the weather")
String getWeather(@Param("City") String city) { return weatherApi.get(city); }
String getWeather(@CopilotToolParam("City") String city) { return weatherApi.get(city); }
```
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.

Expand Down Expand Up @@ -191,7 +192,7 @@ At runtime, `ToolDefinition.fromObject(myTools)` loads the generated `$$CopilotT
### Compile-time validation

Because the processor has full access to the source AST, it can emit compile errors for:
- Missing `@Param` on parameters (when descriptions are required by policy).
- Missing `@CopilotToolParam` on parameters (when descriptions are required by policy).
- Unsupported parameter types (types without a clear JSON Schema mapping).
- Duplicate tool names within the same class hierarchy.
- Invalid annotation combinations (e.g., `overridesBuiltInTool` on a tool with `skipPermission`).
Expand All @@ -217,14 +218,14 @@ Because the processor has full access to the source AST, it can emit compile err

## Consequences

- New public annotations: `@CopilotTool` and `@Param` (in `com.github.copilot.rpc` or a new `com.github.copilot.tool` package).
- New public annotations: `@CopilotTool` and `@CopilotToolParam` (in `com.github.copilot.rpc` or a new `com.github.copilot.tool` package).
- New JSR 269 annotation processor that generates `$$CopilotToolMeta` companion classes at compile time.
- 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).
- The existing `ToolDefinition.create(...)` / `ToolDefinition.createOverride(...)` APIs remain unchanged — they become the "low-level" path.
- No `-parameters` javac flag requirement for users who run the annotation processor (which happens automatically when the SDK is on the compile classpath).
- Async support: methods returning `CompletableFuture<T>` are handled natively; synchronous methods are wrapped in `CompletableFuture.completedFuture(...)` (or dispatched to an executor, TBD).
- GraalVM native-image compatibility without additional reflection configuration.
- **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.
- **Experimental designation:** `@CopilotTool`, `@CopilotToolParam`, `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.

## Related work items

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
*
* <pre>
* &#64;CopilotTool("Get weather for a location")
* public CompletableFuture&lt;String&gt; getWeather(&#64;Param(value = "City name", required = true) String location) {
* public CompletableFuture&lt;String&gt; getWeather(
* &#64;CopilotToolParam(value = "City name", required = true) String location) {
* return CompletableFuture.completedFuture("Sunny in " + location);
* }
* </pre>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
*
* <pre>
* &#64;CopilotTool("Search for issues")
* public CompletableFuture&lt;String&gt; searchIssues(&#64;Param(value = "Search query", required = true) String query,
* &#64;Param(value = "Max results", required = false, defaultValue = "10") int limit) {
* public CompletableFuture&lt;String&gt; searchIssues(
* &#64;CopilotToolParam(value = "Search query", required = true) String query,
* &#64;CopilotToolParam(value = "Max results", required = false, defaultValue = "10") int limit) {
* // ...
* }
* </pre>
Expand All @@ -33,7 +34,7 @@
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@CopilotExperimental
public @interface Param {
public @interface CopilotToolParam {

/** Parameter description (sent to the model). */
String value() default "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,23 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
continue;
}

// Validate @Param conflicts
// Validate @CopilotToolParam conflicts
int toolInvocationParamCount = 0;
for (VariableElement param : method.getParameters()) {
if (isToolInvocationType(param.asType())) {
toolInvocationParamCount++;
if (param.getAnnotation(Param.class) != null) {
if (param.getAnnotation(CopilotToolParam.class) != null) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@Param is not supported on ToolInvocation parameters because ToolInvocation is injected runtime context and not part of the tool schema",
"@CopilotToolParam is not supported on ToolInvocation parameters because ToolInvocation is injected runtime context and not part of the tool schema",
param);
}
continue;
}
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);
if (paramAnnotation != null && paramAnnotation.required()
&& !paramAnnotation.defaultValue().isEmpty()) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@Param cannot have both required=true and a non-empty defaultValue", param);
"@CopilotToolParam cannot have both required=true and a non-empty defaultValue", param);
}
if (paramAnnotation != null && !paramAnnotation.defaultValue().isEmpty()) {
String defaultValidationError = validateDefaultValueCompatibility(param.asType(),
Expand All @@ -96,7 +96,7 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
if (paramAnnotation != null && !paramAnnotation.required() && paramAnnotation.defaultValue().isEmpty()
&& param.asType().getKind().isPrimitive()) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@Param(required=false) primitive parameters must provide defaultValue or use a boxed/Optional type",
"@CopilotToolParam(required=false) primitive parameters must provide defaultValue or use a boxed/Optional type",
param);
}
}
Expand All @@ -111,17 +111,17 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
if (schemaParameters.size() == 1) {
VariableElement singleParam = schemaParameters.get(0);
if (isRecord(singleParam.asType())) {
Param paramAnnotation = singleParam.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = singleParam.getAnnotation(CopilotToolParam.class);
if (paramAnnotation != null) {
if (!paramAnnotation.defaultValue().isEmpty()) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@Param(defaultValue=...) is not supported on single-record tool parameters; use record component defaults or a non-record parameter",
"@CopilotToolParam(defaultValue=...) is not supported on single-record tool parameters; use record component defaults or a non-record parameter",
singleParam);
}
if (!paramAnnotation.name().isEmpty() || !paramAnnotation.value().isEmpty()
|| !paramAnnotation.required()) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"@Param name/value/required are not supported on single-record tool parameters; annotate record components instead",
"@CopilotToolParam name/value/required are not supported on single-record tool parameters; annotate record components instead",
singleParam);
}
}
Expand Down Expand Up @@ -235,7 +235,7 @@ private void writeMetaClass(PrintWriter out, String packageName, String simpleCl
private boolean needsWithMetaHelper(List<ExecutableElement> methods) {
for (ExecutableElement method : methods) {
for (VariableElement param : method.getParameters()) {
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);
if (paramAnnotation != null
&& (!paramAnnotation.value().isEmpty() || !paramAnnotation.defaultValue().isEmpty())) {
return true;
Expand All @@ -255,7 +255,8 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) {
boolean skipPermission = annotation.skipPermission();
com.github.copilot.rpc.ToolDefer defer = annotation.defer();

// Generate schema with @Param metadata (descriptions, names, defaults)
// Generate schema with @CopilotToolParam metadata (descriptions, names,
// defaults)
String schemaSource = generateSchemaWithParamMetadata(method.getParameters());

// Generate invocation lambda
Expand Down Expand Up @@ -296,7 +297,7 @@ private String generateSchemaWithParamMetadata(List<? extends VariableElement> p
for (VariableElement param : schemaParameters) {
String paramName = getParamName(param);
TypeMirror paramType = param.asType();
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);

// Generate the type schema for this parameter
String typeSchema = schemaGenerator.generateSchemaSource(paramType, processingEnv.getTypeUtils(),
Expand Down Expand Up @@ -338,7 +339,7 @@ private boolean isToolInvocationType(TypeMirror type) {
return TOOL_INVOCATION_TYPE.equals(processingEnv.getTypeUtils().erasure(type).toString());
}

private String buildPropertySchema(String typeSchema, Param paramAnnotation, TypeMirror paramType) {
private String buildPropertySchema(String typeSchema, CopilotToolParam paramAnnotation, TypeMirror paramType) {
if (paramAnnotation == null) {
return typeSchema;
}
Expand Down Expand Up @@ -382,7 +383,7 @@ private String generateLambdaBody(ExecutableElement method) {
TypeMirror paramType = param.asType();

// Handle default values
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);
boolean hasDefault = paramAnnotation != null && !paramAnnotation.defaultValue().isEmpty();

if (hasDefault) {
Expand Down Expand Up @@ -695,7 +696,7 @@ private String validatePrimitiveDefault(TypeKind kind, String defaultValue) {
return null;
}
} catch (NumberFormatException ex) {
return "@Param defaultValue '" + defaultValue + "' is not valid for " + kind.name().toLowerCase()
return "@CopilotToolParam defaultValue '" + defaultValue + "' is not valid for " + kind.name().toLowerCase()
+ " parameters";
}
}
Expand All @@ -704,13 +705,13 @@ private String validateBooleanDefault(String defaultValue) {
if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
return null;
}
return "@Param defaultValue '" + defaultValue + "' is not valid for boolean parameters";
return "@CopilotToolParam defaultValue '" + defaultValue + "' is not valid for boolean parameters";
}

private String validateCharacterDefault(String defaultValue) {
return defaultValue != null && defaultValue.length() == 1
? null
: "@Param defaultValue '" + defaultValue + "' is not valid for char parameters";
: "@CopilotToolParam defaultValue '" + defaultValue + "' is not valid for char parameters";
}

private TypeKind boxedTypeKind(String qualifiedName) {
Expand All @@ -733,7 +734,7 @@ private TypeKind boxedTypeKind(String qualifiedName) {
}

private String getParamName(VariableElement param) {
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);
if (paramAnnotation != null && !paramAnnotation.name().isEmpty()) {
return paramAnnotation.name();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public String generateParametersSchemaSource(List<? extends VariableElement> par
propertyEntries.add("Map.entry(\"" + paramName + "\", " + schema + ")");

if (!isOptional) {
Param paramAnnotation = param.getAnnotation(Param.class);
CopilotToolParam paramAnnotation = param.getAnnotation(CopilotToolParam.class);
if (paramAnnotation == null || paramAnnotation.required()) {
requiredNames.add("\"" + paramName + "\"");
}
Expand Down
Loading
Loading