Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.11
*/
@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