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
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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

namespace Microsoft.Extensions.AI;

/// <summary>Provides context for an iteration within the function invocation loop.</summary>
/// <remarks>
/// This context is provided to the <see cref="FunctionInvokingChatClient.IterationCompleted"/>
/// callback after each iteration of the function invocation loop completes.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIIterationCompleted, UrlFormat = DiagnosticIds.UrlFormat)]
public class FunctionInvocationIterationContext
{
/// <summary>Gets or sets the current iteration number (0-based).</summary>
/// <remarks>
/// The initial request to the client that passes along the chat contents provided to the
/// <see cref="FunctionInvokingChatClient"/> is iteration 0. If the client responds with
/// a function call request that is processed, the next iteration is 1, and so on.
/// </remarks>
public int Iteration { get; set; }

/// <summary>Gets or sets the aggregated usage details across all iterations so far.</summary>
/// <remarks>
/// This includes usage from all inner client calls and is updated after each iteration.
/// May be <see langword="null"/> if the inner client doesn't provide usage information.
/// </remarks>
public UsageDetails? TotalUsage { get; set; }

/// <summary>Gets or sets the messages accumulated during the function-calling loop.</summary>
/// <remarks>
/// This includes all messages from all iterations, including function call and result contents.
/// </remarks>
public IReadOnlyList<ChatMessage> Messages
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Immutable? You mentioned wanting to use this for context compaction... how are you doing that?

Copy link
Contributor Author

@PederHP PederHP Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside of the loop. This is purely for inspection, to be able to break out of the agentic loop without being forced to do so during a function invocation. If some condition happens during the tool call - I trigger the exit - and I then compact the context (usually not compacting the tool call but doing so further down the message stack), and then re-invoke the IChatClient.

So what I'm looking for is not to be able to mutate the context inside the loop (although to be honest that would be great, but I seem to recall it would require bigger changes, and I found a workaround for having mutable system context during the loop so that's good enough for me).

The current FunctionInvoker allows for aborting a call in a somewhat destructive, messy manner - I want to signal a loop exit in a cleaner way. Finish the current tool (or tools if parallel) and then return - then I can decide what to do and reinvoke the client. Currently the FICC is very closed, and this was the least invasive way I could find to break the loop.

If you think there are better ways, I am happy to try and rework this. But I think FunctionInvoker and Terminate is too limited for modern long-lived agentic flows.

{
get;
set => field = Throw.IfNull(value);
} = [];

/// <summary>Gets or sets the response from the most recent inner client call.</summary>
/// <remarks>
/// This is the response that triggered the current iteration's function invocations.
/// </remarks>
public ChatResponse Response
{
get;
set => field = Throw.IfNull(value);
} = new([]);

/// <summary>Gets or sets a value indicating whether to terminate the loop after this iteration.</summary>
/// <remarks>
/// <para>
/// Setting this to <see langword="true"/> will cause the function invocation loop to exit
/// after the current iteration completes. The function calls for this iteration will have
/// already been processed before this callback is invoked.
/// </para>
/// <para>
/// This is similar to setting <see cref="FunctionInvocationContext.Terminate"/> from within
/// a function, but can be triggered based on external criteria like usage thresholds.
/// </para>
/// </remarks>
public bool Terminate { get; set; }

/// <summary>Gets or sets a value indicating whether the iteration is part of a streaming operation.</summary>
public bool IsStreaming { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
Expand All @@ -12,6 +12,7 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

#pragma warning disable CA2213 // Disposable fields should be disposed
Expand Down Expand Up @@ -254,6 +255,39 @@ public int MaximumConsecutiveErrorsPerRequest
/// </remarks>
public bool TerminateOnUnknownCalls { get; set; }

/// <summary>Gets or sets a delegate called after each iteration of the function invocation loop completes.</summary>
/// <remarks>
/// <para>
/// This delegate is invoked after each iteration completes - specifically, after function invocations
/// are processed and before the next request is made to the inner client. This timing ensures that:
/// </para>
/// <list type="bullet">
/// <item>Function calls are not lost mid-execution</item>
/// <item>Function results are available for inspection</item>
/// <item>Aggregated usage information is up-to-date</item>
/// </list>
/// <para>
/// Common use cases include:
/// </para>
/// <list type="bullet">
/// <item>Token usage monitoring and context compaction triggers</item>
/// <item>Cost limit enforcement</item>
/// <item>Content guardrails and moderation</item>
/// <item>Time limit enforcement</item>
/// <item>Custom iteration-level logging or metrics</item>
/// </list>
/// <para>
/// Set <see cref="FunctionInvocationIterationContext.Terminate"/> to <see langword="true"/>
/// to stop the loop. The function calls for the current iteration will have already been processed.
/// </para>
/// <para>
/// Changing the value of this property while the client is in use might result in inconsistencies
/// as to whether the callback is invoked for an in-flight request.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIIterationCompleted, UrlFormat = DiagnosticIds.UrlFormat)]
public Func<FunctionInvocationIterationContext, CancellationToken, ValueTask>? IterationCompleted { get; set; }

/// <summary>Gets or sets a delegate used to invoke <see cref="AIFunction"/> instances.</summary>
/// <remarks>
/// By default, the protected <see cref="InvokeFunctionAsync"/> method is called for each <see cref="AIFunction"/> to be invoked,
Expand Down Expand Up @@ -399,6 +433,27 @@ public override async Task<ChatResponse> GetResponseAsync(
break;
}

// Call the iteration completed hook if configured
if (IterationCompleted is { } iterationCallback)
{
var iterationContext = new FunctionInvocationIterationContext
{
Iteration = iteration,
TotalUsage = CloneUsageDetails(totalUsage),
Messages = responseMessages,
Response = response,
IsStreaming = false
};

await iterationCallback(iterationContext, cancellationToken);

if (iterationContext.Terminate)
{
LogIterationCallbackRequestedTermination(iteration);
break;
}
}

UpdateOptionsForNextIteration(ref options, response.ConversationId);
}

