Skip to content

Commit dac4fff

Browse files
belaltaher8Copilot
andcommitted
Java: enable tool metadata for @copilotTool + compat constructor
Address Java SME feedback so annotation-based tools reach parity with the programmatic API and existing constructor call sites keep compiling. - ToolDefinition: add a backward-compatible 7-arg constructor delegating to the canonical 8-arg with metadata=null. - CopilotTool: add metadata() plus nested MetadataEntry/MetadataValue/ MetadataFlag annotations (@target({})) with a shallow bool|str|flag-map representation, documenting the programmatic API for richer values. - CopilotToolProcessor: generate the metadata constructor argument (Map.<String, Object>of(...) with an explicit type witness) instead of a hardcoded null; null when no metadata is present. - Tests: processor generation cases (nested flags, absent, combined flags), ToolDefinition 7-arg/.metadata(...) copy/flag-chaining cases, and a fromObject metadata assertion; extend the SimpleTools fixture pair to emit a safeForTelemetry metadata map. - ADR-005: update the generated snippet to the 8-arg shape and document the @copilotTool(metadata = ...) syntax and emitted shape. Note: mvn verify / spotless:apply not run locally (no JDK/Maven in the dev environment); relies on PR CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf2bf0dd-ec9f-42f5-a6e1-4f2a9e562d5f
1 parent addebe1 commit dac4fff

9 files changed

Lines changed: 322 additions & 4 deletions

File tree

