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
105 changes: 101 additions & 4 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,17 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
{
var connection = await EnsureConnectedAsync(cancellationToken);

// Extract AIFunctions from Tools (which can be either AIFunction or CopilotTool)
var aiFunctions = config?.Tools?
.Select(t => t is CopilotTool ct ? ct.Function : t as AIFunction)
.Where(f => f != null)
.Cast<AIFunction>()
.ToList();

var request = new CreateSessionRequest(
config?.Model,
config?.SessionId,
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
aiFunctions?.Select(ToolDefinition.FromAIFunction).ToList(),
config?.SystemMessage,
config?.AvailableTools,
config?.ExcludedTools,
Expand All @@ -351,7 +358,29 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig? config = nul
connection.Rpc, "session.create", [request], cancellationToken);

var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
session.RegisterTools(config?.Tools ?? []);

// Register tools with their approval settings
if (config?.Tools != null)
{
var copilotTools = new List<CopilotTool>();
foreach (var tool in config.Tools)
{
if (tool is CopilotTool ct)
{
copilotTools.Add(ct);
}
else if (tool is AIFunction af)
{
copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false });
}
else if (tool != null)
{
throw new ArgumentException($"Tool must be either AIFunction or CopilotTool, but was {tool.GetType().Name}");
}
}
session.RegisterTools(copilotTools);
}

