From 0d575bcde4864162719aa32332ec2ea5d3ae07bd Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Tue, 7 Jul 2026 20:47:25 -0400 Subject: [PATCH 1/5] Add opaque metadata passthrough to SDK tool definitions Add an optional, opaque `metadata` bag to tool definitions across all SDK languages and forward it verbatim over the session.create/resume RPC. This lets hosts attach namespaced metadata to tools without expanding the typed public contract; the runtime may recognize specific keys to inform host-specific behavior. Unknown keys are round-tripped untouched. Languages: nodejs (Tool.metadata + defineTool), python (Tool.metadata + define_tool), go (Tool.Metadata), rust (Tool.metadata + with_metadata), java (ToolDefinition.metadata + createWithMetadata), dotnet (CopilotToolOptions.Metadata + wire ToolDefinition.Metadata). Tests added per language for wire/serialization forwarding and omission when unset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 7 +- dotnet/src/CopilotTool.cs | 20 ++++- dotnet/test/Unit/CopilotToolTests.cs | 28 +++++++ go/client_test.go | 47 +++++++++++ go/types.go | 6 ++ .../github/copilot/rpc/ToolDefinition.java | 82 +++++++++++++++---- .../copilot/tool/CopilotToolProcessor.java | 3 +- .../github/copilot/ToolDefinitionTest.java | 23 ++++++ .../ErgonomicTestTools$$CopilotToolMeta.java | 8 +- .../ArgCoercionTools$$CopilotToolMeta.java | 2 +- .../DateTimeTools$$CopilotToolMeta.java | 2 +- .../DefaultValueTools$$CopilotToolMeta.java | 2 +- ...InvocationAwareTools$$CopilotToolMeta.java | 12 +-- .../MultiReturnTools$$CopilotToolMeta.java | 6 +- .../OptionalParamTools$$CopilotToolMeta.java | 8 +- .../OverrideTools$$CopilotToolMeta.java | 2 +- .../SimpleTools$$CopilotToolMeta.java | 4 +- ...taticInvocationTools$$CopilotToolMeta.java | 2 +- .../StaticTools$$CopilotToolMeta.java | 2 +- nodejs/src/client.ts | 2 + nodejs/src/types.ts | 10 +++ nodejs/test/client.test.ts | 65 +++++++++++++++ python/copilot/client.py | 4 + python/copilot/tools.py | 11 +++ python/test_client.py | 41 ++++++++++ rust/src/types.rs | 42 ++++++++++ 26 files changed, 394 insertions(+), 47 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 94b0921995..69bee94e6a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2705,17 +2705,20 @@ internal record ToolDefinition( JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, bool? SkipPermission = null, - CopilotToolDefer? Defer = null) + CopilotToolDefer? Defer = null, + [property: JsonPropertyName("metadata")] IDictionary? Metadata = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; var defer = function.AdditionalProperties.TryGetValue(CopilotTool.DeferKey, out var deferVal) && deferVal is CopilotToolDefer d ? d : (CopilotToolDefer?)null; + var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary m ? m : null; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null, - defer); + defer, + metadata); } } diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index d6dc0351e4..5aab1da352 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -20,6 +20,9 @@ public static class CopilotTool /// The key used in to carry the tool's deferral mode. internal const string DeferKey = "defer"; + /// The key used in to carry the tool's opaque host-defined metadata. + internal const string MetadataKey = "metadata"; + /// /// Defines a tool for use in a . /// @@ -87,7 +90,7 @@ static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions) static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions) { - if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null)) + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null || toolOptions.Metadata is not null)) { Dictionary additionalProperties = new(StringComparer.Ordinal); if (factoryOptions.AdditionalProperties is not null) @@ -113,6 +116,11 @@ static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToo additionalProperties[DeferKey] = defer; } + if (toolOptions.Metadata is { } metadata) + { + additionalProperties[MetadataKey] = metadata; + } + factoryOptions.AdditionalProperties = additionalProperties; } } @@ -151,6 +159,16 @@ public sealed class CopilotToolOptions /// SDK forwards it to the CLI as the tool's defer mode. Defaults to "auto". /// public CopilotToolDefer? Defer { get; set; } + + /// + /// Gets or sets opaque, host-defined metadata associated with the tool definition. + /// + /// + /// Keys are namespaced and not part of the stable public API. When set, the SDK forwards + /// the metadata verbatim to the CLI as the tool's metadata object, which the runtime + /// may recognize to inform host-specific behavior. Unknown keys are preserved. + /// + public IDictionary? Metadata { get; set; } } /// diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index 9c9e2a93ba..900b25d3e3 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -43,6 +43,34 @@ public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() Assert.False(function.AdditionalProperties.ContainsKey("defer")); } + [Fact] + public void DefineTool_Sets_Metadata_In_Additional_Properties() + { + var metadata = new Dictionary + { + ["github.com/copilot:safeForTelemetry"] = new Dictionary + { + ["name"] = true, + ["inputsNames"] = false + } + }; + + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions { Metadata = metadata }); + + Assert.True(function.AdditionalProperties.TryGetValue("metadata", out var value)); + Assert.Same(metadata, value); + } + + [Fact] + public void DefineTool_Omits_Metadata_When_Unset() + { + var function = CopilotTool.DefineTool(ReturnsOk); + + Assert.False(function.AdditionalProperties.ContainsKey("metadata")); + } + [Fact] public void DefineTool_Accepts_Lambda_Handlers_Without_Casts() { diff --git a/go/client_test.go b/go/client_test.go index bd48baacd7..829be492db 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1160,6 +1160,53 @@ func TestToolDefer(t *testing.T) { }) } +func TestToolMetadata(t *testing.T) { + t.Run("Metadata is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "my_tool", + Description: "A custom tool", + Metadata: map[string]any{ + "github.com/copilot:safeForTelemetry": map[string]any{"name": true, "inputsNames": false}, + }, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object, got %v", m) + } + if _, ok := meta["github.com/copilot:safeForTelemetry"]; !ok { + t.Errorf("expected namespaced key preserved, got %v", meta) + } + }) + + t.Run("Metadata omitted when unset", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := m["metadata"]; ok { + t.Errorf("expected metadata to be omitted, got %v", m) + } + }) +} + func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { t.Run("accepts nil config before connection validation", func(t *testing.T) { client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) diff --git a/go/types.go b/go/types.go index 1e424514eb..e400fc637a 100644 --- a/go/types.go +++ b/go/types.go @@ -1269,6 +1269,12 @@ type Tool struct { // Defer controls whether the tool may be deferred (loaded lazily via tool // search) rather than always pre-loaded. When empty, the runtime decides. Defer ToolDefer `json:"defer,omitempty"` + // Metadata is opaque, host-defined metadata associated with the tool + // definition. Keys are namespaced and not part of the stable public API; + // the SDK forwards them verbatim to the runtime, which may recognize + // specific keys to inform host-specific behavior. Unknown keys are + // preserved and round-tripped untouched. + Metadata map[string]any `json:"metadata,omitempty"` // Handler is optional. When nil, the SDK exposes the tool declaration but does // not automatically invoke it. Handler ToolHandler `json:"-"` diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java index 8a336c749a..29579b086c 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -75,6 +75,10 @@ * controls whether the tool may be deferred (loaded lazily via tool * search) rather than always pre-loaded; {@code null} lets the * runtime decide + * @param metadata + * opaque, host-defined metadata forwarded verbatim to the runtime; + * keys are namespaced and not part of the stable public API; + * {@code null} when unset * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -83,7 +87,8 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, - @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer) { + @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer, + @JsonProperty("metadata") Map metadata) { /** * Creates a tool definition with a JSON schema for parameters. @@ -103,7 +108,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null, null, null); + return new ToolDefinition(name, description, schema, handler, null, null, null, null); } /** @@ -127,7 +132,7 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true, null, null); + return new ToolDefinition(name, description, schema, handler, true, null, null, null); } /** @@ -150,7 +155,7 @@ public static ToolDefinition createOverride(String name, String description, Map */ public static ToolDefinition createSkipPermission(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null, true, null); + return new ToolDefinition(name, description, schema, handler, null, true, null, null); } /** @@ -176,7 +181,32 @@ public static ToolDefinition createSkipPermission(String name, String descriptio */ public static ToolDefinition createWithDefer(String name, String description, Map schema, ToolHandler handler, ToolDefer defer) { - return new ToolDefinition(name, description, schema, handler, null, null, defer); + return new ToolDefinition(name, description, schema, handler, null, null, defer, null); + } + + /** + * Creates a tool definition with opaque, host-defined metadata. + *

+ * Use this factory method to attach namespaced metadata that the SDK forwards + * verbatim to the runtime. The keys are not part of the stable public API; the + * runtime may recognize specific keys to inform host-specific behavior. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @param metadata + * the opaque metadata map forwarded to the runtime + * @return a new tool definition with the metadata set + * @since 1.0.7 + */ + public static ToolDefinition createWithMetadata(String name, String description, Map schema, + ToolHandler handler, Map metadata) { + return new ToolDefinition(name, description, schema, handler, null, null, null, metadata); } /** @@ -247,7 +277,7 @@ public static List fromClass(Class clazz) { */ @CopilotExperimental public ToolDefinition overridesBuiltInTool(boolean value) { - return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer); + return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer, metadata); } /** @@ -261,7 +291,7 @@ public ToolDefinition overridesBuiltInTool(boolean value) { */ @CopilotExperimental public ToolDefinition skipPermission(boolean value) { - return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer); + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer, metadata); } /** @@ -275,7 +305,23 @@ public ToolDefinition skipPermission(boolean value) { */ @CopilotExperimental public ToolDefinition defer(ToolDefer value) { - return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, value); + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, value, + metadata); + } + + /** + * Returns a copy with the opaque {@code metadata} bag set. + * + * @param value + * the opaque, host-defined metadata forwarded verbatim to the + * runtime; keys are namespaced and not part of the stable public API + * @return a new {@code ToolDefinition} with the metadata applied + * @since 1.0.7 + */ + @CopilotExperimental + public ToolDefinition metadata(Map value) { + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, + value); } // ------------------------------------------------------------------ @@ -319,7 +365,7 @@ public static ToolDefinition from(String name, String description, Supplier< R result = handler.get(); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -361,7 +407,7 @@ public static ToolDefinition from(String name, String description, Param R result = handler.apply(arg1); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -409,7 +455,7 @@ public static ToolDefinition from(String name, String description, P R result = handler.apply(arg1, arg2); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -458,7 +504,7 @@ public static ToolDefinition fromAsync(String name, String description, } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -506,7 +552,7 @@ public static ToolDefinition fromAsync(String name, String description, } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -551,7 +597,7 @@ public static ToolDefinition fromAsync(String name, String descripti } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -594,7 +640,7 @@ public static ToolDefinition fromWithToolInvocation(String name, String desc R result = handler.apply(invocation); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -640,7 +686,7 @@ public static ToolDefinition fromWithToolInvocation(String name, String R result = handler.apply(arg1, invocation); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -689,7 +735,7 @@ public static ToolDefinition fromAsyncWithToolInvocation(String name, String } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -741,7 +787,7 @@ public static ToolDefinition fromAsyncWithToolInvocation(String name, St } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 9bf4e99c03..af75e97685 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -276,7 +276,8 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { out.println(" },"); out.println(" " + overridesArg + ","); out.println(" " + skipPermArg + ","); - out.println(" " + deferArg); + out.println(" " + deferArg + ","); + out.println(" null"); out.print(" )"); } diff --git a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java index 614e6ab4fa..f244293521 100644 --- a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java +++ b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java @@ -59,4 +59,27 @@ void testDeferNeverIsSerialized() throws Exception { assertEquals("never", json.get("defer").asText()); } + + @Test + void testMetadataIsSerialized() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)); + ToolDefinition tool = ToolDefinition.createWithMetadata("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok"), metadata); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertTrue(json.has("metadata")); + assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry")); + } + + @Test + void testMetadataOmittedWhenNull() throws Exception { + ToolDefinition tool = ToolDefinition.create("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok")); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("metadata")); + } } diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java index 3e6291984f..56b8b281e6 100644 --- a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java @@ -32,7 +32,7 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.setCurrentPhase(phase)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition( "search_items", "Search for items by keyword", Map .of("type", "object", "properties", @@ -44,11 +44,11 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String keyword = (String) args.get("keyword"); return CompletableFuture.completedFuture(instance.searchItems(keyword)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("get_status", "Returns the current status", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return CompletableFuture.completedFuture(instance.getStatus()); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("combine_values", "Combines two values into a single string", Map.of( "type", "object", "properties", Map .ofEntries( @@ -63,6 +63,6 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe String value1 = (String) args.get("value1"); String value2 = (String) args.get("value2"); return CompletableFuture.completedFuture(instance.combineValues(value1, value2)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java index 882c0555f7..5cc5ee87a5 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java @@ -45,6 +45,6 @@ public List definitions(ArgCoercionTools instance, ObjectMapper boolean flag = (Boolean) args.get("flag"); ArgCoercionTools.Color color = ArgCoercionTools.Color.valueOf((String) args.get("color")); return CompletableFuture.completedFuture(instance.mixedArgs(text, count, flag, color)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java index 7001336504..0c2b1f07e7 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java @@ -33,6 +33,6 @@ public List definitions(DateTimeTools instance, ObjectMapper map Map args = invocation.getArguments(); LocalDateTime when = mapper.convertValue(args.get("when"), LocalDateTime.class); return CompletableFuture.completedFuture(instance.scheduleEvent(when)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java index ee5369b849..6cef2e03a0 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java @@ -40,6 +40,6 @@ public List definitions(DefaultValueTools instance, ObjectMapper Object countRaw = args.containsKey("count") ? args.get("count") : 42; int count = ((Number) countRaw).intValue(); return CompletableFuture.completedFuture(instance.withDefault(label, count)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java index cc7150e57c..e7c78608a0 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java @@ -23,7 +23,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.reportProgress(phase, invocation)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_async", "Reports progress asynchronously with invocation context", Map.of("type", "object", "properties", Map.ofEntries( @@ -33,7 +33,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return instance.reportProgressAsync(phase, invocation).thenApply(r -> (Object) r); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_first", "Reports progress with invocation first", Map.of("type", "object", "properties", Map.ofEntries( @@ -43,11 +43,11 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.reportProgressFirst(invocation, phase)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("only_context", "Reports context with invocation only", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> CompletableFuture.completedFuture(instance.onlyContext(invocation)), null, null, - null), + null, null), new ToolDefinition("report_progress_middle", "Reports progress with invocation in the middle", Map.of( "type", "object", "properties", Map.ofEntries(Map.entry("phase", Map.of("type", "string", "description", "Current phase")), @@ -58,7 +58,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap int limit = ((Number) args.get("limit")).intValue(); return CompletableFuture .completedFuture(instance.reportProgressMiddle(phase, invocation, limit)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_with_record", "Reports progress with record args and invocation", Map.of("type", "object", "properties", Map.ofEntries(Map.entry("query", Map.of("type", "string")), @@ -69,6 +69,6 @@ public List definitions(InvocationAwareTools instance, ObjectMap RecordInvocationArgs.class); return CompletableFuture .completedFuture(instance.reportProgressWithRecord(args, invocation)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java index e1ac5c38dd..571db8e7cb 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java @@ -16,14 +16,14 @@ public List definitions(MultiReturnTools instance, ObjectMapper return List.of(new ToolDefinition("string_method", "Returns a string", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return CompletableFuture.completedFuture(instance.stringMethod()); - }, null, null, null), new ToolDefinition("void_method", "Void method", + }, null, null, null, null), new ToolDefinition("void_method", "Void method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { instance.voidMethod(); return CompletableFuture.completedFuture("Success"); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("async_method", "Async method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return instance.asyncMethod().thenApply(r -> (Object) r); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java index df6c39fd66..75fde6bb3c 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java @@ -39,7 +39,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe Object titleRaw = args.get("title"); Optional title = titleRaw != null ? Optional.of((String) titleRaw) : Optional.empty(); return CompletableFuture.completedFuture(instance.greetWithTitle(name, title)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("multiply", "Multiply with optional factor", Map.of("type", "object", "properties", Map.ofEntries( @@ -58,7 +58,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalInt.of(((Number) factorRaw).intValue()) : OptionalInt.empty(); return CompletableFuture.completedFuture(instance.multiply(base, factor)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("scale", "Scale with optional ratio", Map.of("type", "object", "properties", Map.ofEntries( @@ -77,7 +77,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalDouble.of(((Number) ratioRaw).doubleValue()) : OptionalDouble.empty(); return CompletableFuture.completedFuture(instance.scale(value, ratio)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("offset", "Offset with optional delta", Map.of("type", "object", "properties", Map.ofEntries( @@ -96,6 +96,6 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalLong.of(((Number) deltaRaw).longValue()) : OptionalLong.empty(); return CompletableFuture.completedFuture(instance.offset(base, delta)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java index 127dc922b4..2d37204f82 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java @@ -34,6 +34,6 @@ public List definitions(OverrideTools instance, ObjectMapper map Map args = invocation.getArguments(); String pattern = (String) args.get("pattern"); return CompletableFuture.completedFuture(instance.customGrep(pattern)); - }, Boolean.TRUE, null, null)); + }, Boolean.TRUE, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java index 0b52bd1ef2..4e84cb0947 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java @@ -30,7 +30,7 @@ public List definitions(SimpleTools instance, ObjectMapper mappe Map args = invocation.getArguments(); String name = (String) args.get("name"); return CompletableFuture.completedFuture(instance.greetUser(name)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("add_numbers", "Adds two numbers together", Map.of("type", "object", "properties", Map.ofEntries( @@ -46,6 +46,6 @@ public List definitions(SimpleTools instance, ObjectMapper mappe int a = ((Number) args.get("a")).intValue(); int b = ((Number) args.get("b")).intValue(); return CompletableFuture.completedFuture(instance.addNumbers(a, b)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java index c2fd7d7f6c..2535d671e8 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java @@ -24,6 +24,6 @@ public List definitions(StaticInvocationTools instance, ObjectMa Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(StaticInvocationTools.reportStatic(phase, invocation)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java index 842547b68d..a0c6e66855 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java @@ -32,6 +32,6 @@ public List definitions(StaticTools instance, ObjectMapper mappe // Mimics what the processor now generates for static methods: // QualifiedClassName.method(...) instead of instance.method(...) return CompletableFuture.completedFuture(StaticTools.greet(name)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9f430600ca..bd092f47cd 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1409,6 +1409,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + metadata: tool.metadata, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, @@ -1625,6 +1626,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + metadata: tool.metadata, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 97182b5f1c..30c9b6eb4c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -579,6 +579,15 @@ export interface Tool { * Optional; defaults to `"auto"`. */ defer?: "auto" | "never"; + /** + * Opaque, host-defined metadata associated with the tool definition. + * + * Keys are namespaced and are not part of the stable public API. The SDK + * does not interpret these values; it forwards them verbatim to the runtime, + * which may recognize specific namespaced keys to inform host-specific + * behavior. Unknown keys are preserved and round-tripped untouched. + */ + metadata?: Record; } /** @@ -594,6 +603,7 @@ export function defineTool( overridesBuiltInTool?: boolean; skipPermission?: boolean; defer?: "auto" | "never"; + metadata?: Record; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96c32a5951..b30f020dba 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -422,6 +422,71 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("forwards tool metadata verbatim in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const metadata = { + "github.com/copilot:safeForTelemetry": { name: true, inputsNames: false }, + }; + const tool = { + name: "my_tool", + description: "a tool", + parameters: { type: "object", properties: {} }, + metadata, + }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [tool], + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [tool], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.tools[0].metadata).toEqual(metadata); + expect(resumePayload.tools[0].metadata).toEqual(metadata); + }); + + it("omits tool metadata from session.create when unset", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + tools: [{ name: "my_tool", description: "a tool" }], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + expect(createPayload.tools[0].metadata).toBeUndefined(); + }); + it("forwards new session options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 55d01c5b57..28ea93dfa8 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1926,6 +1926,8 @@ async def create_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization @@ -2581,6 +2583,8 @@ async def resume_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization diff --git a/python/copilot/tools.py b/python/copilot/tools.py index a82a48b1e9..6cbec0511f 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -63,6 +63,7 @@ class Tool: overrides_built_in_tool: bool = False skip_permission: bool = False defer: Literal["auto", "never"] | None = None + metadata: dict[str, Any] | None = None T = TypeVar("T", bound=BaseModel) @@ -77,6 +78,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Callable[[Callable[..., Any]], Tool]: pass @@ -91,6 +93,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -105,6 +108,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -118,6 +122,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -166,6 +171,10 @@ def lookup_issue(params: LookupIssueParams) -> str: rather than always pre-loaded. When "auto", the tool can be deferred and surfaced through tool search. When "never", the tool is always pre-loaded. Optional; defaults to "auto". + metadata: Opaque, host-defined metadata associated with the tool definition. + Keys are namespaced and not part of the stable public API; the SDK + forwards them verbatim to the runtime, which may recognize specific + keys to inform host-specific behavior. Unknown keys are preserved. Returns: A Tool instance @@ -246,6 +255,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # If handler is provided, call decorator immediately @@ -265,6 +275,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # Otherwise return decorator for @define_tool(...) usage diff --git a/python/test_client.py b/python/test_client.py index 5e1b8be634..23a3834fe8 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -37,6 +37,7 @@ SessionEvent, SessionEventType, ) +from copilot.tools import Tool from e2e.testharness import CLI_PATH @@ -556,6 +557,46 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_tool_metadata(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + metadata = {"github.com/copilot:safeForTelemetry": {"name": True, "inputsNames": False}} + tool = Tool(name="my_tool", description="a tool", metadata=metadata) + plain_tool = Tool(name="plain_tool", description="a tool") + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + tools=[tool, plain_tool], + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + tools=[tool], + ) + + create_tools = captured["session.create"]["tools"] + assert create_tools[0]["metadata"] == metadata + # Omitted when unset. + assert "metadata" not in create_tools[1] + assert captured["session.resume"]["tools"][0]["metadata"] == metadata + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_new_session_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index e5ba28a56e..fd4f3b7dc6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -351,6 +351,13 @@ pub struct Tool { /// runtime decide. #[serde(default, skip_serializing_if = "Option::is_none")] pub defer: Option, + /// Opaque, host-defined metadata associated with the tool definition. + /// Keys are namespaced and not part of the stable public API; the SDK + /// forwards them verbatim to the runtime, which may recognize specific + /// keys to inform host-specific behavior. Unknown keys are preserved and + /// round-tripped untouched. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, /// Optional runtime implementation. When `Some`, the SDK dispatches /// matching `external_tool.requested` broadcasts to this handler. /// When `None`, the tool is declaration-only. @@ -470,6 +477,14 @@ impl Tool { self } + /// Set opaque, host-defined metadata forwarded verbatim to the runtime. + /// Keys are namespaced and not part of the stable public API. Replaces any + /// previously-set metadata. + pub fn with_metadata(mut self, metadata: HashMap) -> Self { + self.metadata = metadata; + self + } + /// Attach a runtime implementation. The SDK will dispatch matching /// `external_tool.requested` broadcasts to `handler` for this tool's /// name. Without a handler the tool is declaration-only. @@ -498,6 +513,7 @@ impl std::fmt::Debug for Tool { .field("overrides_built_in_tool", &self.overrides_built_in_tool) .field("skip_permission", &self.skip_permission) .field("defer", &self.defer) + .field("metadata", &self.metadata) .field( "handler", &self.handler.as_ref().map(|_| "").unwrap_or("None"), @@ -5209,6 +5225,32 @@ mod tests { assert!(value.get("defer").is_none()); } + #[test] + fn tool_metadata_serialization() { + use std::collections::HashMap; + + let mut metadata = HashMap::new(); + metadata.insert( + "github.com/copilot:safeForTelemetry".to_string(), + json!({ "name": true, "inputsNames": false }), + ); + let tool = Tool::new("lookup").with_metadata(metadata); + let value = serde_json::to_value(&tool).unwrap(); + assert_eq!( + value + .get("metadata") + .unwrap() + .get("github.com/copilot:safeForTelemetry") + .unwrap(), + &json!({ "name": true, "inputsNames": false }) + ); + + // Empty metadata is omitted on the wire. + let plain = Tool::new("plain"); + let value = serde_json::to_value(&plain).unwrap(); + assert!(value.get("metadata").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.") From 71c6fb4cd1589445f392f35629f4c4f35c6d98fc Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Fri, 10 Jul 2026 18:00:14 -0400 Subject: [PATCH 2/5] Address review feedback on tool metadata passthrough - dotnet: change Metadata value type to IDictionary for NativeAOT-safe serialization (CopilotToolOptions + wire ToolDefinition, FromAIFunction cast, unit test); drop redundant [JsonPropertyName("metadata")] since the Web camelCase policy already maps it; remove the block from the Metadata property. - Reword metadata doc comments across nodejs/python/go/rust/java to describe the bag opaquely without the SDK-vs-runtime/CLI distinction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf2bf0dd-ec9f-42f5-a6e1-4f2a9e562d5f --- dotnet/src/Client.cs | 5 +++-- dotnet/src/CopilotTool.cs | 8 ++------ dotnet/test/Unit/CopilotToolTests.cs | 5 +++-- go/types.go | 5 ++--- .../com/github/copilot/rpc/ToolDefinition.java | 17 ++++++++--------- nodejs/src/types.ts | 7 +++---- python/copilot/tools.py | 6 +++--- rust/src/types.rs | 12 +++++------- 8 files changed, 29 insertions(+), 36 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 69bee94e6a..e2b4bdcddf 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -15,6 +15,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -2706,14 +2707,14 @@ internal record ToolDefinition( bool? OverridesBuiltInTool = null, bool? SkipPermission = null, CopilotToolDefer? Defer = null, - [property: JsonPropertyName("metadata")] IDictionary? Metadata = null) + IDictionary? Metadata = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; var defer = function.AdditionalProperties.TryGetValue(CopilotTool.DeferKey, out var deferVal) && deferVal is CopilotToolDefer d ? d : (CopilotToolDefer?)null; - var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary m ? m : null; + var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary m ? m : null; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null, diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index 5aab1da352..e22296bccd 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; +using System.Text.Json.Nodes; namespace GitHub.Copilot; @@ -163,12 +164,7 @@ public sealed class CopilotToolOptions ///

