Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,22 @@ protected override async Task<AgentResponse> RunCoreAsync(
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;
Comment thread
marklicata marked this conversation as resolved.
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;
Expand Down Expand Up @@ -332,6 +340,9 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
/// </summary>
private async ValueTask<LoopNextStep> EvaluateAndBuildNextAsync(LoopContext context, List<string?> feedbackLog, JsonElement? initialSessionSnapshot, CancellationToken cancellationToken)
{
// Clear any exit reason stamped during an earlier pass so only the evaluators of the final iteration can supply it.
context.AdditionalProperties.Remove(LoopExitReason.AdditionalPropertiesKey);

// Evaluate in order; the first evaluator that requests a re-invocation wins.
LoopEvaluation? winner = null;
foreach (LoopEvaluator evaluator in this._evaluators)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ namespace Microsoft.Agents.AI;
/// </para>
/// <para>
/// Out-of-the-box implementations include <see cref="AIJudgeLoopEvaluator"/>, <see cref="DelegateLoopEvaluator"/>,
/// <see cref="CompletionMarkerLoopEvaluator"/>, and <see cref="TodoCompletionLoopEvaluator"/>. Implementations should be stateless and safe to share across
/// <see cref="CompletionMarkerLoopEvaluator"/>, <see cref="SignalProtocolLoopEvaluator"/>, and
/// <see cref="TodoCompletionLoopEvaluator"/>. Implementations should be stateless and safe to share across
/// concurrent loop runs; any per-run state must be stored on the supplied <see cref="LoopContext"/>.
/// </para>
/// </remarks>
Expand Down
42 changes: 42 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopExitReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.

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

namespace Microsoft.Agents.AI;

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

/// <summary>A <c>TASK_COMPLETE:</c> signal was detected in the agent's response.</summary>
public const string Completed = "completed";

/// <summary>The max-iterations cap fired before any terminal signal was emitted.</summary>
public const string IterationCapReached = "iteration_cap_reached";

/// <summary>A <c>NEED_INPUT:</c> signal was detected; the loop stopped to surface a question.</summary>
public const string NeedInput = "need_input";

/// <summary>External cancellation (reserved for future use).</summary>
public const string Cancelled = "cancelled";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.

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

namespace Microsoft.Agents.AI;

/// <summary>
/// A <see cref="LoopEvaluator"/> implementing the GT1 signal protocol: the loop stops when an assistant message in the
/// agent's latest response contains <see cref="TaskCompleteToken"/> or <see cref="NeedInputToken"/> at the start of a
/// line, and otherwise continues with feedback reminding the agent to emit one of the signals.
/// </summary>
/// <remarks>
/// <para>
/// A token only counts when it starts its own line: it must appear at the start of the message or right after a line
/// break, optionally preceded by spaces or tabs. Mentions anywhere else on a line (for example
/// "I'll emit <c>TASK_COMPLETE:</c> when done", or a token quoted in backticks) are ignored, so the agent can talk about
/// the protocol without accidentally ending the loop.
/// </para>
/// <para>
/// <see cref="TaskCompleteToken"/> takes priority over <see cref="NeedInputToken"/>. When a signal is detected, the
/// matching <see cref="LoopExitReason"/> is stamped into <see cref="LoopContext.AdditionalProperties"/> under
/// <see cref="LoopExitReason.AdditionalPropertiesKey"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SignalProtocolLoopEvaluator : LoopEvaluator
{
/// <summary>The token signalling that the task is complete. Only recognized at the start of a line.</summary>
public const string TaskCompleteToken = "TASK_COMPLETE:";

/// <summary>The token signalling that the agent needs input from the user. Only recognized at the start of a line.</summary>
public const string NeedInputToken = "NEED_INPUT:";

/// <summary>The default feedback produced while neither signal is present.</summary>
public const string DefaultFeedbackMessage =
"Continue working on the task. When done, start a new line with '" + TaskCompleteToken + " <summary>'. " +
"If you need input, start a new line with '" + NeedInputToken + " <question>'.";

private readonly string _feedbackMessage;

/// <summary>
/// Initializes a new instance of the <see cref="SignalProtocolLoopEvaluator"/> class.
/// </summary>
/// <param name="feedbackMessage">Optional custom feedback used when no signal is present. When <see langword="null"/>, <see cref="DefaultFeedbackMessage"/> is used.</param>
public SignalProtocolLoopEvaluator(string? feedbackMessage = null)
{
this._feedbackMessage = feedbackMessage ?? DefaultFeedbackMessage;
}

/// <inheritdoc />
public override ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);

if (AssistantMessageHasSignal(context, TaskCompleteToken))
{
context.AdditionalProperties[LoopExitReason.AdditionalPropertiesKey] = LoopExitReason.Completed;
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
}

if (AssistantMessageHasSignal(context, NeedInputToken))
{
context.AdditionalProperties[LoopExitReason.AdditionalPropertiesKey] = LoopExitReason.NeedInput;
return new ValueTask<LoopEvaluation>(LoopEvaluation.Stop());
}

return new ValueTask<LoopEvaluation>(LoopEvaluation.Continue(this._feedbackMessage));
}

/// <summary>
/// Determines whether any assistant-role message in the latest response contains <paramref name="token"/> at the
/// start of a line. Messages are matched individually so a token cannot be formed across message boundaries, and
/// non-assistant messages (for example tool results or echoed user input) never trigger a signal.
/// </summary>
private static bool AssistantMessageHasSignal(LoopContext context, string token)
{
foreach (ChatMessage message in context.LastResponse.Messages)
{
if (message.Role == ChatRole.Assistant && ContainsTokenAtLineStart(message.Text, token))
{
return true;
}
}

return false;
}

/// <summary>
/// Determines whether <paramref name="text"/> contains <paramref name="token"/> at the start of a line, allowing only
/// spaces and tabs between the preceding line break (or the start of the text) and the token.
/// </summary>
private static bool ContainsTokenAtLineStart(string text, string token)
{
int index = text.IndexOf(token, StringComparison.Ordinal);
while (index >= 0)
{
int i = index - 1;
while (i >= 0 && (text[i] == ' ' || text[i] == '\t'))
{
i--;
}

// Checking for '\n' covers both "\n" and "\r\n" line endings.
if (i < 0 || text[i] == '\n')
{
return true;
}

index = text.IndexOf(token, index + token.Length, StringComparison.Ordinal);
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator.SignalProtocolLoopEvaluator(string? feedbackMessage = null) -> void
[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.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]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.DefaultFeedbackMessage = "Continue working on the task. When done, start a new line with 'TASK_COMPLETE: <summary>'. If you need input, start a new line with 'NEED_INPUT: <question>'." -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.NeedInputToken = "NEED_INPUT:" -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.TaskCompleteToken = "TASK_COMPLETE:" -> string!
[MAAI001]override Microsoft.Agents.AI.SignalProtocolLoopEvaluator.EvaluateAsync(Microsoft.Agents.AI.LoopContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.LoopEvaluation!>
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator.SignalProtocolLoopEvaluator(string? feedbackMessage = null) -> void
[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.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]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.DefaultFeedbackMessage = "Continue working on the task. When done, start a new line with 'TASK_COMPLETE: <summary>'. If you need input, start a new line with 'NEED_INPUT: <question>'." -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.NeedInputToken = "NEED_INPUT:" -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.TaskCompleteToken = "TASK_COMPLETE:" -> string!
[MAAI001]override Microsoft.Agents.AI.SignalProtocolLoopEvaluator.EvaluateAsync(Microsoft.Agents.AI.LoopContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.LoopEvaluation!>
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator.SignalProtocolLoopEvaluator(string? feedbackMessage = null) -> void
[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.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]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.DefaultFeedbackMessage = "Continue working on the task. When done, start a new line with 'TASK_COMPLETE: <summary>'. If you need input, start a new line with 'NEED_INPUT: <question>'." -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.NeedInputToken = "NEED_INPUT:" -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.TaskCompleteToken = "TASK_COMPLETE:" -> string!
[MAAI001]override Microsoft.Agents.AI.SignalProtocolLoopEvaluator.EvaluateAsync(Microsoft.Agents.AI.LoopContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.LoopEvaluation!>
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.LoopExitReason
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator
[MAAI001]Microsoft.Agents.AI.SignalProtocolLoopEvaluator.SignalProtocolLoopEvaluator(string? feedbackMessage = null) -> void
[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.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]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.DefaultFeedbackMessage = "Continue working on the task. When done, start a new line with 'TASK_COMPLETE: <summary>'. If you need input, start a new line with 'NEED_INPUT: <question>'." -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.NeedInputToken = "NEED_INPUT:" -> string!
[MAAI001]const Microsoft.Agents.AI.SignalProtocolLoopEvaluator.TaskCompleteToken = "TASK_COMPLETE:" -> string!
[MAAI001]override Microsoft.Agents.AI.SignalProtocolLoopEvaluator.EvaluateAsync(Microsoft.Agents.AI.LoopContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.LoopEvaluation!>
Loading
Loading