if (config?.OnPermissionRequest != null)
{
session.RegisterPermissionHandler(config.OnPermissionRequest);
Expand Down Expand Up @@ -393,9 +422,16 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
{
var connection = await EnsureConnectedAsync(cancellationToken);

// Extract AIFunctions from Tools (which can be either AIFunction or CopilotTool)
var aiFunctions = config?.Tools?
.Select(t => t is CopilotTool ct ? ct.Function : t as AIFunction)
.Where(f => f != null)
.Cast<AIFunction>()
.ToList();

var request = new ResumeSessionRequest(
sessionId,
config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
aiFunctions?.Select(ToolDefinition.FromAIFunction).ToList(),
config?.Provider,
config?.OnPermissionRequest != null ? true : null,
config?.Streaming == true ? true : null,
Expand All @@ -408,7 +444,29 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
connection.Rpc, "session.resume", [request], cancellationToken);

var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
session.RegisterTools(config?.Tools ?? []);

// Register tools with their approval settings
if (config?.Tools != null)
{
var copilotTools = new List<CopilotTool>();
foreach (var tool in config.Tools)
{
if (tool is CopilotTool ct)
{
copilotTools.Add(ct);
}
else if (tool is AIFunction af)
{
copilotTools.Add(new CopilotTool { Function = af, RequiresApproval = false });
}
else if (tool != null)
{
throw new ArgumentException($"Tool must be either AIFunction or CopilotTool, but was {tool.GetType().Name}");
}
}
session.RegisterTools(copilotTools);
}

if (config?.OnPermissionRequest != null)
{
session.RegisterPermissionHandler(config.OnPermissionRequest);
Expand Down Expand Up @@ -872,6 +930,45 @@ public async Task<ToolCallResponse> OnToolCall(string sessionId,
});
}

// Check if tool requires approval
if (session.ToolRequiresApproval(toolName))
{
try
{
// Create permission request as JsonElement manually
var permissionRequestJson = $$"""
{
"kind": "tool",
"toolCallId": "{{toolCallId}}",
"toolName": "{{toolName}}"
}
""";
var permissionRequestElement = JsonDocument.Parse(permissionRequestJson).RootElement;

var permissionResult = await session.HandlePermissionRequestAsync(permissionRequestElement);

if (permissionResult.Kind != "approved")
{
return new ToolCallResponse(new ToolResultObject
{
TextResultForLlm = permissionResult.Kind == "denied-interactively-by-user"
? "Tool execution was denied by user."
: "Tool execution was denied.",
ResultType = "denied"
});
}
}
catch
{
// If permission handler fails or is not configured, deny the tool execution
return new ToolCallResponse(new ToolResultObject
{
TextResultForLlm = "Tool execution requires permission but no permission handler is configured.",
ResultType = "denied"
});
}
Comment on lines +961 to +969
}

try
{
var invocation = new ToolInvocation
Expand Down
35 changes: 34 additions & 1 deletion dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public partial class CopilotSession : IAsyncDisposable
{
private readonly HashSet<SessionEventHandler> _eventHandlers = new();
private readonly Dictionary<string, AIFunction> _toolHandlers = new();
private readonly Dictionary<string, bool> _toolRequiresApproval = new();
private readonly JsonRpc _rpc;
private PermissionHandler? _permissionHandler;
private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1);
Expand Down Expand Up @@ -255,12 +256,36 @@ internal void DispatchEvent(SessionEvent sessionEvent)
/// Tools allow the assistant to execute custom functions. When the assistant invokes a tool,
/// the corresponding handler is called with the tool arguments.
/// </remarks>
internal void RegisterTools(ICollection<AIFunction> tools)
internal void RegisterTools(ICollection<AIFunction>? tools)
{
_toolHandlers.Clear();
_toolRequiresApproval.Clear();
if (tools == null) return;

foreach (var tool in tools)
{
_toolHandlers.Add(tool.Name, tool);
_toolRequiresApproval[tool.Name] = false;
}
}

/// <summary>
/// Registers custom tool handlers for this session with requiresApproval support.
/// </summary>
/// <param name="tools">A collection of CopilotTools that can be invoked by the assistant.</param>
/// <remarks>
/// Tools allow the assistant to execute custom functions. When the assistant invokes a tool,
/// the corresponding handler is called with the tool arguments.
/// CopilotTools support the RequiresApproval flag for permission handling.
/// </remarks>
internal void RegisterTools(ICollection<CopilotTool> tools)
{
_toolHandlers.Clear();
_toolRequiresApproval.Clear();
foreach (var tool in tools)
{
_toolHandlers.Add(tool.Function.Name, tool.Function);
_toolRequiresApproval[tool.Function.Name] = tool.RequiresApproval;
}
}

Expand All @@ -272,6 +297,14 @@ internal void RegisterTools(ICollection<AIFunction> tools)
internal AIFunction? GetTool(string name) =>
_toolHandlers.TryGetValue(name, out var tool) ? tool : null;

/// <summary>
/// Checks if a tool requires approval before execution.
/// </summary>
/// <param name="name">The name of the tool to check.</param>
/// <returns>True if the tool requires approval, false otherwise.</returns>
internal bool ToolRequiresApproval(string name) =>
_toolRequiresApproval.TryGetValue(name, out var requiresApproval) && requiresApproval;

/// <summary>
/// Registers a handler for permission requests.
/// </summary>
Expand Down
41 changes: 39 additions & 2 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ public class ToolInvocation

public delegate Task<object?> ToolHandler(ToolInvocation invocation);

/// <summary>
/// Wraps an AIFunction with additional metadata for the Copilot SDK.
/// </summary>
public class CopilotTool
{
/// <summary>
/// The underlying AIFunction that handles tool execution.
/// </summary>
public AIFunction Function { get; set; } = null!;

/// <summary>
/// Controls whether the tool requires user approval before execution.
/// When true, the OnPermissionRequest handler will be called before invoking the tool.
/// When false or not specified, the tool executes without requesting permission.
/// </summary>
public bool RequiresApproval { get; set; }

/// <summary>
/// Creates a CopilotTool from an AIFunction with optional requiresApproval flag.
/// </summary>
/// <param name="function">The AIFunction to wrap.</param>
/// <param name="requiresApproval">Whether the tool requires approval before execution.</param>
/// <returns>A CopilotTool wrapping the provided function.</returns>
public static implicit operator CopilotTool(AIFunction function) =>
new() { Function = function, RequiresApproval = false };
}

public class PermissionRequest
{
[JsonPropertyName("kind")]
Expand Down Expand Up @@ -339,7 +366,12 @@ public class SessionConfig
/// </summary>
public string? ConfigDir { get; set; }

public ICollection<AIFunction>? Tools { get; set; }
/// <summary>
/// Tools that can be invoked by the assistant.
/// Can be either AIFunction instances or CopilotTool instances.
/// Use CopilotTool to specify RequiresApproval for permission handling.
/// </summary>
public ICollection<object>? Tools { get; set; }
public SystemMessageConfig? SystemMessage { get; set; }
public List<string>? AvailableTools { get; set; }
public List<string>? ExcludedTools { get; set; }
Expand Down Expand Up @@ -388,7 +420,12 @@ public class SessionConfig

public class ResumeSessionConfig
{
public ICollection<AIFunction>? Tools { get; set; }
/// <summary>
/// Tools that can be invoked by the assistant.
/// Can be either AIFunction instances or CopilotTool instances.
/// Use CopilotTool to specify RequiresApproval for permission handling.
/// </summary>
public ICollection<object>? Tools { get; set; }
public ProviderConfig? Provider { get; set; }

/// <summary>
Expand Down
123 changes: 123 additions & 0 deletions dotnet/test/ToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,127 @@ await session.SendAsync(new MessageOptions
SessionLog = "Returned an image",
});
}

[Fact]
public async Task Requests_Permission_For_Tools_With_RequiresApproval()
{
var permissionRequested = false;
string? permissionToolName = null;

var getWeather = new CopilotTool
{
Function = AIFunctionFactory.Create(
([Description("The city name")] string city) =>
{
return new { city, temperature = "72°F", condition = "sunny" };
},
"get_weather",
"Get the current weather for a city"),
RequiresApproval = true
};

var session = await Client.CreateSessionAsync(new SessionConfig
{
Tools = [getWeather],
OnPermissionRequest = (request, invocation) =>
{
if (request.Kind == "tool")
{
permissionRequested = true;
permissionToolName = request.ExtensionData?.GetValueOrDefault("toolName")?.ToString();
}
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
});

await session.SendAsync(new MessageOptions
{
Prompt = "What's the weather in Seattle?"
});

var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);

Assert.True(permissionRequested, "Permission should have been requested");
Assert.Equal("get_weather", permissionToolName);
Assert.Contains("72", assistantMessage?.Data.Content ?? string.Empty);
}

[Fact]
public async Task Denies_Tool_Execution_When_Permission_Denied()
{
var deleteFile = new CopilotTool
{
Function = AIFunctionFactory.Create(
([Description("File path")] string path) => $"Deleted {path}",
"delete_file",
"Deletes a file"),
RequiresApproval = true
};

var session = await Client.CreateSessionAsync(new SessionConfig
{
Tools = [deleteFile],
OnPermissionRequest = (request, invocation) =>
{
if (request.Kind == "tool")
{
return Task.FromResult(new PermissionRequestResult
{
Kind = "denied-interactively-by-user"
});
}
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
});

await session.SendAsync(new MessageOptions
{
Prompt = "Delete the file test.txt"
});

var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
var content = assistantMessage?.Data.Content?.ToLowerInvariant() ?? string.Empty;

Assert.True(
content.Contains("denied") || content.Contains("cannot") || content.Contains("unable"),
"Assistant should indicate the tool was denied");
}

[Fact]
public async Task Executes_Tools_Without_Permission_When_RequiresApproval_False()
{
var permissionRequested = false;

var addNumbers = new CopilotTool
{
Function = AIFunctionFactory.Create(
(int a, int b) => a + b,
"add_numbers",
"Adds two numbers"),
RequiresApproval = false
};

var session = await Client.CreateSessionAsync(new SessionConfig
{
Tools = [addNumbers],
OnPermissionRequest = (request, invocation) =>
{
if (request.Kind == "tool")
{
permissionRequested = true;
}
return Task.FromResult(new PermissionRequestResult { Kind = "approved" });
}
});

await session.SendAsync(new MessageOptions
{
Prompt = "What is 5 + 3?"
});

var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);

Assert.False(permissionRequested, "Permission should not have been requested");
Assert.Contains("8", assistantMessage?.Data.Content ?? string.Empty);
}
}
Loading
Loading