Skip to content
Closed
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
51 changes: 50 additions & 1 deletion dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -69,6 +70,8 @@ public sealed class LoopAgent : DelegatingAIAgent

private readonly IReadOnlyList<LoopEvaluator> _evaluators;
private readonly int _maxIterations;
private readonly long? _maxTokens;
private readonly System.TimeSpan? _maxDuration;
private readonly bool _freshContextPerIteration;
private readonly string? _onBehalfOfAuthorName;
private readonly bool _excludeOnBehalfOfMessages;
Expand Down Expand Up @@ -121,6 +124,8 @@ public LoopAgent(AIAgent innerAgent, IEnumerable<LoopEvaluator> evaluators, Loop
this._evaluators = evaluatorArray;

this._maxIterations = Throw.IfLessThan(options?.MaxIterations ?? DefaultMaxIterations, 1);
this._maxTokens = options?.MaxTokens;
this._maxDuration = options?.MaxDuration;
Comment thread
marklicata marked this conversation as resolved.
this._freshContextPerIteration = options?.FreshContextPerIteration ?? false;
this._onBehalfOfAuthorName = options?.OnBehalfOfAuthorName;
this._excludeOnBehalfOfMessages = options?.ExcludeOnBehalfOfMessages ?? false;
Expand Down Expand Up @@ -159,6 +164,10 @@ protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> currentMessages = initialMessages;
int iteration = 0;

// Budget tracking (tokens and wall-clock time) for the whole run.
long tokensUsed = 0;
Stopwatch? budgetStopwatch = this._maxDuration.HasValue ? Stopwatch.StartNew() : null;

// Aggregates the full transcript across iterations: each iteration's surfaced on-behalf-of input messages
// followed by that iteration's response messages. Unused when only the final response is returned.
List<ChatMessage> transcript = [];
Expand All @@ -180,6 +189,12 @@ protected override async Task<AgentResponse> RunCoreAsync(

UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);

if (this._maxTokens.HasValue && response.Usage is { } usage)
{
tokensUsed += usage.TotalTokenCount
?? ((usage.InputTokenCount ?? 0) + (usage.OutputTokenCount ?? 0));
}

// Record this iteration's on-behalf-of input (before the response it elicited) and the response itself.
transcript.AddRange(currentSurfaced);
transcript.AddRange(response.Messages);
Expand All @@ -198,11 +213,28 @@ protected override async Task<AgentResponse> RunCoreAsync(
return this.BuildResult(response, transcript, aggregatedUsage);
}

// Budget pre-checks — run before the cap and evaluators so an expensive judge is not called once a budget is exhausted.
if (budgetStopwatch is not null && budgetStopwatch.Elapsed >= this._maxDuration)
{
AgentResponse budgetResult = this.BuildResult(response, transcript, aggregatedUsage);
(budgetResult.AdditionalProperties ??= new())[LoopExitReason.AdditionalPropertiesKey] = LoopExitReason.TimeBudgetExceeded;
return budgetResult;
Comment on lines +217 to +221
}

if (this._maxTokens.HasValue && tokensUsed >= this._maxTokens.Value)
{
AgentResponse budgetResult = this.BuildResult(response, transcript, aggregatedUsage);
(budgetResult.AdditionalProperties ??= new())[LoopExitReason.AdditionalPropertiesKey] = LoopExitReason.TokenBudgetExceeded;
return budgetResult;
}

// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
this.LogMaxIterationsReached(iteration);
return this.BuildResult(response, transcript, aggregatedUsage);
AgentResponse capResult = this.BuildResult(response, transcript, aggregatedUsage);
(capResult.AdditionalProperties ??= new())[LoopExitReason.AdditionalPropertiesKey] = LoopExitReason.IterationCapReached;
return capResult;
}

// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
Expand Down Expand Up @@ -247,6 +279,10 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
IEnumerable<ChatMessage> currentMessages = initialMessages;
int iteration = 0;

// Budget tracking (tokens and wall-clock time) for the whole run.
long tokensUsed = 0;
Stopwatch? budgetStopwatch = this._maxDuration.HasValue ? Stopwatch.StartNew() : null;

// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
IReadOnlyList<ChatMessage> currentSurfaced = [];

Expand Down Expand Up @@ -307,6 +343,19 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
yield break;
}

// Budget pre-checks (no exit-reason stamping in streaming; consistent with the iteration cap).
if (this._maxTokens.HasValue && response.Usage is { } streamUsage)
{
tokensUsed += streamUsage.TotalTokenCount
?? ((streamUsage.InputTokenCount ?? 0) + (streamUsage.OutputTokenCount ?? 0));
}
Comment on lines +347 to +351

if ((budgetStopwatch is not null && budgetStopwatch.Elapsed >= this._maxDuration)
|| (this._maxTokens.HasValue && tokensUsed >= this._maxTokens.Value))
{
yield break;
}

// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
Expand Down
41 changes: 41 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,45 @@ public sealed class LoopAgentOptions
/// it fires multiple times, the most recent invocation carries the session the loop is currently using.
/// </remarks>
public Func<AgentSession, CancellationToken, ValueTask>? SessionCreatedCallback { get; set; }

/// <summary>Gets or sets the maximum total LLM token spend across all iterations, or <see langword="null"/> for unconstrained.</summary>
/// <remarks>
/// Counts <c>TotalTokenCount</c> from each iteration's <c>AgentResponse.Usage</c>, falling back to
/// <c>InputTokenCount + OutputTokenCount</c> when <c>TotalTokenCount</c> is absent.
/// When exceeded, the loop stamps <c>AgentResponse.AdditionalProperties["loop_exit_reason"] = "token_budget_exceeded"</c>
/// (non-streaming only) and returns the last response.
/// </remarks>
public long? MaxTokens
{
get;
set
{
if (value.HasValue && value.Value < 1)
{
throw new ArgumentOutOfRangeException(nameof(value), "MaxTokens must be a positive integer.");
}

field = value;
}
}

/// <summary>Gets or sets the maximum wall-clock duration for the entire loop run, or <see langword="null"/> for unconstrained.</summary>
/// <remarks>
/// Checked after each iteration. Catches agents blocked on slow tools that token budgets cannot detect.
/// When exceeded, stamps <c>AgentResponse.AdditionalProperties["loop_exit_reason"] = "time_budget_exceeded"</c>
/// (non-streaming only).
/// </remarks>
public TimeSpan? MaxDuration
{
get;
set
{
if (value.HasValue && value.Value <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(value), "MaxDuration must be a positive duration.");
}

field = value;
}
}
}
34 changes: 34 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI;

/// <summary>
/// Provides typed exit-reason strings for <see cref="LoopAgent"/> runs.
/// Values are plain strings to remain JSON-transparent. The reason is surfaced under
/// <see cref="AdditionalPropertiesKey"/> in <c>AgentResponse.AdditionalProperties</c>.
/// </summary>
/// <remarks>
/// <para>
/// Exit reasons are stamped by <see cref="LoopAgent"/> on <strong>non-streaming</strong> runs only.
/// In streaming mode the response is assembled from yielded updates by the caller, so there is no
/// single <c>AgentResponse</c> object available inside the generator to stamp.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class LoopExitReason
{
/// <summary>The key used in <c>AgentResponse.AdditionalProperties</c> to surface the loop exit reason.</summary>
public const string AdditionalPropertiesKey = "loop_exit_reason";

/// <summary>The <see cref="LoopAgentOptions.MaxIterations"/> cap was reached before the evaluators stopped the loop.</summary>
public const string IterationCapReached = "iteration_cap_reached";

/// <summary>The token budget (<see cref="LoopAgentOptions.MaxTokens"/>) was exhausted before the loop completed.</summary>
public const string TokenBudgetExceeded = "token_budget_exceeded";

/// <summary>The wall-clock time budget (<see cref="LoopAgentOptions.MaxDuration"/>) was exhausted before the loop completed.</summary>
public const string TimeBudgetExceeded = "time_budget_exceeded";
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.AdditionalPropertiesKey = "loop_exit_reason" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TokenBudgetExceeded = "token_budget_exceeded" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TimeBudgetExceeded = "time_budget_exceeded" -> string!
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.get -> long?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.set -> void
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.get -> System.TimeSpan?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.set -> void
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string!
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.AdditionalPropertiesKey = "loop_exit_reason" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TokenBudgetExceeded = "token_budget_exceeded" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TimeBudgetExceeded = "time_budget_exceeded" -> string!
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.get -> long?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.set -> void
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.get -> System.TimeSpan?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.set -> void
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string!
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.AdditionalPropertiesKey = "loop_exit_reason" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TokenBudgetExceeded = "token_budget_exceeded" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TimeBudgetExceeded = "time_budget_exceeded" -> string!
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.get -> long?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.set -> void
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.get -> System.TimeSpan?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.set -> void
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string!
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.AdditionalPropertiesKey = "loop_exit_reason" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TokenBudgetExceeded = "token_budget_exceeded" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TimeBudgetExceeded = "time_budget_exceeded" -> string!
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.get -> long?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.set -> void
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.get -> System.TimeSpan?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.set -> void
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string!
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.AdditionalPropertiesKey = "loop_exit_reason" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TokenBudgetExceeded = "token_budget_exceeded" -> string!
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.TimeBudgetExceeded = "time_budget_exceeded" -> string!
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.get -> long?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxTokens.set -> void
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.get -> System.TimeSpan?
[MAAI001]Microsoft.Agents.AI.LoopAgentOptions.MaxDuration.set -> void
[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string!
Loading
Loading