Skip to content

Commit e4595be

Browse files
authored
.NET: Add always approve helpers, improve sample and fix bug (#5451)
* Add always approve helpers, improve sample and fix bug * Address PR comments
1 parent 025655b commit e4595be

15 files changed

Lines changed: 3270 additions & 65 deletions

‎dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
6161

6262
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens)
6363
{
64+
// Initial user input
65+
var approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(userInput, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
66+
var messagesToSend = PromptForApprovals(approvalRequests);
67+
68+
// Loop while there are approval responses to send back
69+
while (messagesToSend is not null)
70+
{
71+
approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(messagesToSend, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
72+
messagesToSend = PromptForApprovals(approvalRequests);
73+
}
74+
}
75+
76+
private static async Task<List<ToolApprovalRequestContent>> StreamAndCollectApprovalsAsync(IAsyncEnumerable<AgentResponseUpdate> updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens)
77+
{
78+
var approvalRequests = new List<ToolApprovalRequestContent>();
6479
string mode = modeProvider?.GetMode(session) ?? "unknown";
6580
System.Console.ForegroundColor = GetModeColor(mode);
6681
System.Console.Write($"\n[{mode}] Agent: ");
@@ -72,7 +87,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
7287

7388
try
7489
{
75-
await foreach (var update in agent.RunStreamingAsync(userInput, session))
90+
await foreach (var update in updates)
7691
{
7792
foreach (var content in update.Contents)
7893
{
@@ -96,6 +111,17 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
96111
hasTextOutput = false;
97112
spinner.Start();
98113
}
114+
else if (content is ToolApprovalRequestContent approvalRequest)
115+
{
116+
await spinner.StopAsync();
117+
approvalRequests.Add(approvalRequest);
118+
string toolName = approvalRequest.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : approvalRequest.ToolCall?.ToString() ?? "unknown";
119+
System.Console.ForegroundColor = ConsoleColor.Yellow;
120+
System.Console.Write(hasTextOutput ? "\n\n ⚠️ Approval needed: " : "\n ⚠️ Approval needed: ");
121+
System.Console.Write(toolName);
122+
System.Console.ForegroundColor = GetModeColor(mode);
123+
hasTextOutput = false;
124+
}
99125
else if (content is ErrorContent errorContent)
100126
{
101127
await spinner.StopAsync();
@@ -174,7 +200,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
174200

175201
await spinner.StopAsync();
176202

177-
if (!hasReceivedAnyText)
203+
if (!hasReceivedAnyText && approvalRequests.Count == 0)
178204
{
179205
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
180206
System.Console.Write("\n (no text response from agent)");
@@ -183,6 +209,59 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
183209
System.Console.ResetColor();
184210
System.Console.WriteLine();
185211
System.Console.WriteLine();
212+
213+
return approvalRequests;
214+
}
215+
216+
/// <summary>
217+
/// Prompts the user for approval of each tool approval request.
218+
/// Returns a list of messages to send back to the agent, or <see langword="null"/> if there are no requests.
219+
/// </summary>
220+
private static List<ChatMessage>? PromptForApprovals(List<ToolApprovalRequestContent> approvalRequests)
221+
{
222+
if (approvalRequests.Count == 0)
223+
{
224+
return null;
225+
}
226+
227+
var responses = new List<AIContent>();
228+
foreach (var request in approvalRequests)
229+
{
230+
string toolName = request.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : request.ToolCall?.ToString() ?? "unknown";
231+
232+
System.Console.ForegroundColor = ConsoleColor.Yellow;
233+
System.Console.WriteLine($"\n 🔐 Tool approval required: {toolName}");
234+
System.Console.ResetColor();
235+
System.Console.WriteLine(" 1) Approve this call");
236+
System.Console.WriteLine(" 2) Always approve this tool (any arguments)");
237+
System.Console.WriteLine(" 3) Always approve this tool with these arguments");
238+
System.Console.WriteLine(" 4) Deny");
239+
System.Console.Write(" Choice [1-4]: ");
240+
241+
string? choice = System.Console.ReadLine()?.Trim();
242+
AIContent response = choice switch
243+
{
244+
"2" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
245+
"3" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
246+
"4" => request.CreateResponse(approved: false, reason: "User denied"),
247+
_ => request.CreateResponse(approved: true, reason: "User approved"),
248+
};
249+
250+
string action = choice switch
251+
{
252+
"2" => "✅ Always approved (any args)",
253+
"3" => "✅ Always approved (these args)",
254+
"4" => "❌ Denied",
255+
_ => "✅ Approved",
256+
};
257+
System.Console.ForegroundColor = ConsoleColor.DarkGray;
258+
System.Console.WriteLine($" {action}");
259+
System.Console.ResetColor();
260+
261+
responses.Add(response);
262+
}
263+
264+
return [new ChatMessage(ChatRole.User, responses)];
186265
}
187266

188267
private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input)

‎dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs‎

Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,6 @@
2929
const int MaxContextWindowTokens = 1_050_000;
3030
const int MaxOutputTokens = 128_000;
3131

32-
// Create a compaction strategy based on the model's context window.
33-
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
34-
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
35-
var compactionStrategy = new ContextWindowCompactionStrategy(
36-
maxContextWindowTokens: MaxContextWindowTokens,
37-
maxOutputTokens: MaxOutputTokens);
38-
39-
// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled
40-
// so that chat history is managed locally by the agent framework.
41-
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
42-
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
43-
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
44-
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) };
45-
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
46-
.GetResponsesClient()
47-
.AsIChatClientWithStoredOutputDisabled(deploymentName)
48-
.AsBuilder()
49-
.UseFunctionInvocation()
50-
.UsePerServiceCallChatHistoryPersistence()
51-
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
52-
.Build();
53-
54-
// Create web browsing tools for downloading and converting HTML pages to markdown.
55-
var webBrowsingTools = new WebBrowsingTools();
56-
5732
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
5833
// and research-focused instructions including the mandatory planning workflow.
5934
var instructions =
@@ -123,36 +98,70 @@ Also save intermediate notes and findings as you go — this helps with long mul
12398
When a temporary file is no longer needed, delete it to keep file memory tidy.
12499
""";
125100

126-
AIAgent agent = new ChatClientAgent(
127-
chatClient,
128-
new ChatClientAgentOptions
129-
{
130-
Name = "ResearchAgent",
131-
Description = "A research assistant that plans and executes research tasks.",
132-
AIContextProviders =
133-
[
134-
new TodoProvider(),
135-
new AgentModeProvider(),
136-
new FileMemoryProvider(
137-
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
138-
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
139-
],
140-
RequirePerServiceCallChatHistoryPersistence = true,
141-
UseProvidedChatClientAsIs = true,
142-
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
101+
// Create a compaction strategy based on the model's context window.
102+
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
103+
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
104+
var compactionStrategy = new ContextWindowCompactionStrategy(
105+
maxContextWindowTokens: MaxContextWindowTokens,
106+
maxOutputTokens: MaxOutputTokens);
107+
108+
AIAgent agent =
109+
// Create an OpenAIClient that communicates with the Foundry responses service.
110+
new OpenAIClient(
111+
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
112+
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
113+
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
114+
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
115+
new OpenAIClientOptions()
143116
{
144-
ChatReducer = compactionStrategy.AsChatReducer(),
145-
}),
146-
ChatOptions = new ChatOptions
117+
Endpoint = new Uri(endpoint),
118+
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
119+
})
120+
.GetResponsesClient()
121+
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
122+
123+
// Build a ChatClient Pipeline
124+
.AsBuilder()
125+
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
126+
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
127+
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
128+
129+
// Build our agent on top of the ChatClient Pipeline
130+
.BuildAIAgent(
131+
new ChatClientAgentOptions
147132
{
148-
// Set a high token limit for long research tasks with many tool calls and long outputs.
149-
// This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length.
150-
MaxOutputTokens = 128_000,
151-
Instructions = instructions,
152-
Reasoning = new() { Effort = ReasoningEffort.Medium },
153-
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
154-
},
155-
});
133+
Name = "ResearchAgent",
134+
Description = "A research assistant that plans and executes research tasks.",
135+
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
136+
RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run.
137+
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
138+
new InMemoryChatHistoryProviderOptions
139+
{
140+
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
141+
}),
142+
AIContextProviders =
143+
[
144+
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
145+
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
146+
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
147+
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
148+
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
149+
],
150+
ChatOptions = new ChatOptions
151+
{
152+
Instructions = instructions,
153+
Tools =
154+
[
155+
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
156+
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
157+
],
158+
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
159+
Reasoning = new() { Effort = ReasoningEffort.Medium },
160+
},
161+
})
162+
.AsBuilder()
163+
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
164+
.Build();
156165

157166
// Run the interactive console session using the shared HarnessConsole helper.
158167
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens);

dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs renamed to dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,34 @@
22

33
using System.ComponentModel;
44
using System.Net;
5+
using System.Text.Json;
56
using System.Text.RegularExpressions;
67
using Microsoft.Extensions.AI;
78

89
namespace SampleApp;
910

1011
/// <summary>
11-
/// Provides a web browsing tool that downloads HTML pages and converts them to markdown.
12+
/// An AI function that downloads HTML pages and converts them to markdown.
1213
/// </summary>
13-
internal sealed partial class WebBrowsingTools
14+
internal sealed partial class WebBrowsingTool : AIFunction
1415
{
1516
private static readonly HttpClient s_httpClient = new();
17+
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
1618

17-
/// <summary>
18-
/// Gets the web browsing tools.
19-
/// </summary>
20-
public IList<AITool> Tools { get; } =
21-
[
22-
AIFunctionFactory.Create(DownloadUriAsync),
23-
];
19+
/// <inheritdoc/>
20+
public override string Name => this._inner.Name;
21+
22+
/// <inheritdoc/>
23+
public override string Description => this._inner.Description;
24+
25+
/// <inheritdoc/>
26+
public override JsonElement JsonSchema => this._inner.JsonSchema;
27+
28+
/// <inheritdoc/>
29+
protected override ValueTask<object?> InvokeCoreAsync(
30+
AIFunctionArguments arguments,
31+
CancellationToken cancellationToken) =>
32+
this._inner.InvokeAsync(arguments, cancellationToken);
2433

2534
[Description("Download the html from the given url as markdown")]
2635
private static async Task<string> DownloadUriAsync(

‎dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ private static JsonSerializerOptions CreateDefaultOptions()
8181
// AgentModeProvider types
8282
[JsonSerializable(typeof(AgentModeState))]
8383

84+
// ToolApprovalAgent types
85+
[JsonSerializable(typeof(ToolApprovalState))]
86+
[JsonSerializable(typeof(ToolApprovalRule))]
87+
[JsonSerializable(typeof(List<ToolApprovalRule>), TypeInfoPropertyName = "ToolApprovalRuleList")]
88+
8489
// FileMemoryProvider types
8590
[JsonSerializable(typeof(FileMemoryState))]
8691
[JsonSerializable(typeof(FileSearchResult))]

‎dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseA
188188
while (hasUpdates)
189189
{
190190
var update = enumerator.Current;
191-
responseUpdates.Add(update);
191+
responseUpdates.Add(update.Clone());
192192

193193
// If the service returned a real ConversationId on any update, remember that.
194194
// Otherwise stamp our sentinel so FICC treats this as service-managed —
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics.CodeAnalysis;
4+
using Microsoft.Extensions.AI;
5+
using Microsoft.Shared.DiagnosticIds;
6+
using Microsoft.Shared.Diagnostics;
7+
8+
namespace Microsoft.Agents.AI;
9+
10+
/// <summary>
11+
/// Wraps a <see cref="ToolApprovalResponseContent"/> with additional "always approve" settings,
12+
/// enabling the <see cref="ToolApprovalAgent"/> middleware to record standing approval rules
13+
/// so that future matching tool calls are auto-approved without user interaction.
14+
/// </summary>
15+
/// <remarks>
16+
/// <para>
17+
/// Instances of this class should not be created directly. Instead, use the extension methods
18+
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolResponse"/> or
19+
/// <see cref="ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolWithArgumentsResponse"/>
20+
/// on <see cref="ToolApprovalRequestContent"/> to create instances with the appropriate flags set.
21+
/// </para>
22+
/// <para>
23+
/// The <see cref="ToolApprovalAgent"/> middleware will unwrap the <see cref="InnerResponse"/> to forward
24+
/// to the inner agent, while extracting the approval settings to persist as <see cref="ToolApprovalRule"/>
25+
/// entries in the session state.
26+
/// </para>
27+
/// </remarks>
28+
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
29+
public sealed class AlwaysApproveToolApprovalResponseContent : AIContent
30+
{
31+
/// <summary>
32+
/// Initializes a new instance of the <see cref="AlwaysApproveToolApprovalResponseContent"/> class.
33+
/// </summary>
34+
/// <param name="innerResponse">The underlying approval response to forward to the agent.</param>
35+
/// <param name="alwaysApproveTool">
36+
/// When <see langword="true"/>, all future calls to this tool type will be auto-approved.
37+
/// </param>
38+
/// <param name="alwaysApproveToolWithArguments">
39+
/// When <see langword="true"/>, all future calls to this tool type with the same arguments will be auto-approved.
40+
/// </param>
41+
internal AlwaysApproveToolApprovalResponseContent(
42+
ToolApprovalResponseContent innerResponse,
43+
bool alwaysApproveTool,
44+
bool alwaysApproveToolWithArguments)
45+
{
46+
this.InnerResponse = Throw.IfNull(innerResponse);
47+
this.AlwaysApproveTool = alwaysApproveTool;
48+
this.AlwaysApproveToolWithArguments = alwaysApproveToolWithArguments;
49+
}
50+
51+
/// <summary>
52+
/// Gets the underlying <see cref="ToolApprovalResponseContent"/> that will be forwarded to the inner agent.
53+
/// </summary>
54+
public ToolApprovalResponseContent InnerResponse { get; }
55+
56+
/// <summary>
57+
/// Gets a value indicating whether all future calls to the same tool should be auto-approved
58+
/// regardless of the arguments provided.
59+
/// </summary>
60+
public bool AlwaysApproveTool { get; }
61+
62+
/// <summary>
63+
/// Gets a value indicating whether all future calls to the same tool with the exact same
64+
/// arguments should be auto-approved.
65+
/// </summary>
66+
public bool AlwaysApproveToolWithArguments { get; }
67+
}

0 commit comments

Comments
 (0)