/// Gets or sets opaque, host-defined metadata associated with the tool definition. /// - /// - /// Keys are namespaced and not part of the stable public API. When set, the SDK forwards - /// the metadata verbatim to the CLI as the tool's metadata object, which the runtime - /// may recognize to inform host-specific behavior. Unknown keys are preserved. - /// - public IDictionary? Metadata { get; set; } + public IDictionary? Metadata { get; set; } } /// diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index 900b25d3e3..c3f5861492 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -46,9 +47,9 @@ public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() [Fact] public void DefineTool_Sets_Metadata_In_Additional_Properties() { - var metadata = new Dictionary + var metadata = new Dictionary { - ["github.com/copilot:safeForTelemetry"] = new Dictionary + ["github.com/copilot:safeForTelemetry"] = new JsonObject { ["name"] = true, ["inputsNames"] = false diff --git a/go/types.go b/go/types.go index e400fc637a..04148f0a16 100644 --- a/go/types.go +++ b/go/types.go @@ -1271,9 +1271,8 @@ type Tool struct { Defer ToolDefer `json:"defer,omitempty"` // Metadata is opaque, host-defined metadata associated with the tool // definition. Keys are namespaced and not part of the stable public API; - // the SDK forwards them verbatim to the runtime, which may recognize - // specific keys to inform host-specific behavior. Unknown keys are - // preserved and round-tripped untouched. + // values are not interpreted and may be recognized to inform host-specific + // behavior. Unknown keys are preserved and round-tripped untouched. Metadata map[string]any `json:"metadata,omitempty"` // Handler is optional. When nil, the SDK exposes the tool declaration but does // not automatically invoke it. diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java index 29579b086c..13a9efe7d1 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -76,9 +76,8 @@ * search) rather than always pre-loaded; {@code null} lets the * runtime decide * @param metadata - * opaque, host-defined metadata forwarded verbatim to the runtime; - * keys are namespaced and not part of the stable public API; - * {@code null} when unset + * opaque, host-defined metadata; keys are namespaced and not part of + * the stable public API; {@code null} when unset * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -187,9 +186,9 @@ public static ToolDefinition createWithDefer(String name, String description, Ma /** * Creates a tool definition with opaque, host-defined metadata. *

