diff --git a/docs/features/README.md b/docs/features/README.md index ef9ae996d4..066ff7ec42 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -20,6 +20,7 @@ These guides cover the capabilities you can add to your Copilot SDK application. | [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) | | [Usage and Billing](./usage-and-billing.md) | Read token counts, context-window utilization, AI credit cost, and account quota | | [Steering & Queueing](./steering-and-queueing.md) | Control message delivery—immediate steering vs. sequential queueing | +| [Context Clearing](./context-management.md) | Replace conversation context safely with terminal tools | | [Session Persistence](./session-persistence.md) | Resume sessions across restarts, manage session storage | | [Remote Sessions](./remote-sessions.md) | Share locally hosted sessions to GitHub web and mobile via Mission Control | | [Cloud Sessions](./cloud-sessions.md) | Run sessions on GitHub-hosted compute through Mission Control | diff --git a/docs/features/context-management.md b/docs/features/context-management.md new file mode 100644 index 0000000000..6472b046aa --- /dev/null +++ b/docs/features/context-management.md @@ -0,0 +1,57 @@ +# Context clearing and terminal tools + +Use `session.history.clearContext` when a host needs to replace the current conversation context without replacing the session. Typical uses include handoffs and host-managed context lifecycle policies. + +Context clearing is different from creating a new session: it preserves the session identity, system and developer messages, configuration, and event log while removing the model-facing conversation. + +> [!IMPORTANT] +> `clearContext` is a tool-handler primitive. The runtime rejects calls made without a tool call in flight, calls with an empty seed prompt, and calls on remote sessions. + +## Define a context-clearing tool + +A successful context-clearing tool should be terminal. Otherwise, the agent loop may make another model call against the newly cleared window before starting the seeded turn. + +```typescript +import { approveAll, CopilotClient, defineTool } from "@github/copilot-sdk"; +import type { CopilotSession } from "@github/copilot-sdk"; +import { z } from "zod"; + +const client = new CopilotClient(); +let session: CopilotSession; + +session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("clear_context", { + description: "Clear the conversation and start a fresh context window", + parameters: z.object({ prompt: z.string() }), + isTerminal: true, + defer: "never", + handler: async ({ prompt }) => { + const { messagesCleared } = + await session.rpc.history.clearContext({ prompt }); + return `Cleared ${messagesCleared} messages.`; + }, + }), + ], +}); +``` + +The required `prompt` becomes the first user message in the fresh context. A successful clear emits `session.context_cleared` with the number of removed messages and the initial message. + +## Terminal-tool behavior + +`isTerminal` ends the current agent turn only when the tool succeeds. A failure, denial, rejection, timeout, or input-validation error remains visible to the model so it can recover or retry. + +The option follows each language's naming conventions: + +| SDK | Tool option | +|---|---| +| Node.js | `isTerminal` | +| Python | `is_terminal` | +| Go | `IsTerminal` | +| .NET | `CopilotToolOptions.IsTerminal` | +| Java | `ToolDefinition.isTerminal(true)` or `@CopilotTool(isTerminal = true)` | +| Rust | `with_is_terminal(true)` | + +Use terminality only for tools whose successful completion should end the turn. Ordinary tools should leave it unset. diff --git a/docs/troubleshooting/compatibility.md b/docs/troubleshooting/compatibility.md index 795c0f5fd4..3238c59d91 100644 --- a/docs/troubleshooting/compatibility.md +++ b/docs/troubleshooting/compatibility.md @@ -89,6 +89,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Agent management | `session.rpc.agent.*` | List, select, deselect, get current agent | | Fleet mode | `session.rpc.fleet.start()` | Parallel sub-agent execution; see [Fleet mode](../features/fleet-mode.md) | | Manual compaction | `session.rpc.history.compact()` | Trigger compaction on demand | +| Context clearing | `session.rpc.history.clearContext()` | Replace conversation context from a terminal tool | | History truncation | `session.rpc.history.truncate()` | Remove events from a point onward | | Session forking | `server.rpc.sessions.fork()` | Fork a session at a point in history | diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1199dac8a..d2a455d2f3 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2788,7 +2788,8 @@ internal record ToolDefinition( bool? OverridesBuiltInTool = null, bool? SkipPermission = null, CopilotToolDefer? Defer = null, - IDictionary? Metadata = null) + IDictionary? Metadata = null, + bool? IsTerminal = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { @@ -2796,11 +2797,13 @@ public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) 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 isTerminal = function.AdditionalProperties.TryGetValue(CopilotTool.IsTerminalKey, out var terminalVal) && terminalVal is true; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null, defer, - metadata); + metadata, + isTerminal ? true : null); } } diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index e22296bccd..ca62ccc5d7 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -18,6 +18,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 indicate that a successful call to the tool ends the agent turn. + internal const string IsTerminalKey = "is_terminal"; + /// The key used in to carry the tool's deferral mode. internal const string DeferKey = "defer"; @@ -91,7 +94,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 || toolOptions.Metadata is not null)) + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.IsTerminal || toolOptions.Defer is not null || toolOptions.Metadata is not null)) { Dictionary additionalProperties = new(StringComparer.Ordinal); if (factoryOptions.AdditionalProperties is not null) @@ -112,6 +115,11 @@ static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToo additionalProperties[SkipPermissionKey] = true; } + if (toolOptions.IsTerminal) + { + additionalProperties[IsTerminalKey] = true; + } + if (toolOptions.Defer is { } defer) { additionalProperties[DeferKey] = defer; @@ -152,6 +160,16 @@ public sealed class CopilotToolOptions /// public bool SkipPermission { get; set; } + /// + /// Gets or sets a value indicating whether a successful call to this tool ends the agent turn. + /// + /// + /// When true, the runtime's tool phase halts after a successful call instead of feeding the result back to the + /// model for another round. A failed call leaves the loop running so the model can read the error and retry. + /// The resulting includes "is_terminal": true in its . + /// + public bool IsTerminal { get; set; } + /// /// Gets or sets a value controlling whether this tool may be deferred (loaded lazily via tool search) rather than always pre-loaded. /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index a30e6d3030..7b7d8c109a 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -287,6 +287,39 @@ public async Task SessionRequests_Serialize_AdditionalDirectories() value => Assert.Equal("/repo/resumed", value.GetString())); } + [Fact] + public async Task SessionRequests_Serialize_Terminal_Tools() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var terminalTool = CopilotTool.DefineTool( + (Func)(() => "done"), + new CopilotToolOptions { IsTerminal = true }); + var plainTool = CopilotTool.DefineTool((Func)(() => "continue")); + + await using var created = await client.CreateSessionAsync(new SessionConfig + { + Tools = [terminalTool, plainTool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var createRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); + var createTools = createRequest.Params.GetProperty("tools"); + Assert.True(createTools[0].GetProperty("isTerminal").GetBoolean()); + Assert.False(createTools[1].TryGetProperty("isTerminal", out _)); + + server.ClearRequests(); + + await using var resumed = await client.ResumeSessionAsync("resume-with-terminal-tool", new ResumeSessionConfig + { + Tools = [terminalTool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var resumeRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); + Assert.True(resumeRequest.Params.GetProperty("tools")[0].GetProperty("isTerminal").GetBoolean()); + } + [Fact] public async Task CreateSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured() { diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index c3f5861492..19fa6258be 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -34,6 +34,28 @@ public void DefineTool_Sets_Name_Description_And_Copilot_Metadata() Assert.Equal(CopilotToolDefer.Auto, defer); } + [Fact] + public void DefineTool_Sets_IsTerminal_Metadata() + { + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions + { + IsTerminal = true + }); + + Assert.True(function.AdditionalProperties.TryGetValue("is_terminal", out var isTerminal)); + Assert.True((bool)isTerminal!); + } + + [Fact] + public void DefineTool_Omits_IsTerminal_When_Not_Set() + { + var function = CopilotTool.DefineTool(ReturnsOk); + + Assert.False(function.AdditionalProperties.ContainsKey("is_terminal")); + } + [Fact] public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() { diff --git a/go/client_test.go b/go/client_test.go index 14131bc4ce..baeb77575a 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3478,3 +3478,40 @@ func TestResumeSessionRequest_ExpAssignments(t *testing.T) { } }) } + +func TestIsTerminal(t *testing.T) { + t.Run("IsTerminal is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "clear_context", + Description: "Clear the conversation", + IsTerminal: true, + 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 m["isTerminal"] != true { + t.Errorf("Expected isTerminal to be true, got %v", m["isTerminal"]) + } + }) + + t.Run("IsTerminal is omitted when false", func(t *testing.T) { + tool := Tool{Name: "plain", Description: "A plain tool"} + 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["isTerminal"]; ok { + t.Error("Expected isTerminal to be omitted when false") + } + }) +} diff --git a/go/types.go b/go/types.go index 3ddcf930dc..28d3087f56 100644 --- a/go/types.go +++ b/go/types.go @@ -1472,6 +1472,11 @@ type Tool struct { Parameters map[string]any `json:"parameters,omitzero"` OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"` SkipPermission bool `json:"skipPermission,omitempty"` + // IsTerminal reports that a successful call to this tool ends the agent + // turn: the runtime halts instead of feeding the result back to the model + // for another round. A failed call leaves the loop running so the model can + // read the error and retry. + IsTerminal bool `json:"isTerminal,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"` 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 ccf0ef5309..de274b66a1 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -78,6 +78,11 @@ * @param metadata * opaque, host-defined metadata; keys are namespaced and not part of * the stable public API; {@code null} when unset + * @param isTerminal + * when {@code true}, a successful call to this tool ends the agent + * turn: the runtime's tool phase halts instead of feeding the result + * back to the model for another round; {@code null} or {@code false} + * leaves the turn running * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -87,13 +92,13 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer, - @JsonProperty("metadata") Map metadata) { + @JsonProperty("metadata") Map metadata, @JsonProperty("isTerminal") Boolean isTerminal) { /** - * Creates a tool definition without a {@code metadata} bag. + * Creates a tool definition without a {@code metadata} bag or terminality hint. *

* Convenience overload equivalent to the canonical constructor with - * {@code metadata} set to {@code null}. + * {@code metadata} and {@code isTerminal} set to {@code null}. * * @param name * the unique name of the tool @@ -114,7 +119,37 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ 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); + this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, null, null); + } + + /** + * Creates a tool definition without a terminality hint. + *

+ * Convenience overload equivalent to the canonical constructor with + * {@code isTerminal} set to {@code null}. + * + * @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 + * @param metadata + * the opaque, host-defined metadata; {@code null} when unset + */ + public ToolDefinition(String name, String description, Object parameters, ToolHandler handler, + Boolean overridesBuiltInTool, Boolean skipPermission, ToolDefer defer, Map metadata) { + this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, metadata, null); } /** @@ -304,7 +339,8 @@ public static List fromClass(Class clazz) { */ @CopilotExperimental public ToolDefinition overridesBuiltInTool(boolean value) { - return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer, metadata); + return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer, metadata, + isTerminal); } /** @@ -318,7 +354,8 @@ public ToolDefinition overridesBuiltInTool(boolean value) { */ @CopilotExperimental public ToolDefinition skipPermission(boolean value) { - return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer, metadata); + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer, metadata, + isTerminal); } /** @@ -333,7 +370,7 @@ public ToolDefinition skipPermission(boolean value) { @CopilotExperimental public ToolDefinition defer(ToolDefer value) { return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, value, - metadata); + metadata, isTerminal); } /** @@ -348,7 +385,22 @@ public ToolDefinition defer(ToolDefer value) { @CopilotExperimental public ToolDefinition metadata(Map value) { return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, - value); + value, isTerminal); + } + + /** + * Returns a copy with the {@code isTerminal} flag set. + * + * @param value + * {@code true} to end the agent turn after a successful call to this + * tool + * @return a new {@code ToolDefinition} with the flag applied + * @since 1.0.11 + */ + @CopilotExperimental + public ToolDefinition isTerminal(boolean value) { + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, + metadata, value); } // ------------------------------------------------------------------ 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 db9e3ca62d..28cd759288 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -48,6 +48,9 @@ /** Whether to skip permission checks. */ boolean skipPermission() default false; + /** Whether a successful call to this tool ends the agent turn. */ + boolean isTerminal() default false; + /** Defer configuration for this tool. */ ToolDefer defer() default ToolDefer.NONE; 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 dbc6921afa..f88c1ac7c8 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -317,6 +317,7 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { String description = annotation.value(); boolean overridesBuiltIn = annotation.overridesBuiltInTool(); boolean skipPermission = annotation.skipPermission(); + boolean isTerminal = annotation.isTerminal(); com.github.copilot.rpc.ToolDefer defer = annotation.defer(); // Generate schema with @CopilotToolParam metadata (descriptions, names, @@ -329,6 +330,7 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { // Use the record constructor directly so all flags apply independently String overridesArg = overridesBuiltIn ? "Boolean.TRUE" : "null"; String skipPermArg = skipPermission ? "Boolean.TRUE" : "null"; + String isTerminalArg = isTerminal ? "Boolean.TRUE" : "null"; String deferArg = defer != com.github.copilot.rpc.ToolDefer.NONE ? "ToolDefer." + defer.name() : "null"; out.println(" new ToolDefinition("); @@ -341,7 +343,8 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { out.println(" " + overridesArg + ","); out.println(" " + skipPermArg + ","); out.println(" " + deferArg + ","); - out.println(" " + metadataSource(annotation)); + out.println(" " + metadataSource(annotation) + ","); + out.println(" " + isTerminalArg); out.print(" )"); } diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionIsTerminalTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionIsTerminalTest.java new file mode 100644 index 0000000000..850dfa251f --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionIsTerminalTest.java @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +package com.github.copilot.rpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Wire-level coverage for {@link ToolDefinition#isTerminal()}. */ +class ToolDefinitionIsTerminalTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void isTerminalSerializesAsCamelCaseWhenSet() throws Exception { + ToolDefinition definition = new ToolDefinition("clear_context", "Clear the conversation", + Map.of("type", "object"), null, null, null, null, null, true); + + JsonNode node = MAPPER.valueToTree(definition); + + assertTrue(node.has("isTerminal"), "isTerminal should be serialized"); + assertTrue(node.get("isTerminal").asBoolean(), "isTerminal should be true"); + } + + @Test + void isTerminalIsOmittedWhenNull() throws Exception { + ToolDefinition definition = new ToolDefinition("plain", "A plain tool", Map.of("type", "object"), null, null, + null, null, null, null); + + JsonNode node = MAPPER.valueToTree(definition); + + assertFalse(node.has("isTerminal"), "isTerminal should be omitted when null"); + } + + @Test + void sevenArgumentConstructorStillCompilesAndLeavesTerminalityUnset() throws Exception { + // Guards source compatibility for call sites written before isTerminal + // was added as a record component. + ToolDefinition definition = new ToolDefinition("legacy", "Legacy call site", Map.of("type", "object"), null, + null, null, null); + + assertEquals(null, definition.isTerminal()); + assertFalse(MAPPER.valueToTree(definition).has("isTerminal")); + } +} 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 f0623d92ed..e7012c644f 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -620,8 +620,8 @@ public String doSomething(@CopilotToolParam("Input") String input) { assertFalse(generated.contains("Map.of("), "Expected no metadata map for a tool without metadata, got:\n" + generated); String normalizedGenerated = generated.replace("\r\n", "\n").replace('\r', '\n'); - assertTrue(normalizedGenerated.contains(" null\n )"), - "Expected metadata constructor argument to be null when metadata is absent, got:\n" + generated); + assertTrue(normalizedGenerated.contains(" null,\n null\n )"), + "Expected metadata and isTerminal constructor arguments to be null when absent, got:\n" + generated); } @Test @@ -1243,6 +1243,28 @@ public String grep(@CopilotToolParam("Query") String query) { "Expected Boolean.TRUE for overridesBuiltInTool, got:\n" + generated); } + @Test + void generatesTerminalTool_whenIsTerminal() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + public class TerminalTools { + @CopilotTool(value = "Ends the turn", isTerminal = true) + public String finish() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.TerminalTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.TerminalTools$$CopilotToolMeta"); + String normalizedGenerated = generated.replace("\r\n", "\n").replace('\r', '\n'); + assertTrue(normalizedGenerated.contains( + " null,\n null,\n null,\n null,\n Boolean.TRUE\n )"), + "Expected Boolean.TRUE for isTerminal in the final constructor position, got:\n" + generated); + } + // ── Test: Combined flags all apply independently ──────────────────────────── @Test @@ -1252,7 +1274,8 @@ void generatesCombinedFlags() { import com.github.copilot.tool.CopilotTool; import com.github.copilot.rpc.ToolDefer; public class CombinedTools { - @CopilotTool(value = "Combined", overridesBuiltInTool = true, skipPermission = true, defer = ToolDefer.AUTO) + @CopilotTool(value = "Combined", overridesBuiltInTool = true, skipPermission = true, + isTerminal = true, defer = ToolDefer.AUTO) public String doAll() { return "done"; } @@ -1268,11 +1291,10 @@ public String doAll() { assertTrue(generated.contains("Boolean.TRUE"), "Expected Boolean.TRUE for override/skipPermission, got:\n" + generated); assertTrue(generated.contains("ToolDefer.AUTO"), "Expected ToolDefer.AUTO, got:\n" + generated); - // Count Boolean.TRUE occurrences — should be 2 (overridesBuiltInTool + - // skipPermission) + // Count Boolean.TRUE occurrences — override, skipPermission, and isTerminal. long boolCount = generated.lines().filter(l -> l.contains("Boolean.TRUE")).count(); - assertEquals(2, boolCount, - "Expected 2 Boolean.TRUE lines (overridesBuiltInTool + skipPermission), got:\n" + generated); + assertEquals(3, boolCount, + "Expected 3 Boolean.TRUE lines (override + skipPermission + isTerminal), got:\n" + generated); } // ── Test: ToolDefer.NONE results in regular create ────────────────────────── diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3e100edddd..d887065376 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1535,6 +1535,7 @@ export class CopilotClient { skipPermission: tool.skipPermission, defer: tool.defer, metadata: tool.metadata, + isTerminal: tool.isTerminal, })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), @@ -1784,6 +1785,7 @@ export class CopilotClient { skipPermission: tool.skipPermission, defer: tool.defer, metadata: tool.metadata, + isTerminal: tool.isTerminal, })), toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a8a9410f84..bec38657dd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -656,6 +656,17 @@ export interface Tool { * Unknown keys are preserved and round-tripped untouched. */ metadata?: Record; + /** + * When true, a successful call to this tool ends the agent turn: the runtime's + * tool phase halts instead of feeding the tool result back to the model for + * another round. A failed call (for example input validation) leaves the loop + * running so the model can read the error and retry. + * + * Use this for tools whose whole purpose is to terminate the turn, such as a + * context clear that replaces the conversation the model would otherwise + * continue from. + */ + isTerminal?: boolean; } /** @@ -672,6 +683,7 @@ export function defineTool( skipPermission?: boolean; defer?: "auto" | "never"; metadata?: Record; + isTerminal?: boolean; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index bbe6fbe666..f667932ad4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -714,6 +714,68 @@ describe("CopilotClient", () => { expect(createPayload.tools[0].metadata).toBeUndefined(); }); + it("forwards tool isTerminal 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 tool = { + name: "clear_context", + description: "Clears the conversation", + parameters: { type: "object", properties: {} }, + isTerminal: true, + }; + + 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].isTerminal).toBe(true); + expect(resumePayload.tools[0].isTerminal).toBe(true); + }); + + it("omits tool isTerminal 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].isTerminal).toBeUndefined(); + }); + it("forwards new session options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/tool_results.e2e.test.ts b/nodejs/test/e2e/tool_results.e2e.test.ts index 6e8729c429..eb6ecf6f79 100644 --- a/nodejs/test/e2e/tool_results.e2e.test.ts +++ b/nodejs/test/e2e/tool_results.e2e.test.ts @@ -59,6 +59,7 @@ describe("Tool Results", async () => { tools: [ defineTool("check_status", { description: "Checks the status of a service", + isTerminal: true, handler: (): ToolResultObject => ({ textResultForLlm: "Service unavailable", resultType: "failure", @@ -74,6 +75,7 @@ describe("Tool Results", async () => { const failureContent = assistantMessage?.data.content ?? ""; expect(failureContent).toMatch(/service is down/i); + expect(await openAiEndpoint.getExchanges()).toHaveLength(2); await session.disconnect(); }); diff --git a/nodejs/test/e2e/tools.e2e.test.ts b/nodejs/test/e2e/tools.e2e.test.ts index c505f8aa86..7ca943aa79 100644 --- a/nodejs/test/e2e/tools.e2e.test.ts +++ b/nodejs/test/e2e/tools.e2e.test.ts @@ -7,7 +7,7 @@ import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; import { defineTool, approveAll, ToolSet } from "../../src/index.js"; -import type { PermissionRequest } from "../../src/index.js"; +import type { CopilotSession, PermissionRequest, SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -45,6 +45,46 @@ describe("Custom tools", async () => { expect(assistantMessage?.data.content).toContain("HELLO"); }); + it("clears context from a terminal tool and starts the seeded turn", async () => { + const seedPrompt = "Reply with exactly FRESH_CONTEXT."; + const events: SessionEvent[] = []; + let session: CopilotSession; + session = await client.createSession({ + onPermissionRequest: approveAll, + onEvent: (event) => events.push(event), + tools: [ + defineTool("clear_context", { + description: "Clears the conversation and starts a fresh context window", + parameters: z.object({ prompt: z.string() }), + isTerminal: true, + defer: "never", + handler: async () => { + const result = await session.rpc.history.clearContext({ + prompt: seedPrompt, + }); + return `Cleared ${result.messagesCleared} messages.`; + }, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: `Call clear_context with prompt "${seedPrompt}" now.`, + }); + + expect(assistantMessage?.data.content).toContain("FRESH_CONTEXT"); + const contextCleared = events.find((event) => event.type === "session.context_cleared"); + expect(contextCleared).toBeDefined(); + if (contextCleared?.type === "session.context_cleared") { + expect(contextCleared.data.messagesCleared).toBeGreaterThan(0); + expect(contextCleared.data.initialMessage).toBe(seedPrompt); + } + + const traffic = await openAiEndpoint.getExchanges(); + expect(traffic).toHaveLength(2); + expect(JSON.stringify(traffic[1]?.request.messages)).toContain(seedPrompt); + }); + it("low_level_tool_definition", async () => { let currentPhase = ""; const session = await client.createSession({ diff --git a/python/copilot/client.py b/python/copilot/client.py index 737619ef35..ee5bb258c2 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2277,6 +2277,8 @@ async def create_session( definition["defer"] = tool.defer if tool.metadata is not None: definition["metadata"] = tool.metadata + if tool.is_terminal: + definition["isTerminal"] = True tool_defs.append(definition) # Empty-mode validation and normalization @@ -2980,6 +2982,8 @@ async def resume_session( definition["defer"] = tool.defer if tool.metadata is not None: definition["metadata"] = tool.metadata + if tool.is_terminal: + definition["isTerminal"] = True tool_defs.append(definition) # Empty-mode validation and normalization diff --git a/python/copilot/tools.py b/python/copilot/tools.py index de81fe7fd8..dc709cf7d5 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -82,6 +82,11 @@ class Tool: skip_permission: bool = False defer: Literal["auto", "never"] | None = None metadata: dict[str, Any] | None = None + #: When true, a successful call to this tool ends the agent turn: the + #: runtime halts instead of feeding the result back to the model for + #: another round. A failed call leaves the loop running so the model can + #: read the error and retry. + is_terminal: bool = False T = TypeVar("T", bound=BaseModel) @@ -97,6 +102,7 @@ def define_tool( skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, metadata: dict[str, Any] | None = None, + is_terminal: bool = False, ) -> Callable[[Callable[..., Any]], Tool]: pass @@ -112,6 +118,7 @@ def define_tool( skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, metadata: dict[str, Any] | None = None, + is_terminal: bool = False, ) -> Tool: pass @@ -127,6 +134,7 @@ def define_tool( skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, metadata: dict[str, Any] | None = None, + is_terminal: bool = False, ) -> Tool: pass @@ -141,6 +149,7 @@ def define_tool( skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, metadata: dict[str, Any] | None = None, + is_terminal: bool = False, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -193,6 +202,10 @@ def lookup_issue(params: LookupIssueParams) -> str: 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. + is_terminal: When True, a successful call to this tool ends the agent turn: + the runtime halts instead of feeding the result back to the model + for another round. A failed call leaves the loop running so the + model can read the error and retry. Returns: A Tool instance @@ -288,6 +301,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: skip_permission=skip_permission, defer=defer, metadata=metadata, + is_terminal=is_terminal, ) # If handler is provided, call decorator immediately @@ -308,6 +322,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: skip_permission=skip_permission, defer=defer, metadata=metadata, + is_terminal=is_terminal, ) # Otherwise return decorator for @define_tool(...) usage diff --git a/python/test_client.py b/python/test_client.py index 0bba1ccd7f..028b665fd5 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -793,6 +793,45 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_tool_is_terminal(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 + tool = Tool(name="my_tool", description="a tool", is_terminal=True) + 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]["isTerminal"] is True + # Omitted when left at its default. + assert "isTerminal" not in create_tools[1] + assert captured["session.resume"]["tools"][0]["isTerminal"] is True + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_canvas_provider(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index d2b8dcb93a..3370843696 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -346,6 +346,12 @@ pub struct Tool { /// access control. #[serde(default, skip_serializing_if = "is_false")] pub skip_permission: bool, + /// When `true`, a successful call to this tool ends the agent turn: the + /// runtime's tool phase halts instead of feeding the result back to the + /// model for another round. A failed call leaves the loop running so the + /// model can read the error and retry. + #[serde(default, skip_serializing_if = "is_false")] + pub is_terminal: 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 @@ -470,6 +476,18 @@ impl Tool { self } + /// Sets whether a successful call to this tool ends the agent turn. + /// + /// When `true`, the runtime's tool phase halts after a successful call + /// instead of feeding the result back to the model for another round. A + /// failed call leaves the loop running so the model can read the error and + /// retry. + #[must_use] + pub fn with_is_terminal(mut self, is_terminal: bool) -> Self { + self.is_terminal = is_terminal; + self + } + /// Set the deferral mode controlling whether the tool may be loaded /// lazily via tool search ([`DeferMode::Auto`]) or always pre-loaded /// ([`DeferMode::Never`]). @@ -512,6 +530,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("is_terminal", &self.is_terminal) .field("defer", &self.defer) .field("metadata", &self.metadata) .field( @@ -7516,3 +7535,50 @@ mod permission_builder_tests { assert!(json.get("isExperimentalMode").is_none()); } } + +#[cfg(test)] +mod is_terminal_tests { + use super::Tool; + + #[test] + fn is_terminal_serializes_as_camel_case_when_set() { + let tool = Tool { + name: "clear_context".to_owned(), + is_terminal: true, + ..Default::default() + }; + let value = serde_json::to_value(&tool).expect("tool serializes"); + assert_eq!( + value.get("isTerminal"), + Some(&serde_json::Value::Bool(true)) + ); + } + + #[test] + fn is_terminal_is_omitted_when_false() { + let tool = Tool { + name: "plain".to_owned(), + ..Default::default() + }; + let value = serde_json::to_value(&tool).expect("tool serializes"); + assert!(value.get("isTerminal").is_none()); + } + + /// `Tool` has a hand-written `Debug` impl, so a new field is only reported + /// if it is added there by hand. Guard against that drift. + #[test] + fn is_terminal_appears_in_debug_output() { + let terminal = Tool { + name: "clear_context".to_owned(), + is_terminal: true, + ..Default::default() + }; + assert!(format!("{terminal:?}").contains("is_terminal: true")); + + let plain = Tool { + name: "plain".to_owned(), + ..Default::default() + }; + assert!(format!("{plain:?}").contains("is_terminal: false")); + } +} diff --git a/test/snapshots/tools/clears_context_from_a_terminal_tool_and_starts_the_seeded_turn.yaml b/test/snapshots/tools/clears_context_from_a_terminal_tool_and_starts_the_seeded_turn.yaml new file mode 100644 index 0000000000..36d5adce4a --- /dev/null +++ b/test/snapshots/tools/clears_context_from_a_terminal_tool_and_starts_the_seeded_turn.yaml @@ -0,0 +1,22 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Call clear_context with prompt "Reply with exactly FRESH_CONTEXT." now. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: clear_context + arguments: '{"prompt":"Reply with exactly FRESH_CONTEXT."}' + - messages: + - role: system + content: ${system} + - role: user + content: Reply with exactly FRESH_CONTEXT. + - role: assistant + content: FRESH_CONTEXT