From 756630b1015e691750650d60a249e2f3370e292d Mon Sep 17 00:00:00 2001 From: examon Date: Wed, 29 Jul 2026 15:54:42 +0200 Subject: [PATCH 1/3] Add history.clearContext and Tool.isTerminal across all SDKs Regenerates the RPC clients for the new `session.history.clearContext` method and the `session.context_cleared` event, and adds a hand-authored `isTerminal` tool flag to every language surface. `isTerminal` lets a tool declare that a successful call 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. Without it a turn-ending tool can only approximate the behavior by returning a rejected result, which halts the loop but is semantically wrong. Per language: - Node.js: `Tool.isTerminal`, `defineTool` config, both session-config serialization sites. - Go: `Tool.IsTerminal` with `json:"isTerminal,omitempty"`. - Python: `Tool.is_terminal`, `define_tool` overloads, both client serialization sites. - Rust: `Tool::is_terminal`, skipped when false. - Java: `ToolDefinition.isTerminal` as a record component, plus a seven-argument convenience constructor so existing call sites keep compiling. - .NET: `CopilotToolOptions.IsTerminal`, the `is_terminal` additional-property key, and the wire `ToolDefinition`. Adds serialization tests in Go, Rust and Java covering both the camelCase wire name and omission when unset; the Java test also pins the seven-argument constructor so the record change stays source-compatible. --- dotnet/src/Client.cs | 7 +- dotnet/src/CopilotTool.cs | 20 +++++- dotnet/src/Generated/SessionEvents.cs | 31 +++++++++ dotnet/test/Unit/CopilotToolTests.cs | 22 ++++++ go/client_test.go | 37 ++++++++++ go/types.go | 5 ++ .../copilot/generated/SessionEvent.java | 4 +- .../github/copilot/rpc/ToolDefinition.java | 69 ++++++++++++++++--- .../rpc/ToolDefinitionIsTerminalTest.java | 51 ++++++++++++++ nodejs/src/client.ts | 2 + nodejs/src/generated/session-events.ts | 50 +++++++++++++- nodejs/src/types.ts | 12 ++++ python/copilot/client.py | 4 ++ python/copilot/tools.py | 15 ++++ rust/src/generated/session_events.rs | 18 +++++ rust/src/types.rs | 44 ++++++++++++ 16 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 java/src/test/java/com/github/copilot/rpc/ToolDefinitionIsTerminalTest.java 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/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 6b81a4770d..2e05bd5418 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1687,6 +1687,19 @@ public sealed partial class McpAppToolCallCompleteEvent : SessionEvent public required McpAppToolCallCompleteData Data { get; set; } } +/// Context-cleared details emitted when the clear_context tool resets the conversation. +/// Represents the session.context_cleared event. +public sealed partial class SessionContextClearedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.context_cleared"; + + /// The session.context_cleared event payload. + [JsonPropertyName("data")] + public required SessionContextClearedData Data { get; set; } +} + /// Session initialization metadata including context and configuration. public sealed partial class SessionStartData { @@ -4709,6 +4722,24 @@ public sealed partial class McpAppToolCallCompleteData public required string ToolName { get; set; } } +/// Context-cleared details emitted when the clear_context tool resets the conversation. +public sealed partial class SessionContextClearedData +{ + /// Optional initial message set after clearing. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("initialMessage")] + public string? InitialMessage { get; set; } + + /// Number of conversation messages that were cleared. + [JsonPropertyName("messagesCleared")] + public required long MessagesCleared { get; set; } + + /// Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("prependMessages")] + public string[]? PrependMessages { get; set; } +} + /// Working directory and git context at session start. /// Nested data type for WorkingDirectoryContext. public sealed partial class WorkingDirectoryContext 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/generated/java/com/github/copilot/generated/SessionEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java index 582ecd3d4f..1292a5a3e2 100644 --- a/java/src/generated/java/com/github/copilot/generated/SessionEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java @@ -139,7 +139,8 @@ @JsonSubTypes.Type(value = SessionCanvasRecordedEvent.class, name = "session.canvas.recorded"), @JsonSubTypes.Type(value = SessionCanvasRemovedEvent.class, name = "session.canvas.removed"), @JsonSubTypes.Type(value = SessionExtensionsAttachmentsPushedEvent.class, name = "session.extensions.attachments_pushed"), - @JsonSubTypes.Type(value = McpAppToolCallCompleteEvent.class, name = "mcp_app.tool_call_complete") + @JsonSubTypes.Type(value = McpAppToolCallCompleteEvent.class, name = "mcp_app.tool_call_complete"), + @JsonSubTypes.Type(value = SessionContextClearedEvent.class, name = "session.context_cleared") }) @javax.annotation.processing.Generated("copilot-sdk-codegen") public abstract sealed class SessionEvent permits @@ -258,6 +259,7 @@ public abstract sealed class SessionEvent permits SessionCanvasRemovedEvent, SessionExtensionsAttachmentsPushedEvent, McpAppToolCallCompleteEvent, + SessionContextClearedEvent, UnknownSessionEvent { /** Unique event identifier (UUID v4), generated when the event is emitted. */ 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..67616c71c9 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,14 @@ 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 +120,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 +340,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 +355,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 +371,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 +386,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.7 + */ + @CopilotExperimental + public ToolDefinition isTerminal(boolean value) { + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, + metadata, value); } // ------------------------------------------------------------------ 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/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/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 1dad4ab166..07cfceadef 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -119,7 +119,8 @@ export type SessionEvent = | CanvasRecordedEvent | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent - | McpAppToolCallCompleteEvent; + | McpAppToolCallCompleteEvent + | ContextClearedEvent; /** * Hosting platform type of the repository (github or ado) */ @@ -10013,3 +10014,50 @@ export interface McpAppToolCallCompleteToolMetaUI { */ visibility?: string[]; } +/** + * Session event "session.context_cleared". Context-cleared details emitted when the clear_context tool resets the conversation + */ +export interface ContextClearedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: ContextClearedData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "session.context_cleared". + */ + type: "session.context_cleared"; +} +/** + * Context-cleared details emitted when the clear_context tool resets the conversation + */ +export interface ContextClearedData { + /** + * Optional initial message set after clearing + */ + initialMessage?: string; + /** + * Number of conversation messages that were cleared + */ + messagesCleared: number; + /** + * Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. + */ + prependMessages?: string[]; +} 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/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/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index ef3c395b10..abc51e1112 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -318,6 +318,8 @@ pub enum SessionEventType { SessionExtensionsAttachmentsPushed, #[serde(rename = "mcp_app.tool_call_complete")] McpAppToolCallComplete, + #[serde(rename = "session.context_cleared")] + SessionContextCleared, /// Unknown event type for forward compatibility. #[default] #[serde(other)] @@ -630,6 +632,8 @@ pub enum SessionEventData { SessionExtensionsAttachmentsPushed(SessionExtensionsAttachmentsPushedData), #[serde(rename = "mcp_app.tool_call_complete")] McpAppToolCallComplete(McpAppToolCallCompleteData), + #[serde(rename = "session.context_cleared")] + SessionContextCleared(SessionContextClearedData), } /// A session event with typed data payload. @@ -5018,6 +5022,20 @@ pub struct McpAppToolCallCompleteData { pub tool_name: String, } +/// Session event "session.context_cleared". Context-cleared details emitted when the clear_context tool resets the conversation +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionContextClearedData { + /// Optional initial message set after clearing + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_message: Option, + /// Number of conversation messages that were cleared + pub messages_cleared: i64, + /// Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. + #[serde(skip_serializing_if = "Option::is_none")] + pub prepend_messages: Option>, +} + /// Hosting platform type of the repository (github or ado) #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum WorkingDirectoryContextHostType { diff --git a/rust/src/types.rs b/rust/src/types.rs index d2b8dcb93a..bee5e854d0 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`]). @@ -7516,3 +7534,29 @@ 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()); + } +} From 1ca9787ce17e76ad5aa7f62d94af941343668940 Mon Sep 17 00:00:00 2001 From: examon Date: Wed, 29 Jul 2026 21:20:14 +0000 Subject: [PATCH 2/3] Preserve isTerminal in Java fluent copies, add Node isTerminal wire tests Resolves the rebase onto main where both sides added an eighth tool option: main added metadata and this branch added isTerminal. - Java ToolDefinition now carries metadata and isTerminal as separate record components, keeps the seven- and eight-argument convenience constructors, and threads isTerminal through every fluent copy method so it is no longer dropped by .metadata()/.defer()/etc. - Adds ToolDefinition.isTerminal(boolean) so lambda-defined tools can set it, matching the other flags. - Adds the missing Node regression tests asserting isTerminal is forwarded on both session.create and session.resume, and omitted when unset. - Applies the repo rust formatter to the new is_terminal test. --- .../github/copilot/rpc/ToolDefinition.java | 7 +-- nodejs/test/client.test.ts | 62 +++++++++++++++++++ rust/src/types.rs | 5 +- 3 files changed, 69 insertions(+), 5 deletions(-) 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 67616c71c9..05ad1b9cf6 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -95,8 +95,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d @JsonProperty("metadata") Map metadata, @JsonProperty("isTerminal") Boolean isTerminal) { /** - * Creates a tool definition without a {@code metadata} bag or terminality - * hint. + * Creates a tool definition without a {@code metadata} bag or terminality hint. *

* Convenience overload equivalent to the canonical constructor with * {@code metadata} and {@code isTerminal} set to {@code null}. @@ -393,8 +392,8 @@ public ToolDefinition metadata(Map value) { * 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 + * {@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.7 */ 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/rust/src/types.rs b/rust/src/types.rs index bee5e854d0..fd22efc9cc 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -7547,7 +7547,10 @@ mod is_terminal_tests { ..Default::default() }; let value = serde_json::to_value(&tool).expect("tool serializes"); - assert_eq!(value.get("isTerminal"), Some(&serde_json::Value::Bool(true))); + assert_eq!( + value.get("isTerminal"), + Some(&serde_json::Value::Bool(true)) + ); } #[test] From 012f98134199fa1e68782bdb4db86fded6d955e3 Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 31 Jul 2026 12:29:07 +0200 Subject: [PATCH 3/3] Regenerate clearContext bindings for the tightened runtime contract Mirrors github/copilot-agent-runtime#14002 after review: - `HistoryClearContextRequest.prompt` is now required. A cleared window holding only system and developer messages is not a conversation a model can answer, so every clear seeds the window it creates. - `HistoryClearContextResult` loses the `cleared` discriminator. The RPC now rejects the cases it was meant to describe - a remote session, or a call made while no tool call is in flight - so there is one error channel instead of a success flag plus an error channel. - `ContextClearedData.prependMessages` is gone. It had no producer, and it was a permanent commitment on a durable event for a code path nothing exercised. Regenerated with `scripts/codegen`; only the clear-context hunks are taken, so unrelated schema drift stays out of this PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/SessionEvents.cs | 31 ------------ .../copilot/generated/SessionEvent.java | 4 +- nodejs/src/generated/session-events.ts | 50 +------------------ rust/src/generated/session_events.rs | 18 ------- 4 files changed, 2 insertions(+), 101 deletions(-) diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 2e05bd5418..6b81a4770d 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1687,19 +1687,6 @@ public sealed partial class McpAppToolCallCompleteEvent : SessionEvent public required McpAppToolCallCompleteData Data { get; set; } } -///

Context-cleared details emitted when the clear_context tool resets the conversation. -/// Represents the session.context_cleared event. -public sealed partial class SessionContextClearedEvent : SessionEvent -{ - /// - [JsonIgnore] - public override string Type => "session.context_cleared"; - - /// The session.context_cleared event payload. - [JsonPropertyName("data")] - public required SessionContextClearedData Data { get; set; } -} - /// Session initialization metadata including context and configuration. public sealed partial class SessionStartData { @@ -4722,24 +4709,6 @@ public sealed partial class McpAppToolCallCompleteData public required string ToolName { get; set; } } -/// Context-cleared details emitted when the clear_context tool resets the conversation. -public sealed partial class SessionContextClearedData -{ - /// Optional initial message set after clearing. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("initialMessage")] - public string? InitialMessage { get; set; } - - /// Number of conversation messages that were cleared. - [JsonPropertyName("messagesCleared")] - public required long MessagesCleared { get; set; } - - /// Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("prependMessages")] - public string[]? PrependMessages { get; set; } -} - /// Working directory and git context at session start. /// Nested data type for WorkingDirectoryContext. public sealed partial class WorkingDirectoryContext diff --git a/java/src/generated/java/com/github/copilot/generated/SessionEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java index 1292a5a3e2..582ecd3d4f 100644 --- a/java/src/generated/java/com/github/copilot/generated/SessionEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java @@ -139,8 +139,7 @@ @JsonSubTypes.Type(value = SessionCanvasRecordedEvent.class, name = "session.canvas.recorded"), @JsonSubTypes.Type(value = SessionCanvasRemovedEvent.class, name = "session.canvas.removed"), @JsonSubTypes.Type(value = SessionExtensionsAttachmentsPushedEvent.class, name = "session.extensions.attachments_pushed"), - @JsonSubTypes.Type(value = McpAppToolCallCompleteEvent.class, name = "mcp_app.tool_call_complete"), - @JsonSubTypes.Type(value = SessionContextClearedEvent.class, name = "session.context_cleared") + @JsonSubTypes.Type(value = McpAppToolCallCompleteEvent.class, name = "mcp_app.tool_call_complete") }) @javax.annotation.processing.Generated("copilot-sdk-codegen") public abstract sealed class SessionEvent permits @@ -259,7 +258,6 @@ public abstract sealed class SessionEvent permits SessionCanvasRemovedEvent, SessionExtensionsAttachmentsPushedEvent, McpAppToolCallCompleteEvent, - SessionContextClearedEvent, UnknownSessionEvent { /** Unique event identifier (UUID v4), generated when the event is emitted. */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 07cfceadef..1dad4ab166 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -119,8 +119,7 @@ export type SessionEvent = | CanvasRecordedEvent | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent - | McpAppToolCallCompleteEvent - | ContextClearedEvent; + | McpAppToolCallCompleteEvent; /** * Hosting platform type of the repository (github or ado) */ @@ -10014,50 +10013,3 @@ export interface McpAppToolCallCompleteToolMetaUI { */ visibility?: string[]; } -/** - * Session event "session.context_cleared". Context-cleared details emitted when the clear_context tool resets the conversation - */ -export interface ContextClearedEvent { - /** - * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. - */ - agentId?: string; - data: ContextClearedData; - /** - * When true, the event is transient and not persisted to the session event log on disk - */ - ephemeral?: boolean; - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * Type discriminator. Always "session.context_cleared". - */ - type: "session.context_cleared"; -} -/** - * Context-cleared details emitted when the clear_context tool resets the conversation - */ -export interface ContextClearedData { - /** - * Optional initial message set after clearing - */ - initialMessage?: string; - /** - * Number of conversation messages that were cleared - */ - messagesCleared: number; - /** - * Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. - */ - prependMessages?: string[]; -} diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index abc51e1112..ef3c395b10 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -318,8 +318,6 @@ pub enum SessionEventType { SessionExtensionsAttachmentsPushed, #[serde(rename = "mcp_app.tool_call_complete")] McpAppToolCallComplete, - #[serde(rename = "session.context_cleared")] - SessionContextCleared, /// Unknown event type for forward compatibility. #[default] #[serde(other)] @@ -632,8 +630,6 @@ pub enum SessionEventData { SessionExtensionsAttachmentsPushed(SessionExtensionsAttachmentsPushedData), #[serde(rename = "mcp_app.tool_call_complete")] McpAppToolCallComplete(McpAppToolCallCompleteData), - #[serde(rename = "session.context_cleared")] - SessionContextCleared(SessionContextClearedData), } /// A session event with typed data payload. @@ -5022,20 +5018,6 @@ pub struct McpAppToolCallCompleteData { pub tool_name: String, } -/// Session event "session.context_cleared". Context-cleared details emitted when the clear_context tool resets the conversation -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionContextClearedData { - /// Optional initial message set after clearing - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_message: Option, - /// Number of conversation messages that were cleared - pub messages_cleared: i64, - /// Runtime-injected messages re-seeded into the freshly-cleared context (e.g. self-paced loop wrappers). Persisted so a resumed session reproduces the same post-clear window instead of resurrecting the pre-clear history. - #[serde(skip_serializing_if = "Option::is_none")] - pub prepend_messages: Option>, -} - /// Hosting platform type of the repository (github or ado) #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum WorkingDirectoryContextHostType {