File: Common\FunctionInvocationProcessor.cs
Project: ..\..\..\src\Libraries\Microsoft.Extensions.AI\Microsoft.Extensions.AI.csproj (Microsoft.Extensions.AI)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
 
using FunctionInvocationResult = Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult;
using FunctionInvocationStatus = Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus;
 
namespace Microsoft.Extensions.AI;
 
/// <summary>
/// A composition-based helper class for processing function invocations.
/// Used by both <see cref="FunctionInvokingChatClient"/> and <see cref="FunctionInvokingRealtimeClientSession"/>.
/// </summary>
internal sealed class FunctionInvocationProcessor
{
    private readonly ILogger _logger;
    private readonly ActivitySource? _activitySource;
    private readonly Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> _invokeFunction;
    private readonly Func<Activity?, bool> _isSensitiveDataEnabled;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="FunctionInvocationProcessor"/> class.
    /// </summary>
    /// <param name="logger">The logger to use for logging.</param>
    /// <param name="activitySource">The activity source for telemetry.</param>
    /// <param name="invokeFunction">The delegate to invoke a function.</param>
    /// <param name="isSensitiveDataEnabled">
    /// A delegate that determines whether sensitive data logging is enabled.
    /// Receives the invoke agent activity (or null if not in agent context).
    /// Returns true if sensitive data should be logged/tagged, false otherwise.
    /// </param>
    public FunctionInvocationProcessor(
        ILogger logger,
        ActivitySource? activitySource,
        Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> invokeFunction,
        Func<Activity?, bool>? isSensitiveDataEnabled = null)
    {
        _logger = logger;
        _activitySource = activitySource;
        _invokeFunction = invokeFunction;
        _isSensitiveDataEnabled = isSensitiveDataEnabled ?? (_ => false);
    }
 
    /// <summary>
    /// Processes multiple function calls, either concurrently or serially.
    /// </summary>
    /// <param name="functionCallContents">The function calls to process.</param>
    /// <param name="findTool">Delegate to look up a tool by name. Returns null if not found.</param>
    /// <param name="allowConcurrentInvocation">Whether to allow concurrent invocation.</param>
    /// <param name="createContext">Delegate to create a <see cref="FunctionInvocationContext"/> for each function call.</param>
    /// <param name="setCurrentContext">Delegate to set the current context (for AsyncLocal flow).</param>
    /// <param name="captureExceptionsWhenSerial">Whether to capture exceptions when running serially (typically based on consecutive error count).</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A list of function invocation results.</returns>
    public async Task<List<FunctionInvocationResult>> ProcessFunctionCallsAsync(
        List<FunctionCallContent> functionCallContents,
        Func<string, AITool?> findTool,
        bool allowConcurrentInvocation,
        Func<FunctionCallContent, AIFunction, int, FunctionInvocationContext> createContext,
        Action<FunctionInvocationContext?> setCurrentContext,
        bool captureExceptionsWhenSerial,
        CancellationToken cancellationToken)
    {
        var results = new List<FunctionInvocationResult>();
 
        if (allowConcurrentInvocation && functionCallContents.Count > 1)
        {
            // Invoke functions concurrently - always capture exceptions in parallel mode
            results.AddRange(await Task.WhenAll(
                from callIndex in Enumerable.Range(0, functionCallContents.Count)
                select ProcessSingleFunctionCallAsync(
                    functionCallContents[callIndex], findTool, callIndex,
                    createContext, setCurrentContext, captureExceptions: true, cancellationToken)).ConfigureAwait(false));
        }
        else
        {
            // Invoke functions serially
            for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++)
            {
                var result = await ProcessSingleFunctionCallAsync(
                    functionCallContents[callIndex], findTool, callIndex,
                    createContext, setCurrentContext, captureExceptionsWhenSerial, cancellationToken).ConfigureAwait(false);
 
                results.Add(result);
 
                if (result.Terminate)
                {
                    break;
                }
            }
        }
 
