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 @@ -2772,19 +2772,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
37 changes: 37 additions & 0 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions dotnet/src/Generated/SessionEvents.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 @@ -3305,3 +3305,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")
}
})
}
49 changes: 49 additions & 0 deletions go/rpc/zrpc.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions go/rpc/zsession_encoding.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions go/rpc/zsession_events.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,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
Loading
Loading