diff --git a/dotnet/README.md b/dotnet/README.md index 9b266421f..c2829277d 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -504,6 +504,26 @@ var safeLookup = CopilotTool.DefineTool( If you want to use `AIFunctionFactory.Create` directly, you can set `skip_permission` in the tool's `AdditionalProperties`. +#### Deferring Tools + +Set `CopilotToolOptions.Defer` to control whether a tool may be loaded lazily via tool search rather than always pre-loaded. Use `CopilotToolDefer.Auto` to allow the tool to be deferred and surfaced through tool search, or `CopilotToolDefer.Never` to force it to always be pre-loaded. Defaults to `CopilotToolDefer.Auto`. + +```csharp +var lookupIssue = CopilotTool.DefineTool( + async ([Description("Issue ID")] string id) => { + // your logic + }, + toolOptions: new CopilotToolOptions + { + Defer = CopilotToolDefer.Auto + }, + factoryOptions: new AIFunctionFactoryOptions + { + Name = "lookup_issue", + Description = "Fetch issue details", + }); +``` + ## Commands Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `Name`, optional `Description`, and a `Handler` called when the user executes it. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4e8715bd5..e98513c53 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2336,15 +2336,18 @@ internal record ToolDefinition( string? Description, JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, - bool? SkipPermission = null) + bool? SkipPermission = null, + CopilotToolDefer? Defer = 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; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, - skipPerm ? true : null); + skipPerm ? true : null, + defer); } } @@ -2501,6 +2504,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(CommandWireDefinition))] [JsonSerializable(typeof(ToolDefinition))] + [JsonSerializable(typeof(CopilotToolDefer))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] [JsonSerializable(typeof(UserInputRequestResponse))] diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index feed5be76..d6dc0351e 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -17,6 +17,9 @@ public static class CopilotTool /// The key used in to indicate that a tool can execute without a permission prompt. internal const string SkipPermissionKey = "skip_permission"; + /// The key used in to carry the tool's deferral mode. + internal const string DeferKey = "defer"; + /// /// Defines a tool for use in a . /// @@ -84,7 +87,7 @@ static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions) static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions) { - if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission)) + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null)) { Dictionary additionalProperties = new(StringComparer.Ordinal); if (factoryOptions.AdditionalProperties is not null) @@ -105,6 +108,11 @@ static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToo additionalProperties[SkipPermissionKey] = true; } + if (toolOptions.Defer is { } defer) + { + additionalProperties[DeferKey] = defer; + } + factoryOptions.AdditionalProperties = additionalProperties; } } @@ -121,7 +129,7 @@ public sealed class CopilotToolOptions /// Gets or sets a value indicating whether this tool intentionally overrides a built-in Copilot tool with the same name. /// /// - /// When a with set to true is used to define a tool, + /// When a with set to true is used to define a tool, /// the resulting will include "is_override": true in its . /// public bool OverridesBuiltInTool { get; set; } @@ -130,8 +138,32 @@ public sealed class CopilotToolOptions /// Gets or sets a value indicating whether this tool can execute without a permission prompt. /// /// - /// When a with set to true is used to define a tool, + /// When a with set to true is used to define a tool, /// the resulting will include "skip_permission": true in its . /// public bool SkipPermission { get; set; } + + /// + /// Gets or sets a value controlling whether this tool may be deferred (loaded lazily via tool search) rather than always pre-loaded. + /// + /// + /// When set, the resulting carries the value in its and the + /// SDK forwards it to the CLI as the tool's defer mode. Defaults to "auto". + /// + public CopilotToolDefer? Defer { get; set; } +} + +/// +/// Controls whether a tool may be deferred (loaded lazily via tool search) rather than always pre-loaded. +/// +[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] +public enum CopilotToolDefer +{ + /// The tool can be deferred and surfaced through tool search. + [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] + Auto, + + /// The tool is always pre-loaded. + [System.Text.Json.Serialization.JsonStringEnumMemberName("never")] + Never } diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index 76ad0e425..9c9e2a93b 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -19,7 +19,8 @@ public void DefineTool_Sets_Name_Description_And_Copilot_Metadata() new CopilotToolOptions { OverridesBuiltInTool = true, - SkipPermission = true + SkipPermission = true, + Defer = CopilotToolDefer.Auto }); Assert.Equal("test_tool", function.Name); @@ -28,6 +29,8 @@ public void DefineTool_Sets_Name_Description_And_Copilot_Metadata() Assert.True((bool)isOverride!); Assert.True(function.AdditionalProperties.TryGetValue("skip_permission", out var skipPermission)); Assert.True((bool)skipPermission!); + Assert.True(function.AdditionalProperties.TryGetValue("defer", out var defer)); + Assert.Equal(CopilotToolDefer.Auto, defer); } [Fact] @@ -37,6 +40,7 @@ public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() Assert.False(function.AdditionalProperties.ContainsKey("is_override")); Assert.False(function.AdditionalProperties.ContainsKey("skip_permission")); + Assert.False(function.AdditionalProperties.ContainsKey("defer")); } [Fact] diff --git a/go/README.md b/go/README.md index 0ceaabb73..4bdbc75f5 100644 --- a/go/README.md +++ b/go/README.md @@ -372,6 +372,18 @@ safeLookup := copilot.DefineTool("safe_lookup", "A read-only lookup that needs n safeLookup.SkipPermission = true ``` +#### Deferring Tools + +Set `Defer` to control whether a tool may be loaded lazily via tool search rather than always pre-loaded. Use `copilot.ToolDeferAuto` to allow the tool to be deferred and surfaced through tool search, or `copilot.ToolDeferNever` to force it to always be pre-loaded. Defaults to `copilot.ToolDeferAuto`. + +```go +lookupIssue := copilot.DefineTool("lookup_issue", "Fetch issue details", + func(params LookupParams, inv copilot.ToolInvocation) (any, error) { + // your logic + }) +lookupIssue.Defer = copilot.ToolDeferAuto +``` + ## Streaming Enable streaming to receive assistant response chunks as they're generated: diff --git a/go/client_test.go b/go/client_test.go index d5ba47da8..bba1a130d 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -823,6 +823,47 @@ func TestOverridesBuiltInTool(t *testing.T) { }) } +func TestToolDefer(t *testing.T) { + t.Run("Defer is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "lookup_issue", + Description: "Fetch issue details", + Defer: ToolDeferAuto, + 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 v, ok := m["defer"]; !ok || v != "auto" { + t.Errorf("expected defer=auto, got %v", m) + } + }) + + t.Run("Defer 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["defer"]; ok { + t.Errorf("expected defer 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 7ffd454a3..6245ce519 100644 --- a/go/types.go +++ b/go/types.go @@ -1108,12 +1108,27 @@ type SessionConfig struct { // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo } + +// ToolDefer controls whether a tool may be deferred (loaded lazily via tool +// search) rather than always pre-loaded. +type ToolDefer string + +const ( + // ToolDeferAuto allows the tool to be deferred and surfaced through tool search. + ToolDeferAuto ToolDefer = "auto" + // ToolDeferNever forces the tool to always be pre-loaded. + ToolDeferNever ToolDefer = "never" +) + type Tool struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters map[string]any `json:"parameters,omitzero"` OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"` SkipPermission bool `json:"skipPermission,omitempty"` + // 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"` // 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/ToolDefer.java b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java new file mode 100644 index 000000000..bbcae850c --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Controls whether a {@link ToolDefinition} may be deferred (loaded lazily via + * tool search) rather than always pre-loaded. + *

+ * Set on + * {@link ToolDefinition#createWithDefer(String, String, java.util.Map, ToolHandler, ToolDefer)} + * to express the tool's deferral preference; defaults to letting the runtime + * decide when unset. + * + * @see ToolDefinition + * @since 1.2.0 + */ +public enum ToolDefer { + + /** The tool can be deferred and surfaced through tool search. */ + AUTO("auto"), + + /** The tool is always pre-loaded. */ + NEVER("never"); + + private final String value; + + ToolDefer(String value) { + this.value = value; + } + + /** + * Returns the JSON value for this deferral mode. + * + * @return the string value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } + + /** + * Deserializes a JSON string value into the corresponding {@code ToolDefer} + * enum constant. + * + * @param value + * the JSON string value + * @return the matching {@code ToolDefer}, or {@code null} if value is + * {@code null} + * @throws IllegalArgumentException + * if the value does not match any known deferral mode + */ + @JsonCreator + public static ToolDefer fromValue(String value) { + if (value == null) { + return null; + } + for (ToolDefer mode : values()) { + if (mode.value.equals(value)) { + return mode; + } + } + throw new IllegalArgumentException("Unknown ToolDefer value: " + value); + } +} 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 c880e5a77..d8cebe325 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -56,6 +56,10 @@ * when {@code true}, the CLI skips the permission request for this * tool invocation; {@code null} or {@code false} uses normal * permission handling + * @param defer + * controls whether the tool may be deferred (loaded lazily via tool + * search) rather than always pre-loaded; {@code null} lets the + * runtime decide * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -64,7 +68,7 @@ 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("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer) { /** * Creates a tool definition with a JSON schema for parameters. @@ -84,7 +88,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); + return new ToolDefinition(name, description, schema, handler, null, null, null); } /** @@ -108,7 +112,7 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true, null); + return new ToolDefinition(name, description, schema, handler, true, null, null); } /** @@ -131,6 +135,32 @@ 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); + return new ToolDefinition(name, description, schema, handler, null, true, null); + } + + /** + * Creates a tool definition with an explicit deferral mode. + *

+ * Use this factory method to control whether the tool may be deferred (loaded + * lazily via tool search) rather than always pre-loaded. Pass + * {@link ToolDefer#AUTO} to allow deferral and {@link ToolDefer#NEVER} to force + * the tool to always be pre-loaded. + * + * @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 defer + * the deferral mode for the tool + * @return a new tool definition with the deferral mode set + * @since 1.2.0 + */ + public static ToolDefinition createWithDefer(String name, String description, Map schema, + ToolHandler handler, ToolDefer defer) { + return new ToolDefinition(name, description, schema, handler, null, null, defer); } } diff --git a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java new file mode 100644 index 000000000..614e6ab4f --- /dev/null +++ b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import com.github.copilot.rpc.ToolDefer; +import com.github.copilot.rpc.ToolDefinition; + +/** + * Unit tests for {@link ToolDefinition} JSON serialization. + */ +public class ToolDefinitionTest { + + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + private static Map schema() { + return Map.of("type", "object", "properties", + Map.of("query", Map.of("type", "string", "description", "Search query")), "required", List.of("query")); + } + + @Test + void testDeferIsSerialized() throws Exception { + ToolDefinition tool = ToolDefinition.createWithDefer("lookup_issue", "Fetch issue details", schema(), + invocation -> CompletableFuture.completedFuture("ok"), ToolDefer.AUTO); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertEquals("auto", json.get("defer").asText()); + } + + @Test + void testDeferOmittedWhenNull() throws Exception { + ToolDefinition tool = ToolDefinition.create("lookup_issue", "Fetch issue details", schema(), + invocation -> CompletableFuture.completedFuture("ok")); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("defer")); + } + + @Test + void testDeferNeverIsSerialized() throws Exception { + ToolDefinition tool = ToolDefinition.createWithDefer("lookup_issue", "Fetch issue details", schema(), + invocation -> CompletableFuture.completedFuture("ok"), ToolDefer.NEVER); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertEquals("never", json.get("defer").asText()); + } +} diff --git a/nodejs/README.md b/nodejs/README.md index 4219d3bc2..91aa5c4be 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -482,6 +482,21 @@ defineTool("safe_lookup", { }); ``` +#### Deferring Tools + +Set `defer` to control whether a tool may be loaded lazily via tool search rather than always pre-loaded. Use `"auto"` to allow the tool to be deferred and surfaced through tool search, or `"never"` to force it to always be pre-loaded. Defaults to `"auto"`. + +```ts +defineTool("lookup_issue", { + description: "Fetch issue details", + parameters: z.object({ id: z.string() }), + defer: "auto", + handler: async ({ id }) => { + /* your logic */ + }, +}); +``` + ### Commands Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `name`, optional `description`, and a `handler` called when the user executes it. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8dc35b8d7..c59bd3e94 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1107,6 +1107,7 @@ export class CopilotClient { parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, + defer: tool.defer, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, @@ -1293,6 +1294,7 @@ export class CopilotClient { parameters: toJsonSchema(tool.parameters), overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, + defer: tool.defer, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 75aa5159f..647ca79c1 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -511,6 +511,13 @@ export interface Tool { * When true, the tool can execute without a permission prompt. */ skipPermission?: boolean; + /** + * Controls whether the tool may be deferred (loaded lazily via tool search) + * 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"`. + */ + defer?: "auto" | "never"; } /** @@ -525,6 +532,7 @@ export function defineTool( handler?: ToolHandler; overridesBuiltInTool?: boolean; skipPermission?: boolean; + defer?: "auto" | "never"; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 9352eb627..ab09f55ac 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1355,6 +1355,63 @@ describe("CopilotClient", () => { }); }); + describe("defer in tool definitions", () => { + it("sends defer in tool definition on session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + { + name: "lookup_issue", + description: "Fetch issue details", + handler: async () => "ok", + defer: "auto", + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "lookup_issue", defer: "auto" }), + ]); + }); + + it("sends defer in tool definition on session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [ + { + name: "lookup_issue", + description: "Fetch issue details", + handler: async () => "ok", + defer: "auto", + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "lookup_issue", defer: "auto" }), + ]); + spy.mockRestore(); + }); + }); + describe("agent parameter in session creation", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); diff --git a/python/README.md b/python/README.md index a916f98ec..e77fcf16c 100644 --- a/python/README.md +++ b/python/README.md @@ -319,6 +319,16 @@ async def safe_lookup(params: LookupParams) -> str: # your logic ``` +#### Deferring Tools + +Set `defer` to control whether a tool may be loaded lazily via tool search rather than always pre-loaded. Use `"auto"` to allow the tool to be deferred and surfaced through tool search, or `"never"` to force it to always be pre-loaded. Defaults to `"auto"`. + +```python +@define_tool(name="lookup_issue", description="Fetch issue details", defer="auto") +async def lookup_issue(params: LookupParams) -> str: + # your logic +``` + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dcec6e8f..24eec9d72 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1750,6 +1750,8 @@ async def create_session( definition["overridesBuiltInTool"] = True if tool.skip_permission: definition["skipPermission"] = True + if tool.defer is not None: + definition["defer"] = tool.defer tool_defs.append(definition) # Empty-mode validation and normalization @@ -2323,6 +2325,8 @@ async def resume_session( definition["overridesBuiltInTool"] = True if tool.skip_permission: definition["skipPermission"] = True + if tool.defer is not None: + definition["defer"] = tool.defer tool_defs.append(definition) # Empty-mode validation and normalization diff --git a/python/copilot/tools.py b/python/copilot/tools.py index c6a29dc61..a82a48b1e 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -62,6 +62,7 @@ class Tool: parameters: dict[str, Any] | None = None overrides_built_in_tool: bool = False skip_permission: bool = False + defer: Literal["auto", "never"] | None = None T = TypeVar("T", bound=BaseModel) @@ -75,6 +76,7 @@ def define_tool( description: str | None = None, overrides_built_in_tool: bool = False, skip_permission: bool = False, + defer: Literal["auto", "never"] | None = None, ) -> Callable[[Callable[..., Any]], Tool]: pass @@ -88,6 +90,7 @@ def define_tool( handler: None = None, overrides_built_in_tool: bool = False, skip_permission: bool = False, + defer: Literal["auto", "never"] | None = None, ) -> Tool: pass @@ -101,6 +104,7 @@ def define_tool( params_type: type[T], overrides_built_in_tool: bool = False, skip_permission: bool = False, + defer: Literal["auto", "never"] | None = None, ) -> Tool: pass @@ -113,6 +117,7 @@ def define_tool( params_type: type[BaseModel] | None = None, overrides_built_in_tool: bool = False, skip_permission: bool = False, + defer: Literal["auto", "never"] | None = None, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -157,6 +162,10 @@ def lookup_issue(params: LookupIssueParams) -> str: to override a built-in tool of the same name. If not set and the name clashes with a built-in tool, the runtime will return an error. skip_permission: When True, the tool can execute without a permission prompt. + defer: Controls whether the tool may be deferred (loaded lazily via tool search) + 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". Returns: A Tool instance @@ -236,6 +245,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: handler=wrapped_handler, overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, + defer=defer, ) # If handler is provided, call decorator immediately @@ -254,6 +264,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: handler=None, overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, + defer=defer, ) # Otherwise return decorator for @define_tool(...) usage diff --git a/python/test_client.py b/python/test_client.py index 502d410ab..93db185af 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -441,6 +441,70 @@ def grep(params) -> str: await client.force_stop() +class TestDefer: + @pytest.mark.asyncio + async def test_defer_sent_in_tool_definition(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + + @define_tool(description="Fetch issue details", defer="auto") + def lookup_issue(params) -> str: + return "ok" + + await client.create_session( + on_permission_request=PermissionHandler.approve_all, tools=[lookup_issue] + ) + tool_defs = captured["session.create"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["name"] == "lookup_issue" + assert tool_defs[0]["defer"] == "auto" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_sends_defer(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + return {"sessionId": params["sessionId"]} + + client._client.request = mock_request + + @define_tool(description="Fetch issue details", defer="auto") + def lookup_issue(params) -> str: + return "ok" + + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + tools=[lookup_issue], + ) + tool_defs = captured["session.resume"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["defer"] == "auto" + finally: + await client.force_stop() + + class TestInstructionDirectories: @pytest.mark.asyncio async def test_create_session_sends_instruction_directories(self): diff --git a/rust/src/types.rs b/rust/src/types.rs index 8b9b5960a..63d79e573 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -337,6 +337,13 @@ pub struct Tool { /// access control. #[serde(default, skip_serializing_if = "is_false")] pub skip_permission: bool, + /// Controls whether the tool may be deferred (loaded lazily via tool + /// search) rather than always pre-loaded. When [`DeferMode::Auto`], the + /// tool can be deferred and surfaced through tool search. When + /// [`DeferMode::Never`], the tool is always pre-loaded. `None` lets the + /// runtime decide. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defer: Option, /// Optional runtime implementation. When `Some`, the SDK dispatches /// matching `external_tool.requested` broadcasts to this handler. /// When `None`, the tool is declaration-only. @@ -357,6 +364,17 @@ fn is_false(b: &bool) -> bool { !*b } +/// Controls whether a [`Tool`] may be deferred (loaded lazily via tool search) +/// rather than always pre-loaded. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeferMode { + /// The tool can be deferred and surfaced through tool search. + Auto, + /// The tool is always pre-loaded. + Never, +} + impl Tool { /// Construct a new [`Tool`] with the given name and otherwise default /// values. The struct is `#[non_exhaustive]`, so external callers @@ -437,6 +455,14 @@ impl Tool { self } + /// Set the deferral mode controlling whether the tool may be loaded + /// lazily via tool search ([`DeferMode::Auto`]) or always pre-loaded + /// ([`DeferMode::Never`]). + pub fn with_defer(mut self, defer: DeferMode) -> Self { + self.defer = Some(defer); + 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. @@ -464,6 +490,7 @@ impl std::fmt::Debug for Tool { .field("parameters", &self.parameters) .field("overrides_built_in_tool", &self.overrides_built_in_tool) .field("skip_permission", &self.skip_permission) + .field("defer", &self.defer) .field( "handler", &self.handler.as_ref().map(|_| "").unwrap_or("None"), @@ -4300,6 +4327,18 @@ mod tests { assert!(tool.skip_permission); } + #[test] + fn tool_defer_serialization() { + let tool = Tool::new("lookup").with_defer(super::DeferMode::Auto); + assert_eq!(tool.defer, Some(super::DeferMode::Auto)); + let value = serde_json::to_value(&tool).unwrap(); + assert_eq!(value.get("defer").unwrap(), &json!("auto")); + + let plain = Tool::new("plain"); + let value = serde_json::to_value(&plain).unwrap(); + assert!(value.get("defer").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.")