        return results;
    }
 
    /// <summary>
    /// Processes a single function call.
    /// </summary>
    private async Task<FunctionInvocationResult> ProcessSingleFunctionCallAsync(
        FunctionCallContent callContent,
        Func<string, AITool?> findTool,
        int callIndex,
        Func<FunctionCallContent, AIFunction, int, FunctionInvocationContext> createContext,
        Action<FunctionInvocationContext?> setCurrentContext,
        bool captureExceptions,
        CancellationToken cancellationToken)
    {
        // Look up the AIFunction for the function call. If the requested function isn't available, send back an error.
        AITool? tool = findTool(callContent.Name);
        if (tool is null)
        {
            FunctionInvocationLogger.LogFunctionNotFound(_logger, callContent.Name);
            return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null);
        }
 
        if (tool is not AIFunction aiFunction)
        {
            FunctionInvocationLogger.LogNonInvocableFunction(_logger, callContent.Name);
            return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null);
        }
 
        var context = createContext(callContent, aiFunction, callIndex);
 
        try
        {
            setCurrentContext(context);
            var result = await InstrumentedInvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false);
            if (context.Terminate)
            {
                FunctionInvocationLogger.LogFunctionRequestedTermination(_logger, callContent.Name);
            }
 
            return new(context.Terminate, FunctionInvocationStatus.RanToCompletion, callContent, result, exception: null);
        }
        catch (Exception ex) when (captureExceptions && !cancellationToken.IsCancellationRequested)
        {
            return new(terminate: false, FunctionInvocationStatus.Exception, callContent, result: null, exception: ex);
        }
        finally
        {
            setCurrentContext(null);
        }
    }
 
    /// <summary>
    /// Invokes the function with instrumentation (logging and telemetry).
    /// </summary>
    private async Task<object?> InstrumentedInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken)
    {
        Activity? invokeAgentActivity = FunctionInvocationHelpers.CurrentActivityIsInvokeAgent ? Activity.Current : null;
        ActivitySource? source = invokeAgentActivity?.Source ?? _activitySource;
 
        using Activity? activity = source?.StartActivity(
            $"{OpenTelemetryConsts.GenAI.ExecuteToolName} {context.Function.Name}",
            ActivityKind.Internal,
            default(ActivityContext),
            [
                new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ExecuteToolName),
                new(OpenTelemetryConsts.GenAI.Tool.Type, OpenTelemetryConsts.ToolTypeFunction),
                new(OpenTelemetryConsts.GenAI.Tool.Call.Id, context.CallContent.CallId),
                new(OpenTelemetryConsts.GenAI.Tool.Name, context.Function.Name),
                new(OpenTelemetryConsts.GenAI.Tool.Description, context.Function.Description),
            ]);
 
        long startingTimestamp = Stopwatch.GetTimestamp();
 
        // Determine if sensitive data logging is enabled via the delegate
        bool enableSensitiveData = activity is { IsAllDataRequested: true } && _isSensitiveDataEnabled(invokeAgentActivity);
 
        bool traceLoggingEnabled = _logger.IsEnabled(LogLevel.Trace);
        bool loggedInvoke = false;
 
        if (enableSensitiveData || traceLoggingEnabled)
        {
            string functionArguments = TelemetryHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions);
 
            if (enableSensitiveData)
            {
                _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Arguments, functionArguments);
            }
 
            if (traceLoggingEnabled)
            {
                FunctionInvocationLogger.LogInvokingSensitive(_logger, context.Function.Name, functionArguments);
                loggedInvoke = true;
            }
        }
 
        if (!loggedInvoke && _logger.IsEnabled(LogLevel.Debug))
        {
            FunctionInvocationLogger.LogInvoking(_logger, context.Function.Name);
        }
 
        object? result = null;
        try
        {
            result = await _invokeFunction(context, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception e)
        {
            if (activity is not null)
            {
                _ = activity.SetTag(OpenTelemetryConsts.Error.Type, e.GetType().FullName)
                            .SetStatus(ActivityStatusCode.Error, e.Message);
            }
 
            if (e is OperationCanceledException)
            {
                FunctionInvocationLogger.LogInvocationCanceled(_logger, context.Function.Name);
            }
            else
            {
                FunctionInvocationLogger.LogInvocationFailed(_logger, context.Function.Name, e);
            }
 
            throw;
        }
        finally
        {
            bool loggedResult = false;
            if (enableSensitiveData || traceLoggingEnabled)
            {
                string functionResult = TelemetryHelpers.AsJson(result, context.Function.JsonSerializerOptions);
 
                if (enableSensitiveData)
                {
                    _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Result, functionResult);
                }
 
                if (traceLoggingEnabled)
                {
                    FunctionInvocationLogger.LogInvocationCompletedSensitive(_logger, context.Function.Name, FunctionInvocationHelpers.GetElapsedTime(startingTimestamp), functionResult);
                    loggedResult = true;
                }
            }
 
            if (!loggedResult && _logger.IsEnabled(LogLevel.Debug))
            {
                FunctionInvocationLogger.LogInvocationCompleted(_logger, context.Function.Name, FunctionInvocationHelpers.GetElapsedTime(startingTimestamp));
            }
        }
 
        return result;
    }
}