mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add always approve helpers, improve sample and fix bug (#5451)
* Add always approve helpers, improve sample and fix bug * Address PR comments
This commit is contained in:
@@ -61,6 +61,21 @@ public static class HarnessConsole
|
||||
|
||||
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens)
|
||||
{
|
||||
// Initial user input
|
||||
var approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(userInput, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
|
||||
var messagesToSend = PromptForApprovals(approvalRequests);
|
||||
|
||||
// Loop while there are approval responses to send back
|
||||
while (messagesToSend is not null)
|
||||
{
|
||||
approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(messagesToSend, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens);
|
||||
messagesToSend = PromptForApprovals(approvalRequests);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ToolApprovalRequestContent>> StreamAndCollectApprovalsAsync(IAsyncEnumerable<AgentResponseUpdate> updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens)
|
||||
{
|
||||
var approvalRequests = new List<ToolApprovalRequestContent>();
|
||||
string mode = modeProvider?.GetMode(session) ?? "unknown";
|
||||
System.Console.ForegroundColor = GetModeColor(mode);
|
||||
System.Console.Write($"\n[{mode}] Agent: ");
|
||||
@@ -72,7 +87,7 @@ public static class HarnessConsole
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(userInput, session))
|
||||
await foreach (var update in updates)
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
@@ -96,6 +111,17 @@ public static class HarnessConsole
|
||||
hasTextOutput = false;
|
||||
spinner.Start();
|
||||
}
|
||||
else if (content is ToolApprovalRequestContent approvalRequest)
|
||||
{
|
||||
await spinner.StopAsync();
|
||||
approvalRequests.Add(approvalRequest);
|
||||
string toolName = approvalRequest.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
System.Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
System.Console.Write(hasTextOutput ? "\n\n ⚠️ Approval needed: " : "\n ⚠️ Approval needed: ");
|
||||
System.Console.Write(toolName);
|
||||
System.Console.ForegroundColor = GetModeColor(mode);
|
||||
hasTextOutput = false;
|
||||
}
|
||||
else if (content is ErrorContent errorContent)
|
||||
{
|
||||
await spinner.StopAsync();
|
||||
@@ -174,7 +200,7 @@ public static class HarnessConsole
|
||||
|
||||
await spinner.StopAsync();
|
||||
|
||||
if (!hasReceivedAnyText)
|
||||
if (!hasReceivedAnyText && approvalRequests.Count == 0)
|
||||
{
|
||||
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
System.Console.Write("\n (no text response from agent)");
|
||||
@@ -183,6 +209,59 @@ public static class HarnessConsole
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine();
|
||||
|
||||
return approvalRequests;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user for approval of each tool approval request.
|
||||
/// Returns a list of messages to send back to the agent, or <see langword="null"/> if there are no requests.
|
||||
/// </summary>
|
||||
private static List<ChatMessage>? PromptForApprovals(List<ToolApprovalRequestContent> approvalRequests)
|
||||
{
|
||||
if (approvalRequests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var responses = new List<AIContent>();
|
||||
foreach (var request in approvalRequests)
|
||||
{
|
||||
string toolName = request.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : request.ToolCall?.ToString() ?? "unknown";
|
||||
|
||||
System.Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
System.Console.WriteLine($"\n 🔐 Tool approval required: {toolName}");
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine(" 1) Approve this call");
|
||||
System.Console.WriteLine(" 2) Always approve this tool (any arguments)");
|
||||
System.Console.WriteLine(" 3) Always approve this tool with these arguments");
|
||||
System.Console.WriteLine(" 4) Deny");
|
||||
System.Console.Write(" Choice [1-4]: ");
|
||||
|
||||
string? choice = System.Console.ReadLine()?.Trim();
|
||||
AIContent response = choice switch
|
||||
{
|
||||
"2" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||
"3" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||
"4" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||
};
|
||||
|
||||
string action = choice switch
|
||||
{
|
||||
"2" => "✅ Always approved (any args)",
|
||||
"3" => "✅ Always approved (these args)",
|
||||
"4" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
System.Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
System.Console.WriteLine($" {action}");
|
||||
System.Console.ResetColor();
|
||||
|
||||
responses.Add(response);
|
||||
}
|
||||
|
||||
return [new ChatMessage(ChatRole.User, responses)];
|
||||
}
|
||||
|
||||
private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input)
|
||||
|
||||
@@ -29,31 +29,6 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
|
||||
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled
|
||||
// so that chat history is managed locally by the agent framework.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) };
|
||||
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
|
||||
.Build();
|
||||
|
||||
// Create web browsing tools for downloading and converting HTML pages to markdown.
|
||||
var webBrowsingTools = new WebBrowsingTools();
|
||||
|
||||
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// and research-focused instructions including the mandatory planning workflow.
|
||||
var instructions =
|
||||
@@ -123,36 +98,70 @@ var instructions =
|
||||
When a temporary file is no longer needed, delete it to keep file memory tidy.
|
||||
""";
|
||||
|
||||
AIAgent agent = new ChatClientAgent(
|
||||
chatClient,
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new TodoProvider(),
|
||||
new AgentModeProvider(),
|
||||
new FileMemoryProvider(
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
],
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
UseProvidedChatClientAsIs = true,
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
|
||||
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
AIAgent agent =
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||
new OpenAIClient(
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
}),
|
||||
ChatOptions = new ChatOptions
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
|
||||
|
||||
// Build a ChatClient Pipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
|
||||
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
|
||||
|
||||
// Build our agent on top of the ChatClient Pipeline
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
// Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
// This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length.
|
||||
MaxOutputTokens = 128_000,
|
||||
Instructions = instructions,
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
|
||||
},
|
||||
});
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
|
||||
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.
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
|
||||
new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
|
||||
}),
|
||||
AIContextProviders =
|
||||
[
|
||||
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
||||
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
})
|
||||
.AsBuilder()
|
||||
.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.
|
||||
.Build();
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
+18
-9
@@ -2,25 +2,34 @@
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a web browsing tool that downloads HTML pages and converts them to markdown.
|
||||
/// An AI function that downloads HTML pages and converts them to markdown.
|
||||
/// </summary>
|
||||
internal sealed partial class WebBrowsingTools
|
||||
internal sealed partial class WebBrowsingTool : AIFunction
|
||||
{
|
||||
private static readonly HttpClient s_httpClient = new();
|
||||
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the web browsing tools.
|
||||
/// </summary>
|
||||
public IList<AITool> Tools { get; } =
|
||||
[
|
||||
AIFunctionFactory.Create(DownloadUriAsync),
|
||||
];
|
||||
/// <inheritdoc/>
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
[Description("Download the html from the given url as markdown")]
|
||||
private static async Task<string> DownloadUriAsync(
|
||||
Reference in New Issue
Block a user