- * Use this factory method to attach namespaced metadata that the SDK forwards - * verbatim to the runtime. The keys are not part of the stable public API; the - * runtime may recognize specific keys to inform host-specific behavior. + * Use this factory method to attach namespaced metadata to the tool. The keys + * are not part of the stable public API; specific keys may be recognized to + * inform host-specific behavior. * * @param name * the unique name of the tool @@ -200,7 +199,7 @@ public static ToolDefinition createWithDefer(String name, String description, Ma * @param handler * the handler function to execute when invoked * @param metadata - * the opaque metadata map forwarded to the runtime + * the opaque metadata map * @return a new tool definition with the metadata set * @since 1.0.7 */ @@ -313,8 +312,8 @@ public ToolDefinition defer(ToolDefer value) { * Returns a copy with the opaque {@code metadata} bag set. * * @param value - * the opaque, host-defined metadata forwarded verbatim to the - * runtime; keys are namespaced and not part of the stable public API + * the opaque, host-defined metadata; keys are namespaced and not + * part of the stable public API * @return a new {@code ToolDefinition} with the metadata applied * @since 1.0.7 */ diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 30c9b6eb4c..fa0bb4d884 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -582,10 +582,9 @@ export interface Tool { /** * Opaque, host-defined metadata associated with the tool definition. * - * Keys are namespaced and are not part of the stable public API. The SDK - * does not interpret these values; it forwards them verbatim to the runtime, - * which may recognize specific namespaced keys to inform host-specific - * behavior. Unknown keys are preserved and round-tripped untouched. + * Keys are namespaced and are not part of the stable public API. Values are + * not interpreted and may be recognized to inform host-specific behavior. + * Unknown keys are preserved and round-tripped untouched. */ metadata?: Record; } diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 6cbec0511f..b9bb52db60 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -172,9 +172,9 @@ def lookup_issue(params: LookupIssueParams) -> str: and surfaced through tool search. When "never", the tool is always pre-loaded. Optional; defaults to "auto". metadata: Opaque, host-defined metadata associated with the tool definition. - Keys are namespaced and not part of the stable public API; the SDK - forwards them verbatim to the runtime, which may recognize specific - keys to inform host-specific behavior. Unknown keys are preserved. + Keys are namespaced and not part of the stable public API; values + are not interpreted and may be recognized to inform host-specific + behavior. Unknown keys are preserved. Returns: A Tool instance diff --git a/rust/src/types.rs b/rust/src/types.rs index fd4f3b7dc6..36bc4327af 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -352,10 +352,9 @@ pub struct Tool { #[serde(default, skip_serializing_if = "Option::is_none")] pub defer: Option, /// Opaque, host-defined metadata associated with the tool definition. - /// Keys are namespaced and not part of the stable public API; the SDK - /// forwards them verbatim to the runtime, which may recognize specific - /// keys to inform host-specific behavior. Unknown keys are preserved and - /// round-tripped untouched. + /// Keys are namespaced and not part of the stable public API; values are + /// not interpreted and may be recognized to inform host-specific behavior. + /// Unknown keys are preserved and round-tripped untouched. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub metadata: HashMap, /// Optional runtime implementation. When `Some`, the SDK dispatches @@ -477,9 +476,8 @@ impl Tool { self } - /// Set opaque, host-defined metadata forwarded verbatim to the runtime. - /// Keys are namespaced and not part of the stable public API. Replaces any - /// previously-set metadata. + /// Set opaque, host-defined metadata for the tool. Keys are namespaced and + /// not part of the stable public API. Replaces any previously-set metadata. pub fn with_metadata(mut self, metadata: HashMap) -> Self { self.metadata = metadata; self From dac4fff424cb281dc35fffb8cb533f2c6355ef1c Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Tue, 14 Jul 2026 18:37:41 -0400 Subject: [PATCH 3/5] 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.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 --- java/docs/adr/adr-005-tool-definition.md | 33 +++++++- .../github/copilot/rpc/ToolDefinition.java | 30 +++++++ .../com/github/copilot/tool/CopilotTool.java | 75 +++++++++++++++++ .../copilot/tool/CopilotToolProcessor.java | 39 ++++++++- .../github/copilot/ToolDefinitionTest.java | 46 +++++++++++ .../rpc/ToolDefinitionFromObjectTest.java | 15 ++++ .../SimpleTools$$CopilotToolMeta.java | 3 +- .../copilot/rpc/fixtures/SimpleTools.java | 5 +- .../tool/CopilotToolProcessorTest.java | 80 +++++++++++++++++++ 9 files changed, 322 insertions(+), 4 deletions(-) diff --git a/java/docs/adr/adr-005-tool-definition.md b/java/docs/adr/adr-005-tool-definition.md index e1b4dcc764..dc1eb36143 100644 --- a/java/docs/adr/adr-005-tool-definition.md +++ b/java/docs/adr/adr-005-tool-definition.md @@ -181,14 +181,45 @@ final class MyTools$$CopilotToolMeta { Phase phase = invocation.getArgumentsAs(Phase.class); return CompletableFuture.completedFuture( instance.setCurrentPhase(phase)); - }, null, null, null) + }, null, null, null, null) ); } } ``` +The trailing constructor arguments are `overridesBuiltInTool`, `skipPermission`, `defer`, and `metadata` — all `null` here because none were set on the annotation. + At runtime, `ToolDefinition.fromObject(myTools)` loads the generated `$$CopilotToolMeta` class — zero reflection, zero dependency on `-parameters`. +### Host-defined metadata + +`@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. + +```java +@CopilotTool( + value = "Reports phase", + metadata = { + @CopilotTool.MetadataEntry( + key = "github.com/copilot:safeForTelemetry", + value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false) + })) + }) +public String reportPhase(@CopilotToolParam("Phase") String phase) { + return phase; +} +``` + +The processor emits this as the `metadata` constructor argument: + +```java +Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)) +``` + +For richer values (numbers, arrays, deeper nesting), use the programmatic `ToolDefinition.createWithMetadata(...)` / `ToolDefinition.metadata(...)` API instead. + ### Compile-time validation Because the processor has full access to the source AST, it can emit compile errors for: diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java index 13a9efe7d1..f2bca74011 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -89,6 +89,36 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer, @JsonProperty("metadata") Map metadata) { + /** + * Backward-compatible constructor without the {@code metadata} component. + *

+ * Delegates to the canonical constructor with {@code metadata} set to + * {@code null}. Retained so existing direct constructor call sites (including + * previously-generated {@code $$CopilotToolMeta} classes) keep compiling after + * {@code metadata} was added. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param parameters + * the JSON Schema for the tool's parameters + * @param handler + * the handler function to execute when invoked + * @param overridesBuiltInTool + * whether this tool overrides a built-in tool; {@code null} for the + * default + * @param skipPermission + * whether the tool may run without a permission check; {@code null} + * for the default + * @param defer + * the deferral mode; {@code null} lets the runtime decide + */ + public ToolDefinition(String name, String description, Object parameters, ToolHandler handler, + Boolean overridesBuiltInTool, Boolean skipPermission, ToolDefer defer) { + this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, null); + } + /** * Creates a tool definition with a JSON schema for parameters. *

diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java index 0bad327b99..45bc0e07ea 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -50,4 +50,79 @@ /** Defer configuration for this tool. */ ToolDefer defer() default ToolDefer.NONE; + + /** + * Opaque, host-defined metadata for this tool. Keys are namespaced and not + * part of the stable public API; specific keys may be recognized to inform + * host-specific behavior. + * + *

+ * Because annotation members cannot express arbitrary maps, this uses a + * deliberately shallow representation: each {@link MetadataEntry} maps a + * string key to a single {@link MetadataValue} that is either a boolean, a + * string, or a one-level map of named boolean {@link MetadataFlag flags}. + * Numbers, arrays, and deeper nesting are not supported here; use the + * programmatic {@code ToolDefinition.createWithMetadata(...)} / + * {@code ToolDefinition.metadata(...)} API for richer values. + * + *

+ * Example emitted shape: + * + *

+     * Map.of("github.com/copilot:safeForTelemetry",
+     *        Map.of("name", true, "inputsNames", false))
+     * 
+ */ + MetadataEntry[] metadata() default {}; + + /** + * A single metadata key/value pair. Used only as a member value of + * {@link CopilotTool#metadata()}. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataEntry { + + /** The namespaced metadata key. */ + String key(); + + /** The value associated with {@link #key()}. */ + MetadataValue value(); + } + + /** + * A metadata value. Exactly one representation is intended per value: a map + * of named boolean {@link #flags()} (when non-empty), otherwise a + * {@link #str()} (when non-empty), otherwise a {@link #bool()}. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataValue { + + /** Scalar boolean value. Used when {@link #flags()} and {@link #str()} are unset. */ + boolean bool() default false; + + /** Scalar string value. Used when {@link #flags()} is empty and this is non-empty. */ + String str() default ""; + + /** Object-like value: a one-level map of named boolean flags. Takes precedence when non-empty. */ + MetadataFlag[] flags() default {}; + } + + /** + * A single named boolean flag within a {@link MetadataValue#flags()} map. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataFlag { + + /** The flag name (map key). */ + String name(); + + /** The flag value. */ + boolean value(); + } } diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index af75e97685..03af4a7cd9 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -277,10 +277,47 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { out.println(" " + overridesArg + ","); out.println(" " + skipPermArg + ","); out.println(" " + deferArg + ","); - out.println(" null"); + out.println(" " + metadataSource(annotation)); out.print(" )"); } + /** + * Converts the {@code @CopilotTool(metadata = ...)} entries into a Java source + * literal. Returns {@code "null"} when no metadata is present, otherwise a + * {@code Map.of(...)} expression. + */ + private String metadataSource(CopilotTool annotation) { + CopilotTool.MetadataEntry[] entries = annotation.metadata(); + if (entries.length == 0) { + return "null"; + } + List parts = new ArrayList<>(); + for (CopilotTool.MetadataEntry entry : entries) { + parts.add("\"" + escapeJava(entry.key()) + "\", " + metadataValueSource(entry.value())); + } + return "Map.of(" + String.join(", ", parts) + ")"; + } + + /** + * Converts a single {@link CopilotTool.MetadataValue} into a Java source + * literal. A non-empty {@code flags} map takes precedence, then a non-empty + * {@code str}, otherwise the {@code bool} scalar. + */ + private String metadataValueSource(CopilotTool.MetadataValue value) { + CopilotTool.MetadataFlag[] flags = value.flags(); + if (flags.length > 0) { + List flagParts = new ArrayList<>(); + for (CopilotTool.MetadataFlag flag : flags) { + flagParts.add("\"" + escapeJava(flag.name()) + "\", " + flag.value()); + } + return "Map.of(" + String.join(", ", flagParts) + ")"; + } + if (!value.str().isEmpty()) { + return "\"" + escapeJava(value.str()) + "\""; + } + return String.valueOf(value.bool()); + } + private String generateSchemaWithParamMetadata(List parameters) { List schemaParameters = getSchemaParameters(parameters); diff --git a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java index f244293521..66c9f9ec86 100644 --- a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java +++ b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java @@ -82,4 +82,50 @@ void testMetadataOmittedWhenNull() throws Exception { assertFalse(json.has("metadata")); } + + @Test + void testSevenArgConstructorLeavesMetadataNull() throws Exception { + ToolDefinition tool = new ToolDefinition("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok"), null, null, null); + + assertNull(tool.metadata()); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("metadata")); + } + + @Test + void testMetadataCopyMethodSerializes() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)); + ToolDefinition tool = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .metadata(metadata); + + assertEquals(metadata, tool.metadata()); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry")); + } + + @Test + void testChainingFlagsPreservesMetadata() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true)); + + ToolDefinition metadataFirst = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .metadata(metadata).overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER); + + ToolDefinition flagsFirst = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER).metadata(metadata); + + assertEquals(metadata, metadataFirst.metadata()); + assertEquals(metadata, flagsFirst.metadata()); + assertEquals(Boolean.TRUE, flagsFirst.overridesBuiltInTool()); + assertEquals(Boolean.TRUE, flagsFirst.skipPermission()); + assertEquals(ToolDefer.NEVER, flagsFirst.defer()); + } } diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index 37ea9f6a50..afa3d42511 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -92,6 +92,21 @@ void fromObject_handlerInvocation() throws Exception { assertEquals("Hello, Alice!", result); } + @Test + void fromObject_toolMetadata() { + var tools = ToolDefinition.fromObject(new SimpleTools()); + + var withMetadata = findTool(tools, "greet_user"); + assertNotNull(withMetadata); + assertNotNull(withMetadata.metadata()); + assertEquals(Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false)), + withMetadata.metadata()); + + var withoutMetadata = findTool(tools, "add_numbers"); + assertNotNull(withoutMetadata); + assertNull(withoutMetadata.metadata()); + } + // ── Test 2: Handler return type patterns ──────────────────────────────────── @Test diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java index 4e84cb0947..d8090720af 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java @@ -30,7 +30,8 @@ public List definitions(SimpleTools instance, ObjectMapper mappe Map args = invocation.getArguments(); String name = (String) args.get("name"); return CompletableFuture.completedFuture(instance.greetUser(name)); - }, null, null, null, null), + }, null, null, null, Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false))), new ToolDefinition("add_numbers", "Adds two numbers together", Map.of("type", "object", "properties", Map.ofEntries( diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java index 5bc3d841e5..09ecc83ac4 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java @@ -12,7 +12,10 @@ */ public class SimpleTools { - @CopilotTool("Greets a user by name") + @CopilotTool(value = "Greets a user by name", metadata = { + @CopilotTool.MetadataEntry(key = "github.com/copilot:safeForTelemetry", value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false) })) }) public String greetUser(@CopilotToolParam(value = "The user's name", required = true) String name) { return "Hello, " + name + "!"; } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index c2bda9f9ad..009b2edc45 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -212,6 +212,86 @@ public String doSomething(@CopilotToolParam("Input") String input) { "Expected completedFuture wrapping for String return, got:\n" + generated); } + @Test + void generatesMetadata_withNestedFlags() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class MetaTools { + @CopilotTool(value = "Reports phase", metadata = { + @CopilotTool.MetadataEntry( + key = "github.com/copilot:safeForTelemetry", + value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false) + })) + }) + public String reportPhase(@CopilotToolParam("Phase") String phase) { + return phase; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.MetaTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.MetaTools$$CopilotToolMeta"); + assertTrue(generated.contains("Map.of(\"github.com/copilot:safeForTelemetry\""), + "Expected typed metadata map, got:\n" + generated); + assertTrue(generated.contains("Map.of(\"name\", true, \"inputsNames\", false)"), + "Expected nested flag map, got:\n" + generated); + } + + @Test + void generatesNullMetadata_whenAbsent() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class PlainTools { + @CopilotTool("Plain tool") + public String doSomething(@CopilotToolParam("Input") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.PlainTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.PlainTools$$CopilotToolMeta"); + assertFalse(generated.contains("Map.of("), + "Expected no metadata map for a tool without metadata, got:\n" + generated); + } + + @Test + void generatesMetadata_alongsideOtherFlags() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.rpc.ToolDefer; + import com.github.copilot.tool.CopilotToolParam; + public class ComboTools { + @CopilotTool(value = "Combo", name = "combo", overridesBuiltInTool = true, + skipPermission = true, defer = ToolDefer.NEVER, + metadata = { + @CopilotTool.MetadataEntry(key = "k", + value = @CopilotTool.MetadataValue(bool = true)) + }) + public String doSomething(@CopilotToolParam("Input") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ComboTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.ComboTools$$CopilotToolMeta"); + assertTrue(generated.contains("Boolean.TRUE"), "Expected overrides/skip flags, got:\n" + generated); + assertTrue(generated.contains("ToolDefer.NEVER"), "Expected defer, got:\n" + generated); + assertTrue(generated.contains("Map.of(\"k\", true)"), + "Expected scalar bool metadata, got:\n" + generated); + } + @Test void generatesCorrectCode_forVoidReturnType() { String source = """ From 9aa5e0a2965dc03d1bd289db65972c3438f5ce6d Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Tue, 14 Jul 2026 19:11:32 -0400 Subject: [PATCH 4/5] Apply spotless formatting to Java metadata changes Reflow javadoc and wrap annotation/constructor args per the Eclipse formatter (mvn spotless:apply). No behavioral change. Verified locally: mvn spotless:check clean; ToolDefinitionTest (8), ToolDefinitionFromObjectTest (31), CopilotToolProcessorTest (37) all pass on JDK 26. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf2bf0dd-ec9f-42f5-a6e1-4f2a9e562d5f --- .../com/github/copilot/tool/CopilotTool.java | 38 +++++++++++-------- .../SimpleTools$$CopilotToolMeta.java | 3 +- .../copilot/rpc/fixtures/SimpleTools.java | 2 +- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java index 45bc0e07ea..db9e3ca62d 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -52,25 +52,24 @@ ToolDefer defer() default ToolDefer.NONE; /** - * Opaque, host-defined metadata for this tool. Keys are namespaced and not - * part of the stable public API; specific keys may be recognized to inform + * Opaque, host-defined metadata for this tool. Keys are namespaced and not part + * of the stable public API; specific keys may be recognized to inform * host-specific behavior. * *