java/docs/adr/adr-005-tool-definition.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,45 @@ final class MyTools$$CopilotToolMeta {
181181
Phase phase = invocation.getArgumentsAs(Phase.class);
182182
return CompletableFuture.completedFuture(
183183
instance.setCurrentPhase(phase));
184-
}, null, null, null)
184+
}, null, null, null, null)
185185
);
186186
}
187187
}
188188
```
189189

190+
The trailing constructor arguments are `overridesBuiltInTool`, `skipPermission`, `defer`, and `metadata` — all `null` here because none were set on the annotation.
191+
190192
At runtime, `ToolDefinition.fromObject(myTools)` loads the generated `$$CopilotToolMeta` class — zero reflection, zero dependency on `-parameters`.
191193

194+
### Host-defined metadata
195+
196+
`@CopilotTool` also accepts an opaque `metadata` bag via nested annotations. Because annotation members can't express arbitrary maps, the representation is deliberately shallow: each entry maps a namespaced key to a boolean, a string, or a one-level map of named boolean flags.
197+
198+
```java
199+
@CopilotTool(
200+
value = "Reports phase",
201+
metadata = {
202+
@CopilotTool.MetadataEntry(
203+
key = "github.com/copilot:safeForTelemetry",
204+
value = @CopilotTool.MetadataValue(flags = {
205+
@CopilotTool.MetadataFlag(name = "name", value = true),
206+
@CopilotTool.MetadataFlag(name = "inputsNames", value = false)
207+
}))
208+
})
209+
public String reportPhase(@CopilotToolParam("Phase") String phase) {
210+
return phase;
211+
}
212+
```
213+
214+
The processor emits this as the `metadata` constructor argument:
215+
216+
```java
217+
Map.<String, Object>of("github.com/copilot:safeForTelemetry",
218+
Map.of("name", true, "inputsNames", false))
219+
```
220+
221+
For richer values (numbers, arrays, deeper nesting), use the programmatic `ToolDefinition.createWithMetadata(...)` / `ToolDefinition.metadata(...)` API instead.
222+
192223
### Compile-time validation
193224

194225
Because the processor has full access to the source AST, it can emit compile errors for:

java/src/main/java/com/github/copilot/rpc/ToolDefinition.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,36 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d
8989
@JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer,
9090
@JsonProperty("metadata") Map<String, Object> metadata) {
9191

92+
/**
93+
* Backward-compatible constructor without the {@code metadata} component.
94+
* <p>
95+
* Delegates to the canonical constructor with {@code metadata} set to
96+
* {@code null}. Retained so existing direct constructor call sites (including
97+
* previously-generated {@code $$CopilotToolMeta} classes) keep compiling after
98+
* {@code metadata} was added.
99+
*
100+
* @param name
101+
* the unique name of the tool
102+
* @param description
103+
* a description of what the tool does
104+
* @param parameters
105+
* the JSON Schema for the tool's parameters
106+
* @param handler
107+
* the handler function to execute when invoked
108+
* @param overridesBuiltInTool
109+
* whether this tool overrides a built-in tool; {@code null} for the
110+
* default
111+
* @param skipPermission
112+
* whether the tool may run without a permission check; {@code null}
113+
* for the default
114+
* @param defer
115+
* the deferral mode; {@code null} lets the runtime decide
116+
*/
117+
public ToolDefinition(String name, String description, Object parameters, ToolHandler handler,
118+
Boolean overridesBuiltInTool, Boolean skipPermission, ToolDefer defer) {
119+
this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, null);
120+
}
121+
92122
/**
93123
* Creates a tool definition with a JSON schema for parameters.
94124
* <p>

java/src/main/java/com/github/copilot/tool/CopilotTool.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,79 @@
5050

5151
/** Defer configuration for this tool. */
5252
ToolDefer defer() default ToolDefer.NONE;
53+
54+
/**
55+
* Opaque, host-defined metadata for this tool. Keys are namespaced and not
56+
* part of the stable public API; specific keys may be recognized to inform
57+
* host-specific behavior.
58+
*
59+
* <p>
60+
* Because annotation members cannot express arbitrary maps, this uses a
61+
* deliberately shallow representation: each {@link MetadataEntry} maps a
62+
* string key to a single {@link MetadataValue} that is either a boolean, a
63+
* string, or a one-level map of named boolean {@link MetadataFlag flags}.
64+
* Numbers, arrays, and deeper nesting are not supported here; use the
65+
* programmatic {@code ToolDefinition.createWithMetadata(...)} /
66+
* {@code ToolDefinition.metadata(...)} API for richer values.
67+
*
68+
* <p>
69+
* Example emitted shape:
70+
*
71+
* <pre>
72+
* Map.of("github.com/copilot:safeForTelemetry",
73+
* Map.of("name", true, "inputsNames", false))
74+
* </pre>
75+
*/
76+
MetadataEntry[] metadata() default {};
77+
78+
/**
79+
* A single metadata key/value pair. Used only as a member value of
80+
* {@link CopilotTool#metadata()}.
81+
*/
82+
@Documented
83+
@Retention(RetentionPolicy.RUNTIME)
84+
@Target({})
85+
@interface MetadataEntry {
86+
87+
/** The namespaced metadata key. */
88+
String key();
89+
90+
/** The value associated with {@link #key()}. */
91+
MetadataValue value();
92+
}
93+
94+
/**
95+
* A metadata value. Exactly one representation is intended per value: a map
96+
* of named boolean {@link #flags()} (when non-empty), otherwise a
97+
* {@link #str()} (when non-empty), otherwise a {@link #bool()}.
98+
*/
99+
@Documented
100+
@Retention(RetentionPolicy.RUNTIME)
101+
@Target({})
102+
@interface MetadataValue {
103+
104+
/** Scalar boolean value. Used when {@link #flags()} and {@link #str()} are unset. */
105+
boolean bool() default false;
106+
107+
/** Scalar string value. Used when {@link #flags()} is empty and this is non-empty. */
108+
String str() default "";
109+
110+
/** Object-like value: a one-level map of named boolean flags. Takes precedence when non-empty. */
111+
MetadataFlag[] flags() default {};
112+
}
113+
114+
/**
115+
* A single named boolean flag within a {@link MetadataValue#flags()} map.
116+
*/
117+
@Documented
118+
@Retention(RetentionPolicy.RUNTIME)
119+
@Target({})
120+
@interface MetadataFlag {
121+
122+
/** The flag name (map key). */
123+
String name();
124+
125+
/** The flag value. */
126+
boolean value();
127+
}
53128
}

java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,47 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) {
277277
out.println(" " + overridesArg + ",");
278278
out.println(" " + skipPermArg + ",");
279279
out.println(" " + deferArg + ",");
280-
out.println(" null");
280+
out.println(" " + metadataSource(annotation));
281281
out.print(" )");
282282
}
283283

284+
/**
285+
* Converts the {@code @CopilotTool(metadata = ...)} entries into a Java source
286+
* literal. Returns {@code "null"} when no metadata is present, otherwise a
287+
* {@code Map.<String, Object>of(...)} expression.
288+
*/
289+
private String metadataSource(CopilotTool annotation) {
290+
CopilotTool.MetadataEntry[] entries = annotation.metadata();
291+
if (entries.length == 0) {
292+
return "null";
293+
}
294+
List<String> parts = new ArrayList<>();
295+
for (CopilotTool.MetadataEntry entry : entries) {
296+
parts.add("\"" + escapeJava(entry.key()) + "\", " + metadataValueSource(entry.value()));
297+
}
298+
return "Map.<String, Object>of(" + String.join(", ", parts) + ")";
299+
}
300+
301+
/**
302+
* Converts a single {@link CopilotTool.MetadataValue} into a Java source
303+
* literal. A non-empty {@code flags} map takes precedence, then a non-empty
304+
* {@code str}, otherwise the {@code bool} scalar.
305+
*/
306+
private String metadataValueSource(CopilotTool.MetadataValue value) {
307+
CopilotTool.MetadataFlag[] flags = value.flags();
308+
if (flags.length > 0) {
309+
List<String> flagParts = new ArrayList<>();
310+
for (CopilotTool.MetadataFlag flag : flags) {
311+
flagParts.add("\"" + escapeJava(flag.name()) + "\", " + flag.value());
312+
}
313+
return "Map.of(" + String.join(", ", flagParts) + ")";
314+
}
315+
if (!value.str().isEmpty()) {
316+
return "\"" + escapeJava(value.str()) + "\"";
317+
}
318+
return String.valueOf(value.bool());
319+
}
320+
284321
private String generateSchemaWithParamMetadata(List<? extends VariableElement> parameters) {
285322
List<? extends VariableElement> schemaParameters = getSchemaParameters(parameters);
286323

java/src/test/java/com/github/copilot/ToolDefinitionTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,50 @@ void testMetadataOmittedWhenNull() throws Exception {
8282

8383
assertFalse(json.has("metadata"));
8484
}
85+
86+
@Test
87+
void testSevenArgConstructorLeavesMetadataNull() throws Exception {
88+
ToolDefinition tool = new ToolDefinition("my_tool", "A tool", schema(),
89+
invocation -> CompletableFuture.completedFuture("ok"), null, null, null);
90+
91+
assertNull(tool.metadata());
92+
93+
ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool));
94+
95+
assertFalse(json.has("metadata"));
96+
}
97+
98+
@Test
99+
void testMetadataCopyMethodSerializes() throws Exception {
100+
Map<String, Object> metadata = Map.of("github.com/copilot:safeForTelemetry",
101+
Map.of("name", true, "inputsNames", false));
102+
ToolDefinition tool = ToolDefinition
103+
.create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok"))
104+
.metadata(metadata);
105+
106+
assertEquals(metadata, tool.metadata());
107+
108+
ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool));
109+
110+
assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry"));
111+
}
112+
113+
@Test
114+
void testChainingFlagsPreservesMetadata() throws Exception {
115+
Map<String, Object> metadata = Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true));
116+
117+
ToolDefinition metadataFirst = ToolDefinition
118+
.create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok"))
119+
.metadata(metadata).overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER);
120+
121+
ToolDefinition flagsFirst = ToolDefinition
122+
.create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok"))
123+
.overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER).metadata(metadata);
124+
125+
assertEquals(metadata, metadataFirst.metadata());
126+
assertEquals(metadata, flagsFirst.metadata());
127+
assertEquals(Boolean.TRUE, flagsFirst.overridesBuiltInTool());
128+
assertEquals(Boolean.TRUE, flagsFirst.skipPermission());
129+
assertEquals(ToolDefer.NEVER, flagsFirst.defer());
130+
}
85131
}

java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ void fromObject_handlerInvocation() throws Exception {
9292
assertEquals("Hello, Alice!", result);
9393
}
9494

95+
@Test
96+
void fromObject_toolMetadata() {
97+
var tools = ToolDefinition.fromObject(new SimpleTools());
98+
99+
var withMetadata = findTool(tools, "greet_user");
100+
assertNotNull(withMetadata);
101+
assertNotNull(withMetadata.metadata());
102+
assertEquals(Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false)),
103+
withMetadata.metadata());
104+
105+
var withoutMetadata = findTool(tools, "add_numbers");
106+
assertNotNull(withoutMetadata);
107+
assertNull(withoutMetadata.metadata());
108+
}
109+
95110
// ── Test 2: Handler return type patterns ────────────────────────────────────
96111

97112
@Test

java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public List<ToolDefinition> definitions(SimpleTools instance, ObjectMapper mappe
3030
Map<String, Object> args = invocation.getArguments();
3131
String name = (String) args.get("name");
3232
return CompletableFuture.completedFuture(instance.greetUser(name));
33-
}, null, null, null, null),
33+
}, null, null, null, Map.<String, Object>of("github.com/copilot:safeForTelemetry",
34+
Map.of("name", true, "inputsNames", false))),
3435
new ToolDefinition("add_numbers", "Adds two numbers together",
3536
Map.of("type", "object", "properties",
3637
Map.ofEntries(

java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
*/
1313
public class SimpleTools {
1414

15-
@CopilotTool("Greets a user by name")
15+
@CopilotTool(value = "Greets a user by name", metadata = {
16+
@CopilotTool.MetadataEntry(key = "github.com/copilot:safeForTelemetry", value = @CopilotTool.MetadataValue(flags = {
17+
@CopilotTool.MetadataFlag(name = "name", value = true),
18+
@CopilotTool.MetadataFlag(name = "inputsNames", value = false) })) })
1619
public String greetUser(@CopilotToolParam(value = "The user's name", required = true) String name) {
1720
return "Hello, " + name + "!";
1821
}

java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,86 @@ public String doSomething(@CopilotToolParam("Input") String input) {
212212
"Expected completedFuture wrapping for String return, got:\n" + generated);
213213
}
214214

215+
@Test
216+
void generatesMetadata_withNestedFlags() {
217+
String source = """
218+
package test;
219+
import com.github.copilot.tool.CopilotTool;
220+
import com.github.copilot.tool.CopilotToolParam;
221+
public class MetaTools {
222+
@CopilotTool(value = "Reports phase", metadata = {
223+
@CopilotTool.MetadataEntry(
224+
key = "github.com/copilot:safeForTelemetry",
225+
value = @CopilotTool.MetadataValue(flags = {
226+
@CopilotTool.MetadataFlag(name = "name", value = true),
227+
@CopilotTool.MetadataFlag(name = "inputsNames", value = false)
228+
}))
229+
})
230+
public String reportPhase(@CopilotToolParam("Phase") String phase) {
231+
return phase;
232+
}
233+
}
234+
""";
235+
236+
CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.MetaTools", source)));
237+
assertNoErrors(result);
238+
String generated = result.getGeneratedSource("test.MetaTools$$CopilotToolMeta");
239+
assertTrue(generated.contains("Map.<String, Object>of(\"github.com/copilot:safeForTelemetry\""),
240+
"Expected typed metadata map, got:\n" + generated);
241+
assertTrue(generated.contains("Map.of(\"name\", true, \"inputsNames\", false)"),
242+
"Expected nested flag map, got:\n" + generated);
243+
}
244+
245+
@Test
246+
void generatesNullMetadata_whenAbsent() {
247+
String source = """
248+
package test;
249+
import com.github.copilot.tool.CopilotTool;
250+
import com.github.copilot.tool.CopilotToolParam;
251+
public class PlainTools {
252+
@CopilotTool("Plain tool")
253+
public String doSomething(@CopilotToolParam("Input") String input) {
254+
return input;
255+
}
256+
}
257+
""";
258+
259+
CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.PlainTools", source)));
260+
assertNoErrors(result);
261+
String generated = result.getGeneratedSource("test.PlainTools$$CopilotToolMeta");
262+
assertFalse(generated.contains("Map.<String, Object>of("),
263+
"Expected no metadata map for a tool without metadata, got:\n" + generated);
264+
}
265+
266+
@Test
267+
void generatesMetadata_alongsideOtherFlags() {
268+
String source = """
269+
package test;
270+
import com.github.copilot.tool.CopilotTool;
271+
import com.github.copilot.rpc.ToolDefer;
272+
import com.github.copilot.tool.CopilotToolParam;
273+
public class ComboTools {
274+
@CopilotTool(value = "Combo", name = "combo", overridesBuiltInTool = true,
275+
skipPermission = true, defer = ToolDefer.NEVER,
276+
metadata = {
277+
@CopilotTool.MetadataEntry(key = "k",
278+
value = @CopilotTool.MetadataValue(bool = true))
279+
})
280+
public String doSomething(@CopilotToolParam("Input") String input) {
281+
return input;
282+
}
283+
}
284+
""";
285+
286+
CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ComboTools", source)));
287+
assertNoErrors(result);
288+
String generated = result.getGeneratedSource("test.ComboTools$$CopilotToolMeta");
289+
assertTrue(generated.contains("Boolean.TRUE"), "Expected overrides/skip flags, got:\n" + generated);
290+
assertTrue(generated.contains("ToolDefer.NEVER"), "Expected defer, got:\n" + generated);
291+
assertTrue(generated.contains("Map.<String, Object>of(\"k\", true)"),
292+
"Expected scalar bool metadata, got:\n" + generated);
293+
}
294+
215295
@Test
216296
void generatesCorrectCode_forVoidReturnType() {
217297
String source = """

0 commit comments

Comments
 (0)