Expand All @@ -421,7 +476,10 @@ public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseA
// Create an activity to group them together for better observability. If there's already a genai "invoke_agent"
// span that's current, however, we just consider that the group and don't add a new one.
using Activity? activity = CurrentActivityIsInvokeAgent ? null : _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName);
UsageDetails? totalUsage = activity is { IsAllDataRequested: true } ? new() : null; // tracked usage across all turns, to be used for activity purposes

// Track usage if needed for telemetry or for the iteration callback
bool needsUsageTracking = activity is { IsAllDataRequested: true } || IterationCompleted is not null;
UsageDetails? totalUsage = needsUsageTracking ? new() : null;

// Copy the original messages in order to avoid enumerating the original messages multiple times.
// The IEnumerable can represent an arbitrary amount of work.
Expand Down Expand Up @@ -640,6 +698,27 @@ public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseA
break;
}

// Call the iteration completed hook if configured
if (IterationCompleted is { } iterationCallback)
{
var iterationContext = new FunctionInvocationIterationContext
{
Iteration = iteration,
TotalUsage = CloneUsageDetails(totalUsage),
Messages = responseMessages,
Response = response,
IsStreaming = true
};

await iterationCallback(iterationContext, cancellationToken);

if (iterationContext.Terminate)
{
LogIterationCallbackRequestedTermination(iteration);
break;
}
}

UpdateOptionsForNextIteration(ref options, response.ConversationId);
}

Expand Down Expand Up @@ -677,6 +756,31 @@ private static void AddUsageTags(Activity? activity, UsageDetails? usage)
}
}

/// <summary>Creates a defensive copy of usage details to prevent mutation after callback returns.</summary>
private static UsageDetails? CloneUsageDetails(UsageDetails? usage)
{
if (usage is null)
{
return null;
}

var clone = new UsageDetails
{
InputTokenCount = usage.InputTokenCount,
OutputTokenCount = usage.OutputTokenCount,
TotalTokenCount = usage.TotalTokenCount,
CachedInputTokenCount = usage.CachedInputTokenCount,
ReasoningTokenCount = usage.ReasoningTokenCount,
};

if (usage.AdditionalCounts is { } additionalCounts)
{
clone.AdditionalCounts = new(additionalCounts);
}

return clone;
}

/// <summary>Prepares the various chat message lists after a response from the inner client and before invoking functions.</summary>
/// <param name="originalMessages">The original messages provided by the caller.</param>
/// <param name="messages">The messages reference passed to the inner client.</param>
Expand Down Expand Up @@ -1851,6 +1955,9 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) =>
[LoggerMessage(LogLevel.Debug, "Function '{FunctionName}' requested termination of the processing loop.")]
private partial void LogFunctionRequestedTermination(string functionName);

[LoggerMessage(LogLevel.Debug, "Iteration {Iteration}: IterationCompleted callback requested termination.")]
private partial void LogIterationCallbackRequestedTermination(int iteration);

/// <summary>Provides information about the invocation of a function call.</summary>
public sealed class FunctionInvocationResult
{
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal static class Experiments
internal const string AIResponseContinuations = AIExperiments;
internal const string AICodeInterpreter = AIExperiments;
internal const string AIRealTime = AIExperiments;
internal const string AIIterationCompleted = AIExperiments;

private const string AIExperiments = "MEAI001";
}
Expand Down
Loading
Loading