* Because annotation members cannot express arbitrary maps, this uses a - * deliberately shallow representation: each {@link MetadataEntry} maps a - * string key to a single {@link MetadataValue} that is either a boolean, a - * string, or a one-level map of named boolean {@link MetadataFlag flags}. - * Numbers, arrays, and deeper nesting are not supported here; use the - * programmatic {@code ToolDefinition.createWithMetadata(...)} / + * deliberately shallow representation: each {@link MetadataEntry} maps a string + * key to a single {@link MetadataValue} that is either a boolean, a string, or + * a one-level map of named boolean {@link MetadataFlag flags}. Numbers, arrays, + * and deeper nesting are not supported here; use the programmatic + * {@code ToolDefinition.createWithMetadata(...)} / * {@code ToolDefinition.metadata(...)} API for richer values. * *

* Example emitted shape: * *

-     * Map.of("github.com/copilot:safeForTelemetry",
-     *        Map.of("name", true, "inputsNames", false))
+     * Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false))
      * 
*/ MetadataEntry[] metadata() default {}; @@ -92,22 +91,31 @@ } /** - * A metadata value. Exactly one representation is intended per value: a map - * of named boolean {@link #flags()} (when non-empty), otherwise a - * {@link #str()} (when non-empty), otherwise a {@link #bool()}. + * A metadata value. Exactly one representation is intended per value: a map of + * named boolean {@link #flags()} (when non-empty), otherwise a {@link #str()} + * (when non-empty), otherwise a {@link #bool()}. */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({}) @interface MetadataValue { - /** Scalar boolean value. Used when {@link #flags()} and {@link #str()} are unset. */ + /** + * Scalar boolean value. Used when {@link #flags()} and {@link #str()} are + * unset. + */ boolean bool() default false; - /** Scalar string value. Used when {@link #flags()} is empty and this is non-empty. */ + /** + * Scalar string value. Used when {@link #flags()} is empty and this is + * non-empty. + */ String str() default ""; - /** Object-like value: a one-level map of named boolean flags. Takes precedence when non-empty. */ + /** + * Object-like value: a one-level map of named boolean flags. Takes precedence + * when non-empty. + */ MetadataFlag[] flags() default {}; } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java index d8090720af..ac38d0cce2 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java @@ -30,7 +30,8 @@ public List definitions(SimpleTools instance, ObjectMapper mappe Map args = invocation.getArguments(); String name = (String) args.get("name"); return CompletableFuture.completedFuture(instance.greetUser(name)); - }, null, null, null, Map.of("github.com/copilot:safeForTelemetry", + }, null, null, null, + Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false))), new ToolDefinition("add_numbers", "Adds two numbers together", Map.of("type", "object", "properties", diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java index 09ecc83ac4..814b3883c3 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java @@ -15,7 +15,7 @@ public class SimpleTools { @CopilotTool(value = "Greets a user by name", metadata = { @CopilotTool.MetadataEntry(key = "github.com/copilot:safeForTelemetry", value = @CopilotTool.MetadataValue(flags = { @CopilotTool.MetadataFlag(name = "name", value = true), - @CopilotTool.MetadataFlag(name = "inputsNames", value = false) })) }) + @CopilotTool.MetadataFlag(name = "inputsNames", value = false)}))}) public String greetUser(@CopilotToolParam(value = "The user's name", required = true) String name) { return "Hello, " + name + "!"; } From d959b21e5ca9fbfc619dc1466edfebd20ccddc7e Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 14 Jul 2026 19:58:35 -0400 Subject: [PATCH 5/5] test: assert null metadata arg in generated tool definition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab2c14cd-d2e1-4524-a6dc-e9c10899343c --- .../java/com/github/copilot/tool/CopilotToolProcessorTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 009b2edc45..574d3acaa0 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -261,6 +261,8 @@ public String doSomething(@CopilotToolParam("Input") String input) { String generated = result.getGeneratedSource("test.PlainTools$$CopilotToolMeta"); assertFalse(generated.contains("Map.of("), "Expected no metadata map for a tool without metadata, got:\n" + generated); + assertTrue(generated.contains(" null\n )"), + "Expected metadata constructor argument to be null when metadata is absent, got:\n" + generated); } @Test