Skip to content
Merged
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
Comment thread
rogerbarreto marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public static class HarnessConsole
/// <param name="agent">The agent to interact with.</param>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt)
/// <param name="maxContextWindowTokens">Optional max context window size in tokens. When set, usage is displayed as a percentage.</param>
/// <param name="maxOutputTokens">Optional max output tokens. Used with <paramref name="maxContextWindowTokens"/> to show input/output budget breakdown.</param>
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, int? maxContextWindowTokens = null, int? maxOutputTokens = null)
{
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
Expand All @@ -46,7 +48,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
}
else
{
await StreamAgentResponseAsync(agent, session, modeProvider, userInput);
await StreamAgentResponseAsync(agent, session, modeProvider, userInput, maxContextWindowTokens, maxOutputTokens);
}

WritePrompt(modeProvider, session);
Expand All @@ -57,7 +59,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP
System.Console.WriteLine("Goodbye!");
}

private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput)
private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens)
{
string mode = modeProvider?.GetMode(session) ?? "unknown";
System.Console.ForegroundColor = GetModeColor(mode);
Expand Down Expand Up @@ -106,6 +108,37 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s

System.Console.ForegroundColor = GetModeColor(mode);
}
else if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
await spinner.StopAsync();

if (!hasTextOutput)
{
System.Console.Write("\n");
hasTextOutput = true;
hasReceivedAnyText = true;
}

System.Console.ForegroundColor = ConsoleColor.DarkMagenta;
System.Console.Write(reasoning.Text);
System.Console.ForegroundColor = GetModeColor(mode);
}
else if (content is UsageContent usage)
{
await spinner.StopAsync();
System.Console.ForegroundColor = ConsoleColor.DarkGray;
System.Console.Write("\n\n 📊 Tokens");
if (usage.Details is not null)
{
WriteUsageBreakdown(usage.Details, maxContextWindowTokens, maxOutputTokens);
}
else
{
System.Console.Write(" —");
}
System.Console.ForegroundColor = GetModeColor(mode);
hasTextOutput = false;
}
}

if (string.IsNullOrEmpty(update.Text))
Expand Down Expand Up @@ -136,7 +169,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s
{
await spinner.StopAsync();
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}: {ex.Message}");
System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}:\n{ex}");
}
Comment thread
westey-m marked this conversation as resolved.

await spinner.StopAsync();
Expand Down Expand Up @@ -190,7 +223,7 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess
catch (ArgumentException ex)
{
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine($"\n {ex.Message}\n");
System.Console.WriteLine($"\n {ex}\n");
Comment thread
westey-m marked this conversation as resolved.
System.Console.ResetColor();
}
}
Expand Down Expand Up @@ -237,6 +270,38 @@ private static void PrintTodos(TodoProvider? todoProvider, AgentSession session)
System.Console.WriteLine();
}

private static void WriteUsageBreakdown(UsageDetails details, int? maxContextWindowTokens, int? maxOutputTokens)
{
int? inputBudget = (maxContextWindowTokens is not null && maxOutputTokens is not null)
? maxContextWindowTokens.Value - maxOutputTokens.Value
: null;

System.Console.Write(" — input: ");
WriteTokenCount(details.InputTokenCount, inputBudget);

System.Console.Write(" | output: ");
WriteTokenCount(details.OutputTokenCount, maxOutputTokens);

System.Console.Write(" | total: ");
WriteTokenCount(details.TotalTokenCount, maxContextWindowTokens);
}

private static void WriteTokenCount(long? count, int? budget)
{
if (count is null)
{
System.Console.Write("—");
return;
}

System.Console.Write($"{count.Value:N0}");
if (budget is not null && budget.Value > 0)
{
double pct = (double)count.Value / budget.Value * 100;
System.Console.Write($"/{budget.Value:N0} ({pct:F1}%)");
}
}

private static ConsoleColor GetModeColor(string mode) => mode switch
{
AgentModeProvider.PlanMode => ConsoleColor.Cyan,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
Comment thread
westey-m marked this conversation as resolved.
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>

Expand Down
38 changes: 27 additions & 11 deletions dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,40 @@
// exit — End the session.

#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.

using Azure.AI.Projects;
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using SampleApp;

var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
Comment thread
westey-m marked this conversation as resolved.
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";

// Create the Azure AI Project client and get an IChatClient with stored output disabled
// 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: 1_050_000,
maxOutputTokens: 128_000);

// 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.
var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
IChatClient chatClient = aiProjectClient
.GetProjectOpenAIClient()
.GetProjectResponsesClient()
.AsIChatClient(deploymentName);
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint) };
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.Build();

// Create web browsing tools for downloading and converting HTML pages to markdown.
var webBrowsingTools = new WebBrowsingTools();
Expand Down Expand Up @@ -79,16 +91,20 @@ This rule applies even when the answer seems obvious or the task seems small.
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders = [new TodoProvider(), new AgentModeProvider()],
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
}),
ChatOptions = new ChatOptions
{
// 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.High },
Tools = [FoundryAITool.CreateWebSearchTool(), .. webBrowsingTools.Tools],
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
},
});

