From 325a2fdbfb58f037d8dd1c03056b77112cf686bd Mon Sep 17 00:00:00 2001 From: mark licta Date: Mon, 5 Oct 2026 15:33:09 +0000 Subject: [PATCH 1/2] Python + .NET: add AgentBudget for autonomous agent loop resource limits Adds token and wall-clock time budget constraints to AgentLoopMiddleware (Python) and LoopAgent (.NET), enabling safe production autonomous loops. AgentBudget (Python) / MaxTokens + MaxDuration (LoopAgentOptions, .NET): - max_tokens / MaxTokens: stops the loop when cumulative LLM token spend exceeds the limit; counts total_token_count per iteration - max_duration / MaxDuration: stops the loop when elapsed wall-clock time exceeds the limit; catches agents blocked on slow tools Budget checks run before should_continue / LoopEvaluators so an expensive judge predicate is not called after a budget is already exhausted. New LoopExitReason values (both languages): token_budget_exceeded -- token cap fired time_budget_exceeded -- wall-clock cap fired Exit reason stamped in AgentResponse.AdditionalProperties["loop_exit_reason"] on non-streaming runs. Streaming limitation documented as with the iteration cap. Depends on #8993 (GT1 signal protocol) which introduces LoopExitReason. Tests: 124 Python (7 new budget tests), .NET 4 new LoopAgentBudgetTests. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../Harness/Loop/LoopAgent.cs | 60 ++++- .../Harness/Loop/LoopAgentOptions.cs | 45 ++++ .../Harness/Loop/LoopExitReason.cs | 48 ++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 20 +- .../PublicAPI/net472/PublicAPI.Unshipped.txt | 20 +- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 20 +- .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 20 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 20 +- .../Harness/Loop/LoopAgentBudgetTests.cs | 100 +++++++ python/CHANGELOG.md | 3 + .../packages/core/agent_framework/__init__.py | 20 ++ .../core/agent_framework/__init__.pyi | 20 ++ .../core/agent_framework/_harness/_loop.py | 122 +++++++++ .../core/agent_framework/_harness/_signals.py | 174 ++++++++++++ .../core/tests/core/test_harness_loop.py | 255 ++++++++++++++++++ 15 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs create mode 100644 python/packages/core/agent_framework/_harness/_signals.py diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs index 398dc061565..d537c61294c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; @@ -69,6 +71,8 @@ public sealed class LoopAgent : DelegatingAIAgent private readonly IReadOnlyList _evaluators; private readonly int _maxIterations; + private readonly long? _maxTokens; + private readonly TimeSpan? _maxDuration; private readonly bool _freshContextPerIteration; private readonly string? _onBehalfOfAuthorName; private readonly bool _excludeOnBehalfOfMessages; @@ -121,6 +125,8 @@ public LoopAgent(AIAgent innerAgent, IEnumerable evaluators, Loop this._evaluators = evaluatorArray; this._maxIterations = Throw.IfLessThan(options?.MaxIterations ?? DefaultMaxIterations, 1); + this._maxTokens = options?.MaxTokens; + this._maxDuration = options?.MaxDuration; this._freshContextPerIteration = options?.FreshContextPerIteration ?? false; this._onBehalfOfAuthorName = options?.OnBehalfOfAuthorName; this._excludeOnBehalfOfMessages = options?.ExcludeOnBehalfOfMessages ?? false; @@ -159,6 +165,10 @@ protected override async Task RunCoreAsync( IEnumerable 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 transcript = []; @@ -180,6 +190,12 @@ protected override async Task 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); @@ -198,18 +214,41 @@ protected override async Task 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; + } + + 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. LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false); if (!step.ShouldContinue) { - return this.BuildResult(response, transcript, aggregatedUsage); + AgentResponse evalResult = this.BuildResult(response, transcript, aggregatedUsage); + if (context.AdditionalProperties.TryGetValue(LoopExitReason.AdditionalPropertiesKey, out object? exitReason)) + { + (evalResult.AdditionalProperties ??= new())[LoopExitReason.AdditionalPropertiesKey] = exitReason; + } + + return evalResult; } currentMessages = step.Messages; @@ -247,6 +286,10 @@ protected override async IAsyncEnumerable RunCoreStreamingA IEnumerable 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 currentSurfaced = []; @@ -307,6 +350,19 @@ protected override async IAsyncEnumerable 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)); + } + + 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) { diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs index ec009b4594c..cf3baf74b04 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs @@ -114,4 +114,49 @@ public sealed class LoopAgentOptions /// it fires multiple times, the most recent invocation carries the session the loop is currently using. /// public Func? SessionCreatedCallback { get; set; } + + /// Gets or sets the maximum total LLM token spend across all iterations, or for unconstrained. + /// + /// Counts TotalTokenCount from each iteration's AgentResponse.Usage, falling back to + /// InputTokenCount + OutputTokenCount when TotalTokenCount is absent. + /// When exceeded, the loop stamps AgentResponse.AdditionalProperties["loop_exit_reason"] = "token_budget_exceeded" + /// (non-streaming only) and returns the last response. + /// + public long? MaxTokens + { + get => this._maxTokens; + set + { + if (value.HasValue && value.Value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "MaxTokens must be a positive integer."); + } + + this._maxTokens = value; + } + } + + private long? _maxTokens; + + /// Gets or sets the maximum wall-clock duration for the entire loop run, or for unconstrained. + /// + /// Checked after each iteration. Catches agents blocked on slow tools that token budgets cannot detect. + /// When exceeded, stamps AgentResponse.AdditionalProperties["loop_exit_reason"] = "time_budget_exceeded" + /// (non-streaming only). + /// + public TimeSpan? MaxDuration + { + get => this._maxDuration; + set + { + if (value.HasValue && value.Value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), "MaxDuration must be a positive duration."); + } + + this._maxDuration = value; + } + } + + private TimeSpan? _maxDuration; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs new file mode 100644 index 00000000000..11c3e64347c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides typed exit-reason strings for runs. +/// Values are plain strings to remain JSON-transparent. The reason is surfaced under +/// in AgentResponse.AdditionalProperties. +/// +/// +/// +/// Exit reasons are stamped by on non-streaming runs only. +/// In streaming mode the response is assembled from yielded updates by the caller, so there is no +/// single AgentResponse object available inside the generator to stamp. +/// +/// +/// Evaluators such as stamp the exit reason on +/// before returning Stop(); the loop then +/// copies that value to the returned AgentResponse.AdditionalProperties. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class LoopExitReason +{ + /// The key used in AgentResponse.AdditionalProperties to surface the loop exit reason. + public const string AdditionalPropertiesKey = "loop_exit_reason"; + + /// A TASK_COMPLETE: signal was detected in the agent's response. + public const string Completed = "completed"; + + /// The max-iterations cap fired before any terminal signal was emitted. + public const string IterationCapReached = "iteration_cap_reached"; + + /// A NEED_INPUT: signal was detected; the loop stopped to surface a question. + public const string NeedInput = "need_input"; + + /// External cancellation (reserved for future use). + public const string Cancelled = "cancelled"; + + /// The token budget () was exhausted before the loop completed. + public const string TokenBudgetExceeded = "token_budget_exceeded"; + + /// The wall-clock time budget () was exhausted before the loop completed. + public const string TimeBudgetExceeded = "time_budget_exceeded"; +} diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index fada975c025..fff74b8c8dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -24,3 +24,21 @@ 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!>! [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index fada975c025..fff74b8c8dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -24,3 +24,21 @@ 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!>! [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index fada975c025..fff74b8c8dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -24,3 +24,21 @@ 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!>! [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index fada975c025..fff74b8c8dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -24,3 +24,21 @@ 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!>! [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index fada975c025..fff74b8c8dd 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -24,3 +24,21 @@ 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!>! [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! +[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs new file mode 100644 index 00000000000..a95bc2e8a56 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the token and wall-clock budget support of . +/// +public class LoopAgentBudgetTests +{ + private static AgentResponse ResponseWithTokens(long totalTokens) => + new(new ChatMessage(ChatRole.Assistant, "working")) { Usage = new UsageDetails { TotalTokenCount = totalTokens } }; + + /// + /// Verify that the loop stops once cumulative token spend reaches MaxTokens and stamps the exit reason. + /// + [Fact] + public async Task RunAsync_TokenBudgetExceeded_StopsAndStampsReasonAsync() + { + // Arrange + var inner = new InnerAgentCapture(_ => ResponseWithTokens(30)); + var agent = new LoopAgent(inner.Agent, LoopTestHelpers.While(_ => true), new LoopAgentOptions { MaxTokens = 50, MaxIterations = 10 }); + + // Act + AgentResponse response = await agent.RunAsync("task", new ChatClientAgentSession()); + + // Assert: 30 < 50 after call 1, 60 >= 50 after call 2. + Assert.Equal(2, inner.CallCount); + Assert.NotNull(response.AdditionalProperties); + Assert.Equal(LoopExitReason.TokenBudgetExceeded, response.AdditionalProperties![LoopExitReason.AdditionalPropertiesKey]); + Assert.Equal("token_budget_exceeded", response.AdditionalProperties[LoopExitReason.AdditionalPropertiesKey]); + } + + /// + /// Verify that the loop stops after the first iteration when the duration budget is already exhausted. + /// + [Fact] + public async Task RunAsync_TimeBudgetExceeded_StopsAndStampsReasonAsync() + { + // Arrange + var inner = new InnerAgentCapture(_ => ResponseWithTokens(1)); + var agent = new LoopAgent(inner.Agent, LoopTestHelpers.While(_ => true), new LoopAgentOptions { MaxDuration = TimeSpan.Zero, MaxIterations = 10 }); + + // Act + AgentResponse response = await agent.RunAsync("task", new ChatClientAgentSession()); + + // Assert + Assert.Equal(1, inner.CallCount); + Assert.NotNull(response.AdditionalProperties); + Assert.Equal(LoopExitReason.TimeBudgetExceeded, response.AdditionalProperties![LoopExitReason.AdditionalPropertiesKey]); + } + + /// + /// Verify that without a budget the loop runs to the iteration cap. + /// + [Fact] + public async Task RunAsync_NoBudget_RunsToCapAsync() + { + // Arrange + var inner = new InnerAgentCapture(_ => ResponseWithTokens(1_000_000)); + var agent = new LoopAgent(inner.Agent, LoopTestHelpers.While(_ => true), new LoopAgentOptions { MaxIterations = 3 }); + + // Act + AgentResponse response = await agent.RunAsync("task", new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, inner.CallCount); + Assert.Equal(LoopExitReason.IterationCapReached, response.AdditionalProperties![LoopExitReason.AdditionalPropertiesKey]); + } + + /// + /// Verify that the budget stop fires before evaluators are consulted. + /// + [Fact] + public async Task RunAsync_BudgetCheckBeforeEvaluatorsAsync() + { + // Arrange + int evaluatorCalls = 0; + var inner = new InnerAgentCapture(_ => ResponseWithTokens(60)); + var agent = new LoopAgent( + inner.Agent, + LoopTestHelpers.While(_ => + { + evaluatorCalls++; + return true; + }), + new LoopAgentOptions { MaxTokens = 50, MaxIterations = 10 }); + + // Act + AgentResponse response = await agent.RunAsync("task", new ChatClientAgentSession()); + + // Assert + Assert.Equal(1, inner.CallCount); + Assert.Equal(0, evaluatorCalls); + Assert.Equal(LoopExitReason.TokenBudgetExceeded, response.AdditionalProperties![LoopExitReason.AdditionalPropertiesKey]); + } +} diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 6b290c02a70..62078a998ca 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **agent-framework-core**: Add `AgentBudget` with `max_tokens` and `max_duration` constraints for `AgentLoopMiddleware` autonomous loops; budget stops stamp `loop_exit_reason` in `AgentResponse.additional_properties`. Add `LoopExitReason.token_budget_exceeded` and `LoopExitReason.time_budget_exceeded`. + ## [1.19.0] - 2026-09-18 ### Added diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 3953187578e..ec69c33cbe3 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -132,6 +132,7 @@ "FileMemoryProvider", ), "._harness._loop": ( + "AgentBudget", "AgentLoopMiddleware", "JudgeVerdict", "background_tasks_running", @@ -148,6 +149,16 @@ "MemoryTopicRecord", ), "._harness._mode": ("DEFAULT_MODE_SOURCE_ID", "AgentModeProvider", "get_agent_mode", "set_agent_mode"), + "._harness._signals": ( + "LoopExitReason", + "LOOP_EXIT_REASON_KEY", + "LOOP_NEED_INPUT_KEY", + "NEED_INPUT_TOKEN", + "SignalParser", + "TASK_COMPLETE_TOKEN", + "get_loop_exit_reason", + "signal_should_continue", + ), "._harness._todo": ( "DEFAULT_TODO_SOURCE_ID", "TodoFileStore", @@ -424,14 +435,19 @@ "GROUP_KIND_KEY", "GROUP_TOKEN_COUNT_KEY", "INTERNAL_SOURCE_ID", + "LOOP_EXIT_REASON_KEY", + "LOOP_NEED_INPUT_KEY", "MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY", + "NEED_INPUT_TOKEN", "SKIP_PARSING", "SUMMARIZED_BY_SUMMARY_ID_KEY", "SUMMARY_OF_GROUP_IDS_KEY", "SUMMARY_OF_MESSAGE_IDS_KEY", + "TASK_COMPLETE_TOKEN", "USER_AGENT_KEY", "USER_AGENT_TELEMETRY_DISABLED_ENV_VAR", "Agent", + "AgentBudget", "AgentContext", "AgentEvalConverter", "AgentExecutor", @@ -554,6 +570,7 @@ "InlineSkillScript", "JudgeVerdict", "LocalEvaluator", + "LoopExitReason", "MCPSkill", "MCPSkillResource", "MCPSkillsSource", @@ -596,6 +613,7 @@ "ServiceSessionId", "SessionContext", "SessionStore", + "SignalParser", "SingleEdgeGroup", "Skill", "SkillFrontmatter", @@ -696,6 +714,7 @@ "executor", "function_middleware", "get_agent_mode", + "get_loop_exit_reason", "get_run_context", "handler", "included_messages", @@ -715,6 +734,7 @@ "resolve_agent_id", "response_handler", "set_agent_mode", + "signal_should_continue", "step", "todos_remaining", "todos_remaining_message", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index e8d1bc6e411..7599fb6725f 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -90,6 +90,7 @@ from ._harness._file_access import ( ) from ._harness._file_memory import DEFAULT_FILE_MEMORY_INSTRUCTIONS, DEFAULT_FILE_MEMORY_SOURCE_ID, FileMemoryProvider from ._harness._loop import ( + AgentBudget, AgentLoopMiddleware, JudgeVerdict, background_tasks_running, @@ -106,6 +107,16 @@ from ._harness._memory import ( MemoryTopicRecord, ) from ._harness._mode import DEFAULT_MODE_SOURCE_ID, AgentModeProvider, get_agent_mode, set_agent_mode +from ._harness._signals import ( + LOOP_EXIT_REASON_KEY, + LOOP_NEED_INPUT_KEY, + NEED_INPUT_TOKEN, + TASK_COMPLETE_TOKEN, + LoopExitReason, + SignalParser, + get_loop_exit_reason, + signal_should_continue, +) from ._harness._todo import ( DEFAULT_TODO_SOURCE_ID, TodoFileStore, @@ -379,14 +390,19 @@ __all__ = [ "GROUP_KIND_KEY", "GROUP_TOKEN_COUNT_KEY", "INTERNAL_SOURCE_ID", + "LOOP_EXIT_REASON_KEY", + "LOOP_NEED_INPUT_KEY", "MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY", + "NEED_INPUT_TOKEN", "SKIP_PARSING", "SUMMARIZED_BY_SUMMARY_ID_KEY", "SUMMARY_OF_GROUP_IDS_KEY", "SUMMARY_OF_MESSAGE_IDS_KEY", + "TASK_COMPLETE_TOKEN", "USER_AGENT_KEY", "USER_AGENT_TELEMETRY_DISABLED_ENV_VAR", "Agent", + "AgentBudget", "AgentContext", "AgentEvalConverter", "AgentExecutor", @@ -509,6 +525,7 @@ __all__ = [ "InlineSkillScript", "JudgeVerdict", "LocalEvaluator", + "LoopExitReason", "MCPSkill", "MCPSkillResource", "MCPSkillsSource", @@ -551,6 +568,7 @@ __all__ = [ "ServiceSessionId", "SessionContext", "SessionStore", + "SignalParser", "SingleEdgeGroup", "Skill", "SkillFrontmatter", @@ -651,6 +669,7 @@ __all__ = [ "executor", "function_middleware", "get_agent_mode", + "get_loop_exit_reason", "get_run_context", "handler", "included_messages", @@ -670,6 +689,7 @@ __all__ = [ "resolve_agent_id", "response_handler", "set_agent_mode", + "signal_should_continue", "step", "todos_remaining", "todos_remaining_message", diff --git a/python/packages/core/agent_framework/_harness/_loop.py b/python/packages/core/agent_framework/_harness/_loop.py index 7b14765868e..7803e63cbae 100644 --- a/python/packages/core/agent_framework/_harness/_loop.py +++ b/python/packages/core/agent_framework/_harness/_loop.py @@ -21,6 +21,15 @@ whether the user's original request has been answered (via a :class:`JudgeVerdict` structured output); the loop continues while the answer is "no". This is a convenience wrapper that builds an async ``should_continue`` predicate, so it is a special case of (1). +3. Signal protocol (via :func:`~agent_framework.signal_should_continue` from ``_signals.py``) - the + agent emits ``TASK_COMPLETE: `` or ``NEED_INPUT: `` in its response text to + signal terminal intent. Only assistant-role messages are scanned; user-role nudges injected between + iterations do not trigger false positives. ``TASK_COMPLETE:`` is checked first. Read the result + with :func:`~agent_framework.get_loop_exit_reason`, which returns a + :class:`~agent_framework.LoopExitReason` string (``"completed"``, ``"iteration_cap_reached"``, + ``"need_input"``). When ``max_iterations`` fires the loop stamps + ``additional_properties["loop_exit_reason"] = "iteration_cap_reached"``; signal tokens always win + over the cap. In every case, the input for the next iteration is controlled by the ``next_message`` callable. """ @@ -28,7 +37,10 @@ from __future__ import annotations import inspect +import math +import time from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, TypeAlias from pydantic import BaseModel, Field @@ -55,6 +67,7 @@ from .._clients import SupportsChatGetResponse __all__ = [ + "AgentBudget", "AgentLoopMiddleware", "JudgeVerdict", "background_tasks_running", @@ -129,6 +142,58 @@ class JudgeVerdict(BaseModel): DEFAULT_JUDGE_MAX_ITERATIONS = 5 +@experimental(feature_id=ExperimentalFeature.HARNESS) +@dataclass +class AgentBudget: + """Budget constraints for an :class:`AgentLoopMiddleware` autonomous loop. + + Both ``max_tokens`` and ``max_duration`` are optional with no default — they are + workload-specific and must be set explicitly for production autonomous sessions. + When a budget dimension is ``None``, it is unconstrained. + + Examples: + + .. code-block:: python + + from agent_framework import AgentBudget, AgentLoopMiddleware, signal_should_continue + + agent = Agent( + client=client, + middleware=[ + AgentLoopMiddleware( + signal_should_continue(), + budget=AgentBudget(max_tokens=50_000, max_duration=300.0), + ) + ], + ) + + Note: + Budget stops stamp ``additional_properties["loop_exit_reason"]`` with + ``"token_budget_exceeded"`` or ``"time_budget_exceeded"`` on the returned + :class:`~agent_framework.AgentResponse` (non-streaming only). + """ + + max_tokens: int | None = None + """Maximum total LLM token spend across all loop iterations. + + Counts ``total_token_count`` from each iteration's usage details, falling back to + ``input_token_count + output_token_count`` when ``total_token_count`` is absent. + """ + + max_duration: float | None = None + """Maximum wall-clock time in seconds for the entire loop run. + + Checked at the start of each iteration using :func:`time.monotonic`. + Catches agents blocked on slow tools that token budgets cannot detect. + """ + + def __post_init__(self) -> None: + if self.max_tokens is not None and self.max_tokens < 1: + raise ValueError("AgentBudget.max_tokens must be a positive integer.") + if self.max_duration is not None and (not math.isfinite(self.max_duration) or self.max_duration <= 0): + raise ValueError("AgentBudget.max_duration must be a positive, finite number of seconds.") + + # A callable invoked between iterations. It always receives the loop keyword arguments # (``iteration``, ``last_result``, ``messages``, ``original_messages``, ``session``, ``agent``, # ``progress``, ``feedback``). Callers declare only the keywords they need plus ``**kwargs`` to @@ -275,6 +340,7 @@ def __init__( fresh_context: bool = False, return_final_only: bool = False, additional_instructions: str | None = None, + budget: AgentBudget | None = None, ) -> None: """Initialize the agent loop middleware. @@ -332,6 +398,9 @@ def __init__( messages, so it is preserved across ``fresh_context`` resets and (with a session) persists server-side across iterations. Used by :meth:`with_judge` to tell the agent about the criteria its response must satisfy, but available to any loop. + budget: Optional :class:`AgentBudget` bounding total token spend and/or wall-clock time. + Checked after each iteration, before ``should_continue`` is evaluated. When exceeded + the loop stops and (non-streaming only) stamps ``loop_exit_reason``. Raises: ValueError: If ``max_iterations`` is not ``None`` and is less than 1. @@ -347,6 +416,7 @@ def __init__( self.fresh_context = fresh_context self.return_final_only = return_final_only self.additional_instructions = additional_instructions + self.budget: AgentBudget | None = budget @classmethod def with_judge( @@ -358,6 +428,7 @@ def with_judge( max_iterations: int | None = DEFAULT_JUDGE_MAX_ITERATIONS, next_message: NextMessageCallable | None = None, fresh_context: bool = False, + budget: AgentBudget | None = None, ) -> Self: """Create a loop that continues until a judge chat client decides the request was answered. @@ -403,6 +474,7 @@ def with_judge( conversation; an attached session is snapshotted before the loop and restored to that baseline between iterations. See :meth:`__init__` for the full semantics. Defaults to ``False``. + budget: Optional :class:`AgentBudget`. See :meth:`__init__`. """ judge_instructions = (instructions or DEFAULT_JUDGE_INSTRUCTIONS).replace( CRITERIA_PLACEHOLDER, _render_criteria_block(criteria) @@ -414,6 +486,7 @@ def with_judge( next_message=next_message or judge_next_message, fresh_context=fresh_context, additional_instructions=_criteria_agent_instruction(criteria) if criteria else None, + budget=budget, ) async def process( @@ -512,6 +585,7 @@ async def _process_non_streaming( snapshot: dict[str, Any] | None, ) -> None: iteration = 0 + cap_fired = False work_iterations = 0 progress: list[str] = [] # Aggregated transcript across iterations: each iteration's response messages plus the @@ -519,6 +593,9 @@ async def _process_non_streaming( aggregated: list[Message] = [] aggregated_usage: UsageDetails | None = None final_result: AgentResponse | None = None + tokens_used: int = 0 + budget_start: float = time.monotonic() + budget_exit: str | None = None stamped_options = dict(context.options) if context.options is not None else {} stamped_options[_LOOP_ITERATION_TOKEN_KEY] = object() context.options = stamped_options @@ -539,6 +616,13 @@ async def _process_non_streaming( if result.usage_details is not None: aggregated_usage = add_usage_details(aggregated_usage, result.usage_details) + # Accumulate token spend for budget enforcement. + if self.budget is not None and self.budget.max_tokens is not None and result.usage_details is not None: + ud = result.usage_details + tokens_used += ud.get("total_token_count") or ( + (ud.get("input_token_count") or 0) + (ud.get("output_token_count") or 0) + ) + # Escape hatch: if this iteration is asking for tool approval, stop and return the # response so the caller can approve, instead of continuing or injecting next_message. if self._has_pending_approval_request(result): @@ -555,9 +639,21 @@ async def _process_non_streaming( ) work_iterations += 1 + # Budget pre-check — runs before should_continue to avoid invoking an expensive + # predicate/judge after a budget has already been exhausted. + if self.budget is not None: + elapsed = time.monotonic() - budget_start + if self.budget.max_duration is not None and elapsed >= self.budget.max_duration: + budget_exit = "time_budget_exceeded" + break + if self.budget.max_tokens is not None and tokens_used >= self.budget.max_tokens: + budget_exit = "token_budget_exceeded" + break # Decide whether to stop and capture any feedback from should_continue first, so the # feedback is available to both the progress and next-message callables this iteration. stop, feedback = await self._evaluate_stop(loop_kwargs, work_iterations) + if stop and self.max_iterations is not None and work_iterations >= self.max_iterations: + cap_fired = True loop_kwargs = self._build_loop_kwargs( context=context, iteration=iteration, @@ -591,6 +687,11 @@ async def _process_non_streaming( finally: context.options.pop(_LOOP_ITERATION_TOKEN_KEY, None) + if cap_fired and final_result is not None: + final_result.additional_properties.setdefault("loop_exit_reason", "iteration_cap_reached") + if budget_exit is not None and final_result is not None: + final_result.additional_properties.setdefault("loop_exit_reason", budget_exit) + if not self.return_final_only: context.result = self._aggregate_response(final_result, aggregated, aggregated_usage) await self._fire_turn_scoped_after_providers( @@ -612,8 +713,11 @@ def _process_streaming( async def _generator() -> Any: iteration = 0 + cap_fired = False work_iterations = 0 progress: list[str] = [] + tokens_used: int = 0 + budget_start: float = time.monotonic() stamped_options = dict(context.options) if context.options is not None else {} stamped_options[_LOOP_ITERATION_TOKEN_KEY] = object() context.options = stamped_options @@ -657,9 +761,25 @@ async def _generator() -> Any: ) work_iterations += 1 + # Budget pre-check (before should_continue). Streaming cannot stamp + # ``loop_exit_reason`` on the response (the caller assembles it from updates), so + # the loop simply stops, consistent with the iteration-cap limitation. + if self.budget is not None: + if self.budget.max_tokens is not None and final is not None and final.usage_details: + ud = final.usage_details + tokens_used += ud.get("total_token_count") or ( + (ud.get("input_token_count") or 0) + (ud.get("output_token_count") or 0) + ) + elapsed = time.monotonic() - budget_start + if self.budget.max_duration is not None and elapsed >= self.budget.max_duration: + return + if self.budget.max_tokens is not None and tokens_used >= self.budget.max_tokens: + return # Decide whether to stop and capture any feedback from should_continue first, so the # feedback is available to both the progress and next-message callables this iteration. stop, feedback = await self._evaluate_stop(loop_kwargs, work_iterations) + if stop and self.max_iterations is not None and work_iterations >= self.max_iterations: + cap_fired = True loop_kwargs = self._build_loop_kwargs( context=context, iteration=iteration, @@ -695,6 +815,8 @@ async def _generator() -> Any: yield self._message_to_update(message) finally: context.options.pop(_LOOP_ITERATION_TOKEN_KEY, None) + if cap_fired and holder["final"] is not None: + holder["final"].additional_properties.setdefault("loop_exit_reason", "iteration_cap_reached") await self._fire_turn_scoped_after_providers(context, holder["final"], original_messages) def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: diff --git a/python/packages/core/agent_framework/_harness/_signals.py b/python/packages/core/agent_framework/_harness/_signals.py new file mode 100644 index 00000000000..d3d4b0294ea --- /dev/null +++ b/python/packages/core/agent_framework/_harness/_signals.py @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Signal protocol utilities for AgentLoopMiddleware autonomous loops. + +Provides a text-based convention for agents to signal loop termination: + TASK_COMPLETE: -- agent is done; loop should stop + NEED_INPUT: -- agent needs human input; loop should stop + +Use :func:`signal_should_continue` to get a :class:`SignalParser` instance +ready to pass as ``should_continue`` to :class:`~agent_framework.AgentLoopMiddleware`. +Use :func:`get_loop_exit_reason` to read the typed exit reason after the loop. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .._types import AgentResponse + +__all__ = [ + "LOOP_EXIT_REASON_KEY", + "LOOP_NEED_INPUT_KEY", + "NEED_INPUT_TOKEN", + "TASK_COMPLETE_TOKEN", + "LoopExitReason", + "SignalParser", + "get_loop_exit_reason", + "signal_should_continue", +] + +# Text tokens the agent emits in its response to signal loop state. +# Case-sensitive: the agent is instructed to emit the exact tokens. +TASK_COMPLETE_TOKEN: str = "TASK_COMPLETE:" # ruff: ignore[hardcoded-password-string] +NEED_INPUT_TOKEN: str = "NEED_INPUT:" # ruff: ignore[hardcoded-password-string] + +# Keys used in AgentResponse.additional_properties to surface loop metadata. +LOOP_EXIT_REASON_KEY: str = "loop_exit_reason" +LOOP_NEED_INPUT_KEY: str = "loop_need_input" + + +class LoopExitReason: + """Typed exit reason strings for AgentLoopMiddleware runs. + + Values are plain strings (not an enum) to match the ``FinishReason`` + convention in this codebase and remain JSON-transparent. + + Examples:: + + from agent_framework import get_loop_exit_reason, LoopExitReason + + exit_reason = get_loop_exit_reason(response) + if exit_reason == LoopExitReason.completed: + print("Task done!") + elif exit_reason == LoopExitReason.need_input: + # question is in response.text after NEED_INPUT: + """ + + completed: str = "completed" + """``TASK_COMPLETE:`` signal detected in the agent's response.""" + + iteration_cap_reached: str = "iteration_cap_reached" + """``max_iterations`` cap fired before any terminal signal was emitted.""" + + need_input: str = "need_input" + """``NEED_INPUT:`` signal detected; loop stopped to surface a question.""" + + cancelled: str = "cancelled" + """External cancellation (reserved for future use).""" + + token_budget_exceeded: str = "token_budget_exceeded" # ruff: ignore[hardcoded-password-string] + """Token budget (``max_tokens``) exhausted before the loop completed.""" + + time_budget_exceeded: str = "time_budget_exceeded" + """Wall-clock time budget (``max_duration``) exhausted before the loop completed.""" + + +class SignalParser: + """A ``should_continue`` predicate that scans for signal tokens. + + Scans the agent's latest response text for :data:`TASK_COMPLETE_TOKEN` + and :data:`NEED_INPUT_TOKEN` and returns the appropriate continuation + decision for :class:`~agent_framework.AgentLoopMiddleware`. + + ``TASK_COMPLETE:`` is checked before ``NEED_INPUT:``; if an agent emits + both, the loop treats the run as completed. + + The extracted text after the token (up to the first newline) is returned + as the feedback string so ``next_message`` and ``record_feedback`` + callbacks can reference it. + + Examples:: + + from agent_framework import AgentLoopMiddleware, signal_should_continue + + agent = Agent( + client=client, + middleware=[AgentLoopMiddleware(signal_should_continue())], + ) + """ + + def __call__(self, *, last_result: AgentResponse, **kwargs: Any) -> tuple[bool, str | None]: + """Return ``(False, extracted_text)`` on a terminal signal, ``(True, None)`` otherwise.""" + text = last_result.text or "" + + idx = text.find(TASK_COMPLETE_TOKEN) + if idx != -1: + summary = text[idx + len(TASK_COMPLETE_TOKEN) :].split("\n", 1)[0].strip() + return (False, summary or None) + + idx = text.find(NEED_INPUT_TOKEN) + if idx != -1: + question = text[idx + len(NEED_INPUT_TOKEN) :].split("\n", 1)[0].strip() + return (False, question or None) + + return (True, None) + + +def signal_should_continue() -> SignalParser: + """Return a :class:`SignalParser` instance ready for :class:`~agent_framework.AgentLoopMiddleware`. + + Pass the returned callable as ``should_continue``:: + + middleware = AgentLoopMiddleware(signal_should_continue()) + """ + return SignalParser() + + +def get_loop_exit_reason(response: AgentResponse) -> str | None: + """Return the typed exit reason for a completed loop run, or ``None`` if unknown. + + Scans the agent's own (assistant-role) response messages for signal tokens first, + then falls back to ``response.additional_properties[LOOP_EXIT_REASON_KEY]`` + (set by :class:`~agent_framework.AgentLoopMiddleware` when the iteration cap fires). + + Signal tokens take priority: an agent that emitted ``TASK_COMPLETE:`` on the final + allowed iteration is treated as completed, not cap-reached. + + Args: + response: The :class:`~agent_framework.AgentResponse` returned by the loop. + + Returns: + A :class:`LoopExitReason` string, or ``None`` when the reason cannot + be determined (e.g. the loop was stopped by a custom predicate with no + signal tokens in the response). + + Examples: + + .. code-block:: python + + from agent_framework import get_loop_exit_reason, LoopExitReason + + response = await agent.run(task) + match get_loop_exit_reason(response): + case LoopExitReason.completed: + print("Done:", response.text) + case LoopExitReason.need_input: + # Question is in response.text after NEED_INPUT: + # LOOP_NEED_INPUT_KEY is a convention key reserved for future use. + print("Agent needs input; see response.text for the question.") + case LoopExitReason.iteration_cap_reached: + print("Hit cap - partial result") + case _: + print("Unknown exit reason") + """ + # Scan only assistant messages - the agent's own output. User-role nudges injected + # by the loop between iterations must not trigger a false completion signal. + assistant_text = "".join(m.text or "" for m in response.messages if m.role == "assistant") + if TASK_COMPLETE_TOKEN in assistant_text: + return LoopExitReason.completed + if NEED_INPUT_TOKEN in assistant_text: + return LoopExitReason.need_input + # Fall back to additional_properties for framework-set reasons (e.g. iteration cap). + return response.additional_properties.get(LOOP_EXIT_REASON_KEY) or None diff --git a/python/packages/core/tests/core/test_harness_loop.py b/python/packages/core/tests/core/test_harness_loop.py index 5332f65cd21..57243893a19 100644 --- a/python/packages/core/tests/core/test_harness_loop.py +++ b/python/packages/core/tests/core/test_harness_loop.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from typing import Any @@ -9,7 +10,9 @@ from pydantic import BaseModel from agent_framework import ( + LOOP_EXIT_REASON_KEY, Agent, + AgentBudget, AgentContext, AgentMiddleware, AgentModeProvider, @@ -24,14 +27,19 @@ Content, ContextProvider, JudgeVerdict, + LoopExitReason, Message, MiddlewareTermination, ResponseStream, + SignalParser, TodoItem, TodoProvider, + UsageDetails, background_tasks_running, background_tasks_running_message, + get_loop_exit_reason, set_agent_mode, + signal_should_continue, todos_remaining, todos_remaining_message, tool, @@ -61,8 +69,10 @@ def __init__( texts: list[str] | None = None, honor_response_format: bool = False, service_mode: bool = False, + usage_per_call: int | None = None, ) -> None: super().__init__() + self._usage_per_call = usage_per_call self.call_count: int = 0 self.received_messages: list[list[str]] = [] self.received_response_formats: list[Any] = [] @@ -108,6 +118,9 @@ async def _get() -> ChatResponse: messages=Message(role="assistant", contents=[self._next_text(messages)]), response_format=response_format if self._honor_response_format else None, conversation_id=conversation_id, + usage_details=( + UsageDetails(total_token_count=self._usage_per_call) if self._usage_per_call is not None else None + ), ) return _get() @@ -1639,3 +1652,245 @@ async def test_concurrent_same_agent_run_during_stream_pause_is_not_suppressed() # once for the loop boundary, once for the concurrent run's own boundary assert turn_scoped.after_calls == 2 + + +# --- Signal Protocol Tests --- + + +def _make_signal_response(text: str, additional_properties: dict[str, Any] | None = None) -> AgentResponse: + return AgentResponse( + messages=[Message(role="assistant", contents=[text])], + additional_properties=additional_properties, + ) + + +def test_signal_parser_task_complete_stops_and_extracts_summary() -> None: + response = _make_signal_response("TASK_COMPLETE: finished the job") + assert SignalParser()(last_result=response) == (False, "finished the job") + + +def test_signal_parser_need_input_stops_and_extracts_question() -> None: + response = _make_signal_response("NEED_INPUT: which file should I edit?") + assert SignalParser()(last_result=response) == (False, "which file should I edit?") + + +def test_signal_parser_no_signal_continues() -> None: + response = _make_signal_response("I am still working on it.") + assert SignalParser()(last_result=response) == (True, None) + + +def test_signal_parser_task_complete_wins_over_need_input() -> None: + response = _make_signal_response("TASK_COMPLETE: done\nNEED_INPUT: are you sure?") + assert SignalParser()(last_result=response) == (False, "done") + + +def test_signal_parser_extracts_only_first_line_after_token() -> None: + response = _make_signal_response("Some preamble\nTASK_COMPLETE: line1\nmore stuff after") + assert SignalParser()(last_result=response) == (False, "line1") + + +def test_signal_parser_empty_response_continues() -> None: + response = AgentResponse(messages=[]) + assert SignalParser()(last_result=response) == (True, None) + + +def test_signal_parser_token_with_no_following_text_returns_none_feedback() -> None: + response = _make_signal_response("TASK_COMPLETE:") + assert SignalParser()(last_result=response) == (False, None) + + +def test_get_loop_exit_reason_completed_from_text() -> None: + response = _make_signal_response("TASK_COMPLETE: done") + assert get_loop_exit_reason(response) == LoopExitReason.completed + + +def test_get_loop_exit_reason_need_input_from_text() -> None: + response = _make_signal_response("NEED_INPUT: which approach?") + assert get_loop_exit_reason(response) == LoopExitReason.need_input + + +def test_get_loop_exit_reason_cap_from_additional_properties() -> None: + response = _make_signal_response("still going", additional_properties={"loop_exit_reason": "iteration_cap_reached"}) + assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached + + +def test_get_loop_exit_reason_signal_wins_over_additional_properties() -> None: + response = _make_signal_response( + "TASK_COMPLETE: done", additional_properties={"loop_exit_reason": "iteration_cap_reached"} + ) + assert get_loop_exit_reason(response) == LoopExitReason.completed + + +def test_get_loop_exit_reason_unknown_returns_none() -> None: + response = _make_signal_response("nothing special") + assert get_loop_exit_reason(response) is None + + +def test_signal_should_continue_factory_returns_signal_parser() -> None: + assert isinstance(signal_should_continue(), SignalParser) + + +async def test_loop_stops_on_task_complete_signal() -> None: + client = RecordingChatClient(texts=["TASK_COMPLETE: all done"]) + agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) + + response = await agent.run("task") + + assert client.call_count == 1 + assert get_loop_exit_reason(response) == LoopExitReason.completed + assert LOOP_EXIT_REASON_KEY not in response.additional_properties + + +async def test_loop_stops_on_need_input_signal() -> None: + client = RecordingChatClient(texts=["NEED_INPUT: which approach?"]) + agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) + + response = await agent.run("task") + + assert client.call_count == 1 + assert get_loop_exit_reason(response) == LoopExitReason.need_input + assert LOOP_EXIT_REASON_KEY not in response.additional_properties + + +async def test_loop_sets_iteration_cap_reason_when_no_signal() -> None: + client = RecordingChatClient() + agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=2)]) + + response = await agent.run("task") + + assert client.call_count == 2 + assert response.additional_properties.get("loop_exit_reason") == LoopExitReason.iteration_cap_reached + assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached + + +async def test_loop_sets_iteration_cap_reason_streaming() -> None: + client = RecordingChatClient() + agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=2)]) + + stream = agent.run("task", stream=True) + async for _ in stream: + pass + response = await stream.get_final_response() + + assert client.call_count == 2 + assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached + + +async def test_get_loop_exit_reason_ignores_nudge_text_with_token() -> None: + # A next_message nudge that mentions TASK_COMPLETE: must not trigger a false positive. + # Scripted texts keep the agent from echoing the nudge back in its own messages. + client = RecordingChatClient(texts=["working...", "still working..."]) + agent = Agent( + client=client, + middleware=[ + AgentLoopMiddleware( + signal_should_continue(), + max_iterations=2, + next_message=lambda **kwargs: "Reminder: emit TASK_COMPLETE: when done.", + ) + ], + ) + + response = await agent.run("task") + + assert client.call_count == 2 + assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached + assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached + + +async def test_loop_continues_when_no_signal() -> None: + client = RecordingChatClient(texts=["working...", "still working...", "TASK_COMPLETE: finally done"]) + agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) + + response = await agent.run("task") + + assert client.call_count == 3 + assert get_loop_exit_reason(response) == LoopExitReason.completed + + +# region budget + + +async def test_budget_stops_on_token_limit() -> None: + client = RecordingChatClient(usage_per_call=50) + agent = Agent( + client=client, + middleware=[AgentLoopMiddleware(always_continue, max_iterations=10, budget=AgentBudget(max_tokens=100))], + ) + + response = await agent.run("task") + + assert client.call_count == 2 + assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == "token_budget_exceeded" + + +async def test_budget_stops_on_duration_limit() -> None: + class _SlowClient(RecordingChatClient): + def _inner_get_response(self, **kwargs: Any) -> Any: + time.sleep(0.05) # exceed the 1ms budget (Windows clock tick is ~15ms) + return super()._inner_get_response(**kwargs) + + client = _SlowClient() + agent = Agent( + client=client, + middleware=[AgentLoopMiddleware(always_continue, max_iterations=10, budget=AgentBudget(max_duration=0.001))], + ) + + response = await agent.run("task") + + assert client.call_count == 1 + assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.time_budget_exceeded + + +async def test_budget_not_triggered_when_none() -> None: + client = RecordingChatClient(usage_per_call=50) + agent = Agent(client=client, middleware=[AgentLoopMiddleware(always_continue, max_iterations=3)]) + + response = await agent.run("task") + + assert client.call_count == 3 + assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached + + +async def test_budget_token_check_before_should_continue() -> None: + client = RecordingChatClient(usage_per_call=50) + calls: list[int] = [] + + def should_continue(*, iteration: int, **kwargs: Any) -> bool: + calls.append(iteration) + return True + + agent = Agent( + client=client, + middleware=[AgentLoopMiddleware(should_continue, max_iterations=10, budget=AgentBudget(max_tokens=100))], + ) + + await agent.run("task") + + assert client.call_count == 2 + # should_continue ran only for iteration 1; the budget stop pre-empted it on iteration 2. + assert calls == [1] + + +async def test_budget_exit_reason_in_additional_properties() -> None: + client = RecordingChatClient(usage_per_call=60) + agent = Agent( + client=client, + middleware=[AgentLoopMiddleware(always_continue, budget=AgentBudget(max_tokens=50))], + ) + + response = await agent.run("task") + + assert client.call_count == 1 + assert response.additional_properties["loop_exit_reason"] == LoopExitReason.token_budget_exceeded + assert get_loop_exit_reason(response) == LoopExitReason.token_budget_exceeded + + +def test_agent_budget_validates_max_tokens() -> None: + with pytest.raises(ValueError, match="max_tokens"): + AgentBudget(max_tokens=0) + + +def test_agent_budget_validates_max_duration() -> None: + with pytest.raises(ValueError, match="max_duration"): + AgentBudget(max_duration=-1.0) From 57a59a907967f2d770056c98733f4f24c62aa280 Mon Sep 17 00:00:00 2001 From: Mark Licata Date: Tue, 6 Oct 2026 13:48:15 -0700 Subject: [PATCH 2/2] Scope AgentBudget PR to budget only; fix CI failures Remove signal-protocol pieces that belong to #8993 (Python _signals.py, its exports and tests; .NET Completed/NeedInput/Cancelled exit reasons and evaluator exit-reason propagation) and AsyncRequestBroker API entries that belong to #8995. Iteration-cap stamping is kept since the budget exit-reason contract and tests depend on it. Also fix CI: restore PublicAPI BOMs, drop redundant System. qualifiers (IDE0001/IDE0002), use the field keyword for validated options (IDE0032), remove pyright-redundant None checks, and use a valid positive MaxDuration in the time-budget test. Co-Authored-By: Claude Opus 5.5 --- .../Harness/Loop/LoopAgent.cs | 11 +- .../Harness/Loop/LoopAgentOptions.cs | 12 +- .../Harness/Loop/LoopExitReason.cs | 16 +- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 11 +- .../PublicAPI/net472/PublicAPI.Unshipped.txt | 11 +- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 11 +- .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 11 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 11 +- .../Harness/Loop/LoopAgentBudgetTests.cs | 2 +- python/CHANGELOG.md | 2 +- .../packages/core/agent_framework/__init__.py | 18 -- .../core/agent_framework/__init__.pyi | 18 -- .../core/agent_framework/_harness/_loop.py | 19 +- .../core/agent_framework/_harness/_signals.py | 174 ------------------ .../core/tests/core/test_harness_loop.py | 168 +---------------- 15 files changed, 23 insertions(+), 472 deletions(-) delete mode 100644 python/packages/core/agent_framework/_harness/_signals.py diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs index d537c61294c..809ef8259d7 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -72,7 +71,7 @@ public sealed class LoopAgent : DelegatingAIAgent private readonly IReadOnlyList _evaluators; private readonly int _maxIterations; private readonly long? _maxTokens; - private readonly TimeSpan? _maxDuration; + private readonly System.TimeSpan? _maxDuration; private readonly bool _freshContextPerIteration; private readonly string? _onBehalfOfAuthorName; private readonly bool _excludeOnBehalfOfMessages; @@ -242,13 +241,7 @@ protected override async Task RunCoreAsync( LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false); if (!step.ShouldContinue) { - AgentResponse evalResult = this.BuildResult(response, transcript, aggregatedUsage); - if (context.AdditionalProperties.TryGetValue(LoopExitReason.AdditionalPropertiesKey, out object? exitReason)) - { - (evalResult.AdditionalProperties ??= new())[LoopExitReason.AdditionalPropertiesKey] = exitReason; - } - - return evalResult; + return this.BuildResult(response, transcript, aggregatedUsage); } currentMessages = step.Messages; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs index cf3baf74b04..a00e1d35f6b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs @@ -124,7 +124,7 @@ public sealed class LoopAgentOptions /// public long? MaxTokens { - get => this._maxTokens; + get; set { if (value.HasValue && value.Value < 1) @@ -132,12 +132,10 @@ public long? MaxTokens throw new ArgumentOutOfRangeException(nameof(value), "MaxTokens must be a positive integer."); } - this._maxTokens = value; + field = value; } } - private long? _maxTokens; - /// Gets or sets the maximum wall-clock duration for the entire loop run, or for unconstrained. /// /// Checked after each iteration. Catches agents blocked on slow tools that token budgets cannot detect. @@ -146,7 +144,7 @@ public long? MaxTokens /// public TimeSpan? MaxDuration { - get => this._maxDuration; + get; set { if (value.HasValue && value.Value <= TimeSpan.Zero) @@ -154,9 +152,7 @@ public TimeSpan? MaxDuration throw new ArgumentOutOfRangeException(nameof(value), "MaxDuration must be a positive duration."); } - this._maxDuration = value; + field = value; } } - - private TimeSpan? _maxDuration; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs index 11c3e64347c..12c0720f43d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs @@ -16,11 +16,6 @@ namespace Microsoft.Agents.AI; /// In streaming mode the response is assembled from yielded updates by the caller, so there is no /// single AgentResponse object available inside the generator to stamp. /// -/// -/// Evaluators such as stamp the exit reason on -/// before returning Stop(); the loop then -/// copies that value to the returned AgentResponse.AdditionalProperties. -/// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public static class LoopExitReason @@ -28,18 +23,9 @@ public static class LoopExitReason /// The key used in AgentResponse.AdditionalProperties to surface the loop exit reason. public const string AdditionalPropertiesKey = "loop_exit_reason"; - /// A TASK_COMPLETE: signal was detected in the agent's response. - public const string Completed = "completed"; - - /// The max-iterations cap fired before any terminal signal was emitted. + /// The cap was reached before the evaluators stopped the loop. public const string IterationCapReached = "iteration_cap_reached"; - /// A NEED_INPUT: signal was detected; the loop stopped to surface a question. - public const string NeedInput = "need_input"; - - /// External cancellation (reserved for future use). - public const string Cancelled = "cancelled"; - /// The token budget () was exhausted before the loop completed. public const string TokenBudgetExceeded = "token_budget_exceeded"; diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index fff74b8c8dd..7676ecacf21 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -26,19 +26,10 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! [MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! -[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index fff74b8c8dd..7676ecacf21 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -26,19 +26,10 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! [MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! -[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index fff74b8c8dd..7676ecacf21 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -26,19 +26,10 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! [MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! -[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index fff74b8c8dd..7676ecacf21 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -26,19 +26,10 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! [MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! -[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index fff74b8c8dd..7676ecacf21 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! @@ -26,19 +26,10 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void [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.Cancelled = "cancelled" -> 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.Completed = "completed" -> string! [MAAI001]const Microsoft.Agents.AI.LoopExitReason.IterationCapReached = "iteration_cap_reached" -> string! -[MAAI001]const Microsoft.Agents.AI.LoopExitReason.NeedInput = "need_input" -> string! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.AsyncRequestBroker(int settledQueueSize = 256) -> void -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.PendingCount.get -> int -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Reject(string! requestId, System.Exception! exception) -> bool -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker.Resolve(string! requestId, TResult result) -> bool diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs index a95bc2e8a56..655b4377986 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentBudgetTests.cs @@ -42,7 +42,7 @@ public async Task RunAsync_TimeBudgetExceeded_StopsAndStampsReasonAsync() { // Arrange var inner = new InnerAgentCapture(_ => ResponseWithTokens(1)); - var agent = new LoopAgent(inner.Agent, LoopTestHelpers.While(_ => true), new LoopAgentOptions { MaxDuration = TimeSpan.Zero, MaxIterations = 10 }); + var agent = new LoopAgent(inner.Agent, LoopTestHelpers.While(_ => true), new LoopAgentOptions { MaxDuration = TimeSpan.FromTicks(1), MaxIterations = 10 }); // Act AgentResponse response = await agent.RunAsync("task", new ChatClientAgentSession()); diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index bddbda19feb..3ea45f30b90 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **agent-framework-core**: Add `AgentBudget` with `max_tokens` and `max_duration` constraints for `AgentLoopMiddleware` autonomous loops; budget stops stamp `loop_exit_reason` in `AgentResponse.additional_properties`. Add `LoopExitReason.token_budget_exceeded` and `LoopExitReason.time_budget_exceeded`. +- **agent-framework-core**: Add `AgentBudget` with `max_tokens` and `max_duration` constraints for `AgentLoopMiddleware` autonomous loops; budget stops stamp `loop_exit_reason` (`"token_budget_exceeded"` or `"time_budget_exceeded"`) in `AgentResponse.additional_properties`, and reaching `max_iterations` stamps `"iteration_cap_reached"`. ## [1.20.0] - 2026-10-02 diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index ec69c33cbe3..efacbf476c3 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -149,16 +149,6 @@ "MemoryTopicRecord", ), "._harness._mode": ("DEFAULT_MODE_SOURCE_ID", "AgentModeProvider", "get_agent_mode", "set_agent_mode"), - "._harness._signals": ( - "LoopExitReason", - "LOOP_EXIT_REASON_KEY", - "LOOP_NEED_INPUT_KEY", - "NEED_INPUT_TOKEN", - "SignalParser", - "TASK_COMPLETE_TOKEN", - "get_loop_exit_reason", - "signal_should_continue", - ), "._harness._todo": ( "DEFAULT_TODO_SOURCE_ID", "TodoFileStore", @@ -435,15 +425,11 @@ "GROUP_KIND_KEY", "GROUP_TOKEN_COUNT_KEY", "INTERNAL_SOURCE_ID", - "LOOP_EXIT_REASON_KEY", - "LOOP_NEED_INPUT_KEY", "MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY", - "NEED_INPUT_TOKEN", "SKIP_PARSING", "SUMMARIZED_BY_SUMMARY_ID_KEY", "SUMMARY_OF_GROUP_IDS_KEY", "SUMMARY_OF_MESSAGE_IDS_KEY", - "TASK_COMPLETE_TOKEN", "USER_AGENT_KEY", "USER_AGENT_TELEMETRY_DISABLED_ENV_VAR", "Agent", @@ -570,7 +556,6 @@ "InlineSkillScript", "JudgeVerdict", "LocalEvaluator", - "LoopExitReason", "MCPSkill", "MCPSkillResource", "MCPSkillsSource", @@ -613,7 +598,6 @@ "ServiceSessionId", "SessionContext", "SessionStore", - "SignalParser", "SingleEdgeGroup", "Skill", "SkillFrontmatter", @@ -714,7 +698,6 @@ "executor", "function_middleware", "get_agent_mode", - "get_loop_exit_reason", "get_run_context", "handler", "included_messages", @@ -734,7 +717,6 @@ "resolve_agent_id", "response_handler", "set_agent_mode", - "signal_should_continue", "step", "todos_remaining", "todos_remaining_message", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index 7599fb6725f..5ba07b7e58a 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -107,16 +107,6 @@ from ._harness._memory import ( MemoryTopicRecord, ) from ._harness._mode import DEFAULT_MODE_SOURCE_ID, AgentModeProvider, get_agent_mode, set_agent_mode -from ._harness._signals import ( - LOOP_EXIT_REASON_KEY, - LOOP_NEED_INPUT_KEY, - NEED_INPUT_TOKEN, - TASK_COMPLETE_TOKEN, - LoopExitReason, - SignalParser, - get_loop_exit_reason, - signal_should_continue, -) from ._harness._todo import ( DEFAULT_TODO_SOURCE_ID, TodoFileStore, @@ -390,15 +380,11 @@ __all__ = [ "GROUP_KIND_KEY", "GROUP_TOKEN_COUNT_KEY", "INTERNAL_SOURCE_ID", - "LOOP_EXIT_REASON_KEY", - "LOOP_NEED_INPUT_KEY", "MESSAGE_INJECTION_PENDING_MESSAGES_STATE_KEY", - "NEED_INPUT_TOKEN", "SKIP_PARSING", "SUMMARIZED_BY_SUMMARY_ID_KEY", "SUMMARY_OF_GROUP_IDS_KEY", "SUMMARY_OF_MESSAGE_IDS_KEY", - "TASK_COMPLETE_TOKEN", "USER_AGENT_KEY", "USER_AGENT_TELEMETRY_DISABLED_ENV_VAR", "Agent", @@ -525,7 +511,6 @@ __all__ = [ "InlineSkillScript", "JudgeVerdict", "LocalEvaluator", - "LoopExitReason", "MCPSkill", "MCPSkillResource", "MCPSkillsSource", @@ -568,7 +553,6 @@ __all__ = [ "ServiceSessionId", "SessionContext", "SessionStore", - "SignalParser", "SingleEdgeGroup", "Skill", "SkillFrontmatter", @@ -669,7 +653,6 @@ __all__ = [ "executor", "function_middleware", "get_agent_mode", - "get_loop_exit_reason", "get_run_context", "handler", "included_messages", @@ -689,7 +672,6 @@ __all__ = [ "resolve_agent_id", "response_handler", "set_agent_mode", - "signal_should_continue", "step", "todos_remaining", "todos_remaining_message", diff --git a/python/packages/core/agent_framework/_harness/_loop.py b/python/packages/core/agent_framework/_harness/_loop.py index 66112aecdaf..762f82e55e4 100644 --- a/python/packages/core/agent_framework/_harness/_loop.py +++ b/python/packages/core/agent_framework/_harness/_loop.py @@ -22,15 +22,6 @@ structured output; provider-specific formats can be supplied with a parser that converts the response into ``JudgeVerdict``. The loop continues while the answer is "no". This is a convenience wrapper that builds an async ``should_continue`` predicate, so it is a special case of (1). -3. Signal protocol (via :func:`~agent_framework.signal_should_continue` from ``_signals.py``) - the - agent emits ``TASK_COMPLETE: `` or ``NEED_INPUT: `` in its response text to - signal terminal intent. Only assistant-role messages are scanned; user-role nudges injected between - iterations do not trigger false positives. ``TASK_COMPLETE:`` is checked first. Read the result - with :func:`~agent_framework.get_loop_exit_reason`, which returns a - :class:`~agent_framework.LoopExitReason` string (``"completed"``, ``"iteration_cap_reached"``, - ``"need_input"``). When ``max_iterations`` fires the loop stamps - ``additional_properties["loop_exit_reason"] = "iteration_cap_reached"``; signal tokens always win - over the cap. In every case, the input for the next iteration is controlled by the ``next_message`` callable. """ @@ -157,13 +148,13 @@ class AgentBudget: .. code-block:: python - from agent_framework import AgentBudget, AgentLoopMiddleware, signal_should_continue + from agent_framework import AgentBudget, AgentLoopMiddleware, todos_remaining agent = Agent( client=client, middleware=[ AgentLoopMiddleware( - signal_should_continue(), + todos_remaining(), budget=AgentBudget(max_tokens=50_000, max_duration=300.0), ) ], @@ -713,9 +704,9 @@ async def _process_non_streaming( finally: context.options.pop(_LOOP_ITERATION_TOKEN_KEY, None) - if cap_fired and final_result is not None: + if cap_fired: final_result.additional_properties.setdefault("loop_exit_reason", "iteration_cap_reached") - if budget_exit is not None and final_result is not None: + if budget_exit is not None: final_result.additional_properties.setdefault("loop_exit_reason", budget_exit) if not self.return_final_only: @@ -791,7 +782,7 @@ async def _generator() -> Any: # ``loop_exit_reason`` on the response (the caller assembles it from updates), so # the loop simply stops, consistent with the iteration-cap limitation. if self.budget is not None: - if self.budget.max_tokens is not None and final is not None and final.usage_details: + if self.budget.max_tokens is not None and final.usage_details: ud = final.usage_details tokens_used += ud.get("total_token_count") or ( (ud.get("input_token_count") or 0) + (ud.get("output_token_count") or 0) diff --git a/python/packages/core/agent_framework/_harness/_signals.py b/python/packages/core/agent_framework/_harness/_signals.py deleted file mode 100644 index d3d4b0294ea..00000000000 --- a/python/packages/core/agent_framework/_harness/_signals.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Signal protocol utilities for AgentLoopMiddleware autonomous loops. - -Provides a text-based convention for agents to signal loop termination: - TASK_COMPLETE: -- agent is done; loop should stop - NEED_INPUT: -- agent needs human input; loop should stop - -Use :func:`signal_should_continue` to get a :class:`SignalParser` instance -ready to pass as ``should_continue`` to :class:`~agent_framework.AgentLoopMiddleware`. -Use :func:`get_loop_exit_reason` to read the typed exit reason after the loop. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .._types import AgentResponse - -__all__ = [ - "LOOP_EXIT_REASON_KEY", - "LOOP_NEED_INPUT_KEY", - "NEED_INPUT_TOKEN", - "TASK_COMPLETE_TOKEN", - "LoopExitReason", - "SignalParser", - "get_loop_exit_reason", - "signal_should_continue", -] - -# Text tokens the agent emits in its response to signal loop state. -# Case-sensitive: the agent is instructed to emit the exact tokens. -TASK_COMPLETE_TOKEN: str = "TASK_COMPLETE:" # ruff: ignore[hardcoded-password-string] -NEED_INPUT_TOKEN: str = "NEED_INPUT:" # ruff: ignore[hardcoded-password-string] - -# Keys used in AgentResponse.additional_properties to surface loop metadata. -LOOP_EXIT_REASON_KEY: str = "loop_exit_reason" -LOOP_NEED_INPUT_KEY: str = "loop_need_input" - - -class LoopExitReason: - """Typed exit reason strings for AgentLoopMiddleware runs. - - Values are plain strings (not an enum) to match the ``FinishReason`` - convention in this codebase and remain JSON-transparent. - - Examples:: - - from agent_framework import get_loop_exit_reason, LoopExitReason - - exit_reason = get_loop_exit_reason(response) - if exit_reason == LoopExitReason.completed: - print("Task done!") - elif exit_reason == LoopExitReason.need_input: - # question is in response.text after NEED_INPUT: - """ - - completed: str = "completed" - """``TASK_COMPLETE:`` signal detected in the agent's response.""" - - iteration_cap_reached: str = "iteration_cap_reached" - """``max_iterations`` cap fired before any terminal signal was emitted.""" - - need_input: str = "need_input" - """``NEED_INPUT:`` signal detected; loop stopped to surface a question.""" - - cancelled: str = "cancelled" - """External cancellation (reserved for future use).""" - - token_budget_exceeded: str = "token_budget_exceeded" # ruff: ignore[hardcoded-password-string] - """Token budget (``max_tokens``) exhausted before the loop completed.""" - - time_budget_exceeded: str = "time_budget_exceeded" - """Wall-clock time budget (``max_duration``) exhausted before the loop completed.""" - - -class SignalParser: - """A ``should_continue`` predicate that scans for signal tokens. - - Scans the agent's latest response text for :data:`TASK_COMPLETE_TOKEN` - and :data:`NEED_INPUT_TOKEN` and returns the appropriate continuation - decision for :class:`~agent_framework.AgentLoopMiddleware`. - - ``TASK_COMPLETE:`` is checked before ``NEED_INPUT:``; if an agent emits - both, the loop treats the run as completed. - - The extracted text after the token (up to the first newline) is returned - as the feedback string so ``next_message`` and ``record_feedback`` - callbacks can reference it. - - Examples:: - - from agent_framework import AgentLoopMiddleware, signal_should_continue - - agent = Agent( - client=client, - middleware=[AgentLoopMiddleware(signal_should_continue())], - ) - """ - - def __call__(self, *, last_result: AgentResponse, **kwargs: Any) -> tuple[bool, str | None]: - """Return ``(False, extracted_text)`` on a terminal signal, ``(True, None)`` otherwise.""" - text = last_result.text or "" - - idx = text.find(TASK_COMPLETE_TOKEN) - if idx != -1: - summary = text[idx + len(TASK_COMPLETE_TOKEN) :].split("\n", 1)[0].strip() - return (False, summary or None) - - idx = text.find(NEED_INPUT_TOKEN) - if idx != -1: - question = text[idx + len(NEED_INPUT_TOKEN) :].split("\n", 1)[0].strip() - return (False, question or None) - - return (True, None) - - -def signal_should_continue() -> SignalParser: - """Return a :class:`SignalParser` instance ready for :class:`~agent_framework.AgentLoopMiddleware`. - - Pass the returned callable as ``should_continue``:: - - middleware = AgentLoopMiddleware(signal_should_continue()) - """ - return SignalParser() - - -def get_loop_exit_reason(response: AgentResponse) -> str | None: - """Return the typed exit reason for a completed loop run, or ``None`` if unknown. - - Scans the agent's own (assistant-role) response messages for signal tokens first, - then falls back to ``response.additional_properties[LOOP_EXIT_REASON_KEY]`` - (set by :class:`~agent_framework.AgentLoopMiddleware` when the iteration cap fires). - - Signal tokens take priority: an agent that emitted ``TASK_COMPLETE:`` on the final - allowed iteration is treated as completed, not cap-reached. - - Args: - response: The :class:`~agent_framework.AgentResponse` returned by the loop. - - Returns: - A :class:`LoopExitReason` string, or ``None`` when the reason cannot - be determined (e.g. the loop was stopped by a custom predicate with no - signal tokens in the response). - - Examples: - - .. code-block:: python - - from agent_framework import get_loop_exit_reason, LoopExitReason - - response = await agent.run(task) - match get_loop_exit_reason(response): - case LoopExitReason.completed: - print("Done:", response.text) - case LoopExitReason.need_input: - # Question is in response.text after NEED_INPUT: - # LOOP_NEED_INPUT_KEY is a convention key reserved for future use. - print("Agent needs input; see response.text for the question.") - case LoopExitReason.iteration_cap_reached: - print("Hit cap - partial result") - case _: - print("Unknown exit reason") - """ - # Scan only assistant messages - the agent's own output. User-role nudges injected - # by the loop between iterations must not trigger a false completion signal. - assistant_text = "".join(m.text or "" for m in response.messages if m.role == "assistant") - if TASK_COMPLETE_TOKEN in assistant_text: - return LoopExitReason.completed - if NEED_INPUT_TOKEN in assistant_text: - return LoopExitReason.need_input - # Fall back to additional_properties for framework-set reasons (e.g. iteration cap). - return response.additional_properties.get(LOOP_EXIT_REASON_KEY) or None diff --git a/python/packages/core/tests/core/test_harness_loop.py b/python/packages/core/tests/core/test_harness_loop.py index 32fa15e1dd9..be2b392b271 100644 --- a/python/packages/core/tests/core/test_harness_loop.py +++ b/python/packages/core/tests/core/test_harness_loop.py @@ -10,7 +10,6 @@ from pydantic import BaseModel from agent_framework import ( - LOOP_EXIT_REASON_KEY, Agent, AgentBudget, AgentContext, @@ -27,19 +26,15 @@ Content, ContextProvider, JudgeVerdict, - LoopExitReason, Message, MiddlewareTermination, ResponseStream, - SignalParser, TodoItem, TodoProvider, UsageDetails, background_tasks_running, background_tasks_running_message, - get_loop_exit_reason, set_agent_mode, - signal_should_continue, todos_remaining, todos_remaining_message, tool, @@ -1712,160 +1707,6 @@ async def test_concurrent_same_agent_run_during_stream_pause_is_not_suppressed() assert turn_scoped.after_calls == 2 -# --- Signal Protocol Tests --- - - -def _make_signal_response(text: str, additional_properties: dict[str, Any] | None = None) -> AgentResponse: - return AgentResponse( - messages=[Message(role="assistant", contents=[text])], - additional_properties=additional_properties, - ) - - -def test_signal_parser_task_complete_stops_and_extracts_summary() -> None: - response = _make_signal_response("TASK_COMPLETE: finished the job") - assert SignalParser()(last_result=response) == (False, "finished the job") - - -def test_signal_parser_need_input_stops_and_extracts_question() -> None: - response = _make_signal_response("NEED_INPUT: which file should I edit?") - assert SignalParser()(last_result=response) == (False, "which file should I edit?") - - -def test_signal_parser_no_signal_continues() -> None: - response = _make_signal_response("I am still working on it.") - assert SignalParser()(last_result=response) == (True, None) - - -def test_signal_parser_task_complete_wins_over_need_input() -> None: - response = _make_signal_response("TASK_COMPLETE: done\nNEED_INPUT: are you sure?") - assert SignalParser()(last_result=response) == (False, "done") - - -def test_signal_parser_extracts_only_first_line_after_token() -> None: - response = _make_signal_response("Some preamble\nTASK_COMPLETE: line1\nmore stuff after") - assert SignalParser()(last_result=response) == (False, "line1") - - -def test_signal_parser_empty_response_continues() -> None: - response = AgentResponse(messages=[]) - assert SignalParser()(last_result=response) == (True, None) - - -def test_signal_parser_token_with_no_following_text_returns_none_feedback() -> None: - response = _make_signal_response("TASK_COMPLETE:") - assert SignalParser()(last_result=response) == (False, None) - - -def test_get_loop_exit_reason_completed_from_text() -> None: - response = _make_signal_response("TASK_COMPLETE: done") - assert get_loop_exit_reason(response) == LoopExitReason.completed - - -def test_get_loop_exit_reason_need_input_from_text() -> None: - response = _make_signal_response("NEED_INPUT: which approach?") - assert get_loop_exit_reason(response) == LoopExitReason.need_input - - -def test_get_loop_exit_reason_cap_from_additional_properties() -> None: - response = _make_signal_response("still going", additional_properties={"loop_exit_reason": "iteration_cap_reached"}) - assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached - - -def test_get_loop_exit_reason_signal_wins_over_additional_properties() -> None: - response = _make_signal_response( - "TASK_COMPLETE: done", additional_properties={"loop_exit_reason": "iteration_cap_reached"} - ) - assert get_loop_exit_reason(response) == LoopExitReason.completed - - -def test_get_loop_exit_reason_unknown_returns_none() -> None: - response = _make_signal_response("nothing special") - assert get_loop_exit_reason(response) is None - - -def test_signal_should_continue_factory_returns_signal_parser() -> None: - assert isinstance(signal_should_continue(), SignalParser) - - -async def test_loop_stops_on_task_complete_signal() -> None: - client = RecordingChatClient(texts=["TASK_COMPLETE: all done"]) - agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) - - response = await agent.run("task") - - assert client.call_count == 1 - assert get_loop_exit_reason(response) == LoopExitReason.completed - assert LOOP_EXIT_REASON_KEY not in response.additional_properties - - -async def test_loop_stops_on_need_input_signal() -> None: - client = RecordingChatClient(texts=["NEED_INPUT: which approach?"]) - agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) - - response = await agent.run("task") - - assert client.call_count == 1 - assert get_loop_exit_reason(response) == LoopExitReason.need_input - assert LOOP_EXIT_REASON_KEY not in response.additional_properties - - -async def test_loop_sets_iteration_cap_reason_when_no_signal() -> None: - client = RecordingChatClient() - agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=2)]) - - response = await agent.run("task") - - assert client.call_count == 2 - assert response.additional_properties.get("loop_exit_reason") == LoopExitReason.iteration_cap_reached - assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached - - -async def test_loop_sets_iteration_cap_reason_streaming() -> None: - client = RecordingChatClient() - agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=2)]) - - stream = agent.run("task", stream=True) - async for _ in stream: - pass - response = await stream.get_final_response() - - assert client.call_count == 2 - assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached - - -async def test_get_loop_exit_reason_ignores_nudge_text_with_token() -> None: - # A next_message nudge that mentions TASK_COMPLETE: must not trigger a false positive. - # Scripted texts keep the agent from echoing the nudge back in its own messages. - client = RecordingChatClient(texts=["working...", "still working..."]) - agent = Agent( - client=client, - middleware=[ - AgentLoopMiddleware( - signal_should_continue(), - max_iterations=2, - next_message=lambda **kwargs: "Reminder: emit TASK_COMPLETE: when done.", - ) - ], - ) - - response = await agent.run("task") - - assert client.call_count == 2 - assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached - assert get_loop_exit_reason(response) == LoopExitReason.iteration_cap_reached - - -async def test_loop_continues_when_no_signal() -> None: - client = RecordingChatClient(texts=["working...", "still working...", "TASK_COMPLETE: finally done"]) - agent = Agent(client=client, middleware=[AgentLoopMiddleware(signal_should_continue(), max_iterations=10)]) - - response = await agent.run("task") - - assert client.call_count == 3 - assert get_loop_exit_reason(response) == LoopExitReason.completed - - # region budget @@ -1879,7 +1720,7 @@ async def test_budget_stops_on_token_limit() -> None: response = await agent.run("task") assert client.call_count == 2 - assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == "token_budget_exceeded" + assert response.additional_properties.get("loop_exit_reason") == "token_budget_exceeded" async def test_budget_stops_on_duration_limit() -> None: @@ -1897,7 +1738,7 @@ def _inner_get_response(self, **kwargs: Any) -> Any: response = await agent.run("task") assert client.call_count == 1 - assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.time_budget_exceeded + assert response.additional_properties.get("loop_exit_reason") == "time_budget_exceeded" async def test_budget_not_triggered_when_none() -> None: @@ -1907,7 +1748,7 @@ async def test_budget_not_triggered_when_none() -> None: response = await agent.run("task") assert client.call_count == 3 - assert response.additional_properties.get(LOOP_EXIT_REASON_KEY) == LoopExitReason.iteration_cap_reached + assert response.additional_properties.get("loop_exit_reason") == "iteration_cap_reached" async def test_budget_token_check_before_should_continue() -> None: @@ -1940,8 +1781,7 @@ async def test_budget_exit_reason_in_additional_properties() -> None: response = await agent.run("task") assert client.call_count == 1 - assert response.additional_properties["loop_exit_reason"] == LoopExitReason.token_budget_exceeded - assert get_loop_exit_reason(response) == LoopExitReason.token_budget_exceeded + assert response.additional_properties["loop_exit_reason"] == "token_budget_exceeded" def test_agent_budget_validates_max_tokens() -> None: