Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2788,19 +2788,22 @@ internal record ToolDefinition(
bool? OverridesBuiltInTool = null,
bool? SkipPermission = null,
CopilotToolDefer? Defer = null,
IDictionary<string, JsonNode?>? Metadata = null)
IDictionary<string, JsonNode?>? Metadata = null,
bool? IsTerminal = null)
{
public static ToolDefinition FromAIFunction(AIFunctionDeclaration function)
{
var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true;
var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true;
var defer = function.AdditionalProperties.TryGetValue(CopilotTool.DeferKey, out var deferVal) && deferVal is CopilotToolDefer d ? d : (CopilotToolDefer?)null;
var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary<string, JsonNode?> 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);
}
}

Expand Down
20 changes: 19 additions & 1 deletion dotnet/src/CopilotTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public static class CopilotTool
/// <summary>The key used in <see cref="AITool.AdditionalProperties"/> to indicate that a tool can execute without a permission prompt.</summary>
internal const string SkipPermissionKey = "skip_permission";

/// <summary>The key used in <see cref="AITool.AdditionalProperties"/> to indicate that a successful call to the tool ends the agent turn.</summary>
internal const string IsTerminalKey = "is_terminal";

/// <summary>The key used in <see cref="AITool.AdditionalProperties"/> to carry the tool's <see cref="CopilotToolDefer"/> deferral mode.</summary>
internal const string DeferKey = "defer";

Expand Down Expand Up @@ -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<string, object?> additionalProperties = new(StringComparer.Ordinal);
if (factoryOptions.AdditionalProperties is not null)
Expand All @@ -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;
Expand Down Expand Up @@ -152,6 +160,16 @@ public sealed class CopilotToolOptions
/// </remarks>
public bool SkipPermission { get; set; }

/// <summary>
/// Gets or sets a value indicating whether a successful call to this tool ends the agent turn.
/// </summary>
/// <remarks>
/// 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 <see cref="AIFunction"/> includes "is_terminal": true in its <see cref="AITool.AdditionalProperties"/>.
/// </remarks>
public bool IsTerminal { get; set; }

/// <summary>
/// Gets or sets a value controlling whether this tool may be deferred (loaded lazily via tool search) rather than always pre-loaded.
/// </summary>
Expand Down
22 changes: 22 additions & 0 deletions dotnet/test/Unit/CopilotToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
37 changes: 37 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
5 changes: 5 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
68 changes: 60 additions & 8 deletions java/src/main/java/com/github/copilot/rpc/ToolDefinition.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, Object> metadata) {
@JsonProperty("metadata") Map<String, Object> metadata, @JsonProperty("isTerminal") Boolean isTerminal) {
Comment thread
examon marked this conversation as resolved.

/**
* Creates a tool definition without a {@code metadata} bag.
* Creates a tool definition without a {@code metadata} bag or terminality hint.
* <p>
* 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
Expand All @@ -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.
* <p>
* 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<String, Object> metadata) {
this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, metadata, null);
}

/**
Expand Down Expand Up @@ -304,7 +339,8 @@ public static List<ToolDefinition> 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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -348,7 +385,22 @@ public ToolDefinition defer(ToolDefer value) {
@CopilotExperimental
public ToolDefinition metadata(Map<String, Object> 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);
}

// ------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,17 @@ export interface Tool<TArgs = unknown> {
* Unknown keys are preserved and round-tripped untouched.
*/
metadata?: Record<string, unknown>;
/**
* 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;
Comment thread
examon marked this conversation as resolved.
}

/**
Expand All @@ -672,6 +683,7 @@ export function defineTool<T = unknown>(
skipPermission?: boolean;
defer?: "auto" | "never";
metadata?: Record<string, unknown>;
isTerminal?: boolean;
}
): Tool<T> {
return { name, ...config };
Expand Down
Loading
Loading