// 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.");
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: 1_050_000, maxOutputTokens: 128_000);
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This sample demonstrates how to use a `ChatClientAgent` with the Harness `AICont
Key features showcased:

- **ChatClientAgent** — configured directly with Harness providers for planning and task management
- **Web Search** — the agent can search the web for current information via `FoundryAITool.CreateWebSearchTool()`
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
- **TodoProvider** — the agent creates and manages a todo list to track research questions
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
Expand All @@ -25,8 +25,8 @@ Before running this sample, ensure you have:
Set the following environment variables:

```bash
# Required: Your Azure AI Foundry project endpoint
export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name"
# Required: Your Azure AI Foundry OpenAI endpoint
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"

# Optional: Model deployment name (defaults to gpt-5.4)
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI.Compaction;

/// <summary>
/// A compaction strategy that derives token thresholds from a model's context window size
/// and maximum output tokens, applying a two-phase compaction pipeline:
/// <list type="number">
/// <item><description><b>Tool result eviction</b> (<see cref="ToolResultCompactionStrategy"/>) — collapses old tool call groups
/// into concise summaries when the token count exceeds the <see cref="ToolEvictionThreshold"/>.</description></item>
/// <item><description><b>Truncation</b> (<see cref="TruncationCompactionStrategy"/>) — removes the oldest non-system message groups
/// when the token count exceeds the <see cref="TruncationThreshold"/>.</description></item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// The <b>input budget</b> is defined as <c>maxContextWindowTokens - maxOutputTokens</c>, representing
/// the maximum number of tokens available for the conversation input (including system messages, tools, and history).
/// </para>
/// <para>
/// This strategy is a convenience wrapper around <see cref="PipelineCompactionStrategy"/> that automates
/// threshold calculation from model specifications.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ContextWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default fraction of the input budget at which tool result eviction triggers.
/// </summary>
public const double DefaultToolEvictionThreshold = 0.5;

/// <summary>
/// The default fraction of the input budget at which truncation triggers.
/// </summary>
public const double DefaultTruncationThreshold = 0.8;

private readonly PipelineCompactionStrategy _pipeline;

/// <summary>
/// Initializes a new instance of the <see cref="ContextWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// </param>
/// <param name="toolEvictionThreshold">
/// The fraction of the input budget (0.0, 1.0] at which tool result eviction triggers.
/// Defaults to <see cref="DefaultToolEvictionThreshold"/> (0.5).
/// </param>
/// <param name="truncationThreshold">
/// The fraction of the input budget (0.0, 1.0] at which truncation triggers.
/// Defaults to <see cref="DefaultTruncationThreshold"/> (0.8).
/// Must be greater than or equal to <paramref name="toolEvictionThreshold"/>.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>, or
/// <paramref name="toolEvictionThreshold"/> or <paramref name="truncationThreshold"/> is not in (0.0, 1.0], or
/// <paramref name="truncationThreshold"/> is less than <paramref name="toolEvictionThreshold"/>.
/// </exception>
public ContextWindowCompactionStrategy(
int maxContextWindowTokens,
int maxOutputTokens,
double toolEvictionThreshold = DefaultToolEvictionThreshold,
double truncationThreshold = DefaultTruncationThreshold)
: base(CompactionTriggers.Always)
{
Throw.IfLessThanOrEqual(maxContextWindowTokens, 0);
Throw.IfLessThan(maxOutputTokens, 0);
Throw.IfGreaterThanOrEqual(maxOutputTokens, maxContextWindowTokens);

ValidateThreshold(toolEvictionThreshold, nameof(toolEvictionThreshold));
ValidateThreshold(truncationThreshold, nameof(truncationThreshold));

if (truncationThreshold < toolEvictionThreshold)
{
throw new ArgumentOutOfRangeException(nameof(truncationThreshold), truncationThreshold,
$"Truncation threshold ({truncationThreshold}) must be greater than or equal to tool eviction threshold ({toolEvictionThreshold}).");
}

this.MaxContextWindowTokens = maxContextWindowTokens;
this.MaxOutputTokens = maxOutputTokens;
this.InputBudgetTokens = maxContextWindowTokens - maxOutputTokens;
this.ToolEvictionThreshold = toolEvictionThreshold;
this.TruncationThreshold = truncationThreshold;

int toolEvictionTokens = (int)(this.InputBudgetTokens * toolEvictionThreshold);
int truncationTokens = (int)(this.InputBudgetTokens * truncationThreshold);

this._pipeline = new PipelineCompactionStrategy(
new ToolResultCompactionStrategy(
trigger: CompactionTriggers.TokensExceed(toolEvictionTokens),
minimumPreservedGroups: 2),
new TruncationCompactionStrategy(
trigger: CompactionTriggers.TokensExceed(truncationTokens),
minimumPreservedGroups: 2));
}

/// <summary>
/// Gets the maximum context window size in tokens.
/// </summary>
public int MaxContextWindowTokens { get; }

/// <summary>
/// Gets the maximum output tokens per response.
/// </summary>
public int MaxOutputTokens { get; }

/// <summary>
/// Gets the computed input budget in tokens (<see cref="MaxContextWindowTokens"/> minus <see cref="MaxOutputTokens"/>).
/// </summary>
public int InputBudgetTokens { get; }

/// <summary>
/// Gets the fraction of the input budget at which tool result eviction triggers.
/// </summary>
public double ToolEvictionThreshold { get; }

/// <summary>
/// Gets the fraction of the input budget at which truncation triggers.
/// </summary>
public double TruncationThreshold { get; }

/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
return await this._pipeline.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
}

private static void ValidateThreshold(double value, string paramName)
{
if (value is <= 0.0 or > 1.0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Threshold must be in the range (0.0, 1.0].");
}
}
}
Loading
Loading