< Summary

Information
Class: Elsa.AI.Host.Services.AIOrchestrator
Assembly: Elsa.AI.Host
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.AI.Host/Services/AIOrchestrator.cs
Line coverage
97%
Covered lines: 477
Uncovered lines: 12
Coverable lines: 489
Total lines: 761
Line coverage: 97.5%
Branch coverage
88%
Covered branches: 210
Total branches: 238
Branch coverage: 88.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.AI.Host/Services/AIOrchestrator.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3using Elsa.AI.Abstractions.Contracts;
 4using Elsa.AI.Abstractions.Models;
 5using Elsa.AI.Host.Context;
 6using Elsa.AI.Host.Options;
 7using Elsa.AI.Host.Streaming;
 8using Microsoft.Extensions.Logging;
 9using Microsoft.Extensions.Options;
 10
 11namespace Elsa.AI.Host.Services;
 12
 4213public class AIOrchestrator(
 4214    IEnumerable<IAIProvider> providers,
 4215    IAIToolRegistry toolRegistry,
 4216    IAIConversationStore conversationStore,
 4217    AIContextResolver contextResolver,
 4218    AIStreamEventMapper streamEventMapper,
 4219    IAIAuditSink auditSink,
 4220    ILogger<AIOrchestrator> logger,
 4221    IOptions<AIHostOptions> options) : IAIOrchestrator
 22{
 23    private const int MaxProviderTurns = 8;
 24
 25    public async IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, [System.Runtime.CompilerService
 26    {
 4327        var conversationId = request.ConversationId ?? Guid.NewGuid().ToString("N");
 4328        var sequence = 0L;
 4329        var providerSelection = SelectProvider(request);
 4330        var provider = providerSelection.Provider;
 4331        var conversationPersistenceEnabled = options.Value.ConversationPersistenceEnabled;
 4332        AIConversation? conversation = null;
 4333        Exception? preparationError = null;
 4334        if (conversationPersistenceEnabled)
 35        {
 36            try
 37            {
 4238                conversation = await conversationStore.FindAsync(conversationId, cancellationToken);
 4139            }
 140            catch (Exception e) when (e is not OperationCanceledException)
 41            {
 142                preparationError = e;
 143            }
 44        }
 45
 4346        if (conversation != null && (!BelongsToTenant(conversation, request.TenantId) || !BelongsToUser(conversation, re
 47        {
 248            conversation = null;
 249            conversationId = Guid.NewGuid().ToString("N");
 50        }
 4351        var messages = conversation?.Messages.ToList() ?? [];
 4352        if (request.IsReconnect && messages.Count == 0)
 153            conversationId = Guid.NewGuid().ToString("N");
 54
 4355        if (request.IsReconnect && IsCompletedReconnect(conversation, request.Message))
 56        {
 257            var nextSequence = GetNextSequence(messages);
 258            if (conversation!.Status == AIConversationStatus.Failed)
 59            {
 60                var lastAssistantContent = conversation.Messages.LastOrDefault(x => x.Role == AIMessageRole.Assistant)?.
 161                if (!string.IsNullOrEmpty(lastAssistantContent))
 162                    yield return CreateEvent("conversation.error", conversationId, nextSequence++, new JsonObject
 163                    {
 164                        ["content"] = lastAssistantContent
 165                    });
 66            }
 67
 268            yield return CreateEvent("conversation.completed", conversationId, nextSequence);
 269            yield break;
 70        }
 71
 4172        var providerSessionId = conversation?.ProviderSessionId;
 73
 4174        if (preparationError == null && provider != null && string.IsNullOrWhiteSpace(providerSessionId))
 75        {
 76            try
 77            {
 2578                var session = await provider.CreateSessionAsync(new CreateAISessionRequest
 2579                {
 2580                    ConversationId = conversationId,
 2581                    Agent = request.Agent,
 2582                    TenantId = request.TenantId,
 2583                    ProviderConfiguration = providerSelection.Configuration
 2584                }, cancellationToken);
 2485                providerSessionId = session.ProviderSessionId ?? session.Id;
 2486            }
 187            catch (Exception e) when (e is not OperationCanceledException)
 88            {
 189                preparationError = e;
 190            }
 91        }
 92
 4193        var isDuplicateReconnectMessage = request.IsReconnect && HasReconnectUserMessage(conversation, request.Message);
 4194        var providerHistory = messages.ToList();
 4195        if (request.IsReconnect && messages.Count > 0)
 396            sequence = GetNextSequence(messages);
 97
 4198        yield return CreateEvent("conversation.started", conversationId, sequence++);
 99
 41100        var userMessage = isDuplicateReconnectMessage
 5101            ? messages.Last(x => x.Role == AIMessageRole.User && string.Equals(NormalizeMessage(x.Content), NormalizeMes
 41102            : CreateMessage(conversationId, AIMessageRole.User, request.Message, sequence++);
 103
 41104        if (!isDuplicateReconnectMessage)
 38105            messages.Add(userMessage);
 106
 107        var knownToolCallIds = RestoreToolResults(messages).Select(x => x.ToolCallId).ToHashSet(StringComparer.OrdinalIg
 41108        var pendingToolResults = isDuplicateReconnectMessage ? RestorePendingToolResults(messages) : new List<AIToolTurn
 109
 41110        await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Active, messages, conversation, pro
 41111        await RecordChatAuditAsync("chat.started", request, conversationId, provider?.Name, cancellationToken);
 112
 41113        IReadOnlyCollection<AIResolvedContext> context = [];
 41114        IReadOnlyCollection<AIToolDefinition> tools = [];
 115
 41116        if (preparationError == null)
 117        {
 118            try
 119            {
 39120                context = LimitResolvedContext(await contextResolver.ResolveAsync(request, cancellationToken));
 38121                tools = await toolRegistry.ListAsync(new AIToolQuery
 38122                {
 38123                    Agent = request.Agent,
 38124                    ActorId = request.UserId,
 38125                    TenantId = request.TenantId,
 38126                    UserPermissions = request.UserPermissions
 38127                }, cancellationToken);
 38128            }
 1129            catch (Exception e) when (e is not OperationCanceledException)
 130            {
 1131                preparationError = e;
 1132            }
 133        }
 134
 41135        if (preparationError != null)
 136        {
 137            const string content = "Weaver could not prepare AI context or tools for this request.";
 3138            logger.LogWarning(preparationError, "Failed to prepare AI chat context or tools for conversation {Conversati
 3139            yield return CreateEvent("conversation.error", conversationId, sequence++, new JsonObject
 3140            {
 3141                ["content"] = content
 3142            });
 3143            messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
 3144            await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Failed, messages, conversation,
 3145            await RecordChatAuditAsync("chat.failed", request, conversationId, provider?.Name, cancellationToken);
 3146            yield return CreateEvent("conversation.completed", conversationId, sequence);
 3147            yield break;
 148        }
 149
 38150        if (provider == null)
 151        {
 152            const string content = "Weaver is ready, but no AI provider is configured.";
 11153            yield return CreateEvent("assistant.delta", conversationId, sequence++, new JsonObject
 11154            {
 11155                ["content"] = content
 11156            });
 11157            messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
 158        }
 159        else
 160        {
 27161            var assistantContent = new StringBuilder();
 162
 80163            for (var turn = 0; turn < MaxProviderTurns; turn++)
 164            {
 40165                var currentTurnToolResults = new List<AIToolTurnResult>();
 40166                var currentTurnMessages = new List<AIMessage>();
 40167                var currentTurnToolMessages = new List<AIMessage>();
 40168                assistantContent.Clear();
 169
 40170                var turnRequest = new AITurnRequest
 40171                {
 40172                    ConversationId = conversationId,
 40173                    ProviderSessionId = providerSessionId,
 40174                    Message = turn == 0 && !isDuplicateReconnectMessage ? request.Message : "",
 40175                    Messages = providerHistory.ToList(),
 40176                    Context = context,
 177                    Tools = tools.Where(x => x.IsEnabled).ToList(),
 40178                    ToolResults = GetUnrepresentedToolResults(pendingToolResults, providerHistory),
 40179                    Agent = request.Agent,
 40180                    ProviderConfiguration = providerSelection.Configuration
 40181                };
 40182                Exception? providerTurnError = null;
 161183                await foreach (var providerRead in ReadProviderEventsAsync(provider.ExecuteTurnAsync(turnRequest, cancel
 184                {
 41185                    if (providerRead.Error != null)
 186                    {
 1187                        providerTurnError = providerRead.Error;
 1188                        break;
 189                    }
 190
 40191                    var providerEvent = providerRead.Event!;
 40192                    var streamEvent = streamEventMapper.Map(conversationId, providerEvent) with { Sequence = sequence++ 
 40193                    yield return streamEvent;
 194
 40195                    if (TryReadAssistantContent(providerEvent, out var content))
 17196                        assistantContent.Append(content);
 197
 40198                    if (!TryReadToolCall(providerEvent, out var toolCall))
 199                        continue;
 200
 19201                    if (knownToolCallIds.Contains(toolCall.Id) ||
 19202                        currentTurnToolResults.Any(x => string.Equals(x.ToolCallId, toolCall.Id, StringComparison.Ordina
 203                        continue;
 204
 14205                    var toolExecution = await ExecuteToolCallAsync(toolCall, request, conversationId, sequence++, cancel
 14206                    yield return toolExecution.StreamEvent;
 207
 14208                    currentTurnToolResults.Add(toolExecution.TurnResult);
 14209                    var toolMessage = CreateMessage(conversationId, AIMessageRole.Tool, toolExecution.TurnResult.Result.
 14210                    {
 14211                        ["toolCallId"] = toolExecution.TurnResult.ToolCallId,
 14212                        ["toolName"] = toolExecution.TurnResult.ToolName,
 14213                        ["status"] = toolExecution.TurnResult.Result.Status.ToString()
 14214                    });
 14215                    currentTurnToolMessages.Add(toolMessage);
 14216                }
 217
 40218                if (providerTurnError != null)
 219                {
 220                    const string content = "Weaver could not complete the AI provider turn for this request.";
 1221                    logger.LogWarning(providerTurnError, "Failed to execute AI provider turn for conversation {Conversat
 1222                    yield return CreateEvent("conversation.error", conversationId, sequence++, new JsonObject
 1223                    {
 1224                        ["content"] = content
 1225                    });
 1226                    messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
 1227                    await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Failed, messages, conve
 1228                    await RecordChatAuditAsync("chat.failed", request, conversationId, provider.Name, cancellationToken)
 1229                    yield return CreateEvent("conversation.completed", conversationId, sequence);
 1230                    yield break;
 231                }
 232
 39233                if (assistantContent.Length > 0 || currentTurnToolMessages.Count > 0)
 234                {
 31235                    var assistantSequence = currentTurnToolMessages.Count > 0
 236                        ? currentTurnToolMessages.Min(x => x.StreamSequence) - 1
 31237                        : sequence - 1;
 31238                    var assistantMessage = CreateMessage(conversationId, AIMessageRole.Assistant, assistantContent.ToStr
 31239                    messages.Add(assistantMessage);
 31240                    currentTurnMessages.Add(assistantMessage);
 241                }
 242
 39243                messages.AddRange(currentTurnToolMessages);
 39244                currentTurnMessages.AddRange(currentTurnToolMessages);
 245
 39246                if (currentTurnToolResults.Count == 0)
 247                    break;
 248
 56249                foreach (var toolResult in currentTurnToolResults)
 14250                    knownToolCallIds.Add(toolResult.ToolCallId);
 251
 14252                pendingToolResults = currentTurnToolResults;
 21253                if (providerHistory.All(x => x.Id != userMessage.Id))
 7254                    providerHistory.Add(userMessage);
 255
 14256                providerHistory.AddRange(currentTurnMessages);
 14257                await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Active, messages, conversat
 258
 14259                if (turn == MaxProviderTurns - 1)
 260                {
 261                    const string content = "Tool execution stopped because the provider requested too many continuation 
 1262                    yield return CreateEvent("assistant.delta", conversationId, sequence++, new JsonObject
 1263                    {
 1264                        ["content"] = content
 1265                    });
 1266                    messages.Add(CreateMessage(conversationId, AIMessageRole.Assistant, content, sequence - 1));
 1267                    break;
 268                }
 13269            }
 26270        }
 271
 37272        await TrySaveConversationAsync(conversationId, request, AIConversationStatus.Completed, messages, conversation, 
 37273        await RecordChatAuditAsync("chat.completed", request, conversationId, provider?.Name, cancellationToken);
 37274        yield return CreateEvent("conversation.completed", conversationId, sequence);
 43275    }
 276
 277    private static AIStreamEvent CreateEvent(string type, string conversationId, long sequence, JsonObject? data = null)
 115278        new()
 115279        {
 115280            Type = type,
 115281            ConversationId = conversationId,
 115282            Sequence = sequence,
 115283            Timestamp = DateTimeOffset.UtcNow,
 115284            Data = data ?? []
 115285        };
 286
 287    private static async IAsyncEnumerable<ProviderReadResult> ReadProviderEventsAsync(
 288        IAsyncEnumerable<AIProviderEvent> providerEvents,
 289        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
 290    {
 40291        var enumerator = providerEvents.GetAsyncEnumerator(cancellationToken);
 292        try
 293        {
 40294            while (true)
 295            {
 80296                AIProviderEvent? providerEvent = null;
 80297                Exception? error = null;
 80298                var hasEvent = false;
 299
 300                try
 301                {
 80302                    hasEvent = await enumerator.MoveNextAsync();
 79303                    if (hasEvent)
 40304                        providerEvent = enumerator.Current;
 79305                }
 1306                catch (Exception e) when (e is not OperationCanceledException)
 307                {
 1308                    error = e;
 1309                }
 310
 80311                if (error != null)
 312                {
 1313                    yield return new ProviderReadResult(null, error);
 0314                    yield break;
 315                }
 316
 79317                if (!hasEvent)
 39318                    yield break;
 319
 40320                yield return new ProviderReadResult(providerEvent, null);
 40321            }
 322        }
 323        finally
 324        {
 40325            await enumerator.DisposeAsync();
 326        }
 40327    }
 328
 329    private ProviderSelection SelectProvider(AIChatRequest request)
 330    {
 43331        var providerOptions = options.Value.Providers.ToList();
 45332        var configuredProviders = providerOptions.Where(x => x.Enabled).ToList();
 43333        var availableProviders = providers
 32334            .Where(x => providerOptions.IsProviderEnabled(x.Name))
 43335            .ToList();
 43336        var providerName = request.ProviderName ?? FindAgentProviderName(request.Agent) ?? options.Value.DefaultProvider
 337
 43338        if (!string.IsNullOrWhiteSpace(providerName))
 339        {
 5340            var configuredProvider = configuredProviders.FirstOrDefault(x => string.Equals(x.Name, providerName, StringC
 4341            var provider = configuredProvider != null
 1342                ? availableProviders.FirstOrDefault(x => string.Equals(x.Name, configuredProvider.Name, StringComparison
 1343                  availableProviders.FirstOrDefault(x => string.Equals(x.Name, configuredProvider.Provider, StringCompar
 8344                : availableProviders.FirstOrDefault(x => string.Equals(x.Name, providerName, StringComparison.OrdinalIgn
 345
 4346            return new ProviderSelection(provider, configuredProvider?.ToProviderConfiguration());
 347        }
 348
 39349        if (availableProviders.Count != 1)
 350        {
 13351            if (availableProviders.Count > 1)
 0352                logger.LogWarning(
 0353                    "Multiple AI providers are available ({ProviderNames}) but no default provider name is configured. S
 0354                    string.Join(", ", availableProviders.Select(x => x.Name)));
 355
 13356            return new ProviderSelection(null, null);
 357        }
 358
 26359        var selectedProvider = availableProviders[0];
 26360        var selectedConfiguration = configuredProviders.FirstOrDefault(x => string.Equals(x.Name, selectedProvider.Name,
 26361                                                                            string.Equals(x.Provider, selectedProvider.N
 362
 26363        return new ProviderSelection(selectedProvider, selectedConfiguration?.ToProviderConfiguration());
 364    }
 365
 366    private string? FindAgentProviderName(string? agent) =>
 40367        string.IsNullOrWhiteSpace(agent)
 40368            ? null
 41369            : options.Value.Agents.FirstOrDefault(x => string.Equals(x.Name, agent, StringComparison.OrdinalIgnoreCase))
 370
 371    private async ValueTask RecordChatAuditAsync(string type, AIChatRequest request, string conversationId, string? prov
 372    {
 373        try
 374        {
 82375            await auditSink.RecordAsync(new AIAuditEvent
 82376            {
 82377                Type = type,
 82378                TenantId = request.TenantId,
 82379                ActorId = request.UserId,
 82380                ConversationId = conversationId,
 82381                Timestamp = DateTimeOffset.UtcNow,
 82382                Summary = type switch
 82383                {
 41384                    "chat.started" => "Chat started",
 4385                    "chat.failed" => "Chat failed",
 37386                    _ => "Chat completed"
 82387                },
 82388                Data = new JsonObject
 82389                {
 82390                    ["agent"] = request.Agent,
 82391                    ["provider"] = providerName,
 82392                    ["attachmentCount"] = request.Attachments.Count
 82393                }
 82394            }, cancellationToken);
 80395        }
 2396        catch (Exception e) when (e is not OperationCanceledException)
 397        {
 2398            logger.LogWarning(e, "Failed to record AI chat audit event {AuditEventType} for conversation {ConversationId
 2399        }
 82400    }
 401
 402    private async ValueTask<ToolExecutionResult> ExecuteToolCallAsync(ToolCall toolCall, AIChatRequest request, string c
 403    {
 14404        var tool = await toolRegistry.FindAsync(toolCall.Name, new AIToolQuery
 14405        {
 14406            Agent = request.Agent,
 14407            ActorId = request.UserId,
 14408            TenantId = request.TenantId,
 14409            UserPermissions = request.UserPermissions
 14410        }, cancellationToken);
 14411        if (tool == null)
 412        {
 1413            var result = new AIToolResult { Status = AIToolInvocationStatus.Failed, Error = $"Tool '{toolCall.Name}' was
 1414            await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.failed"], cancellationToken);
 1415            return CreateToolExecutionResult(conversationId, sequence, toolCall, result);
 416        }
 417
 13418        using var toolScope = tool;
 419        try
 420        {
 13421            await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.invoked"], cancellationToken);
 13422            var result = await tool.ExecuteAsync(new AIToolExecutionContext
 13423            {
 13424                ConversationId = conversationId,
 13425                TenantId = request.TenantId,
 13426                ActorId = request.UserId,
 13427                Agent = request.Agent,
 13428                Arguments = toolCall.Arguments
 13429            }, cancellationToken);
 12430            await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.completed"], cancellationToken);
 431
 12432            return CreateToolExecutionResult(conversationId, sequence, toolCall, LimitToolResult(result));
 433        }
 1434        catch (Exception e) when (e is not OperationCanceledException)
 435        {
 1436            logger.LogWarning(e, "AI tool {ToolName} failed for conversation {ConversationId}.", toolCall.Name, conversa
 1437            await RecordToolAuditEventsAsync(request, conversationId, toolCall, ["tool.failed"], cancellationToken);
 1438            return CreateToolExecutionResult(conversationId, sequence, toolCall, new AIToolResult { Status = AIToolInvoc
 439        }
 14440    }
 441
 442    private async ValueTask RecordToolAuditEventsAsync(AIChatRequest request, string conversationId, ToolCall toolCall, 
 443    {
 444        try
 445        {
 54446            await auditSink.RecordManyAsync(types.Select(type => CreateToolAuditEvent(type, request, conversationId, too
 27447        }
 0448        catch (Exception e) when (e is not OperationCanceledException)
 449        {
 0450            logger.LogWarning(e, "Failed to record AI tool audit events for tool {ToolName}.", toolCall.Name);
 0451        }
 27452    }
 453
 454    private static AIAuditEvent CreateToolAuditEvent(string type, AIChatRequest request, string conversationId, ToolCall
 27455        new()
 27456        {
 27457            Type = type,
 27458            TenantId = request.TenantId,
 27459            ActorId = request.UserId,
 27460            ConversationId = conversationId,
 27461            ToolInvocationId = toolCall.Id,
 27462            Timestamp = DateTimeOffset.UtcNow,
 27463            Summary = $"{toolCall.Name} {type}",
 27464            Data = new JsonObject
 27465            {
 27466                ["toolName"] = toolCall.Name
 27467            }
 27468        };
 469
 470    private static AIStreamEvent CreateToolResultEvent(string conversationId, long sequence, ToolCall toolCall, AIToolRe
 14471        CreateEvent("tool.result", conversationId, sequence, new JsonObject
 14472        {
 14473            ["toolCallId"] = toolCall.Id,
 14474            ["toolName"] = toolCall.Name,
 14475            ["status"] = result.Status.ToString(),
 14476            ["summary"] = result.Summary,
 14477            ["error"] = result.Error,
 14478            ["data"] = result.Data.DeepClone()
 14479        });
 480
 481    private static ToolExecutionResult CreateToolExecutionResult(string conversationId, long sequence, ToolCall toolCall
 14482        new(CreateToolResultEvent(conversationId, sequence, toolCall, result), new AIToolTurnResult
 14483        {
 14484            ToolCallId = toolCall.Id,
 14485            ToolName = toolCall.Name,
 14486            Result = result
 14487        });
 488
 489    private IReadOnlyCollection<AIResolvedContext> LimitResolvedContext(IReadOnlyCollection<AIResolvedContext> contexts)
 490    {
 38491        var maxBytes = options.Value.MaxResolvedContextBytes;
 38492        if (maxBytes <= 0)
 1493            return contexts;
 494
 37495        var limited = new List<AIResolvedContext>();
 37496        var usedBytes = 0;
 497
 84498        foreach (var context in contexts)
 499        {
 7500            var contextSize = GetUtf8Size(context);
 7501            if (usedBytes + contextSize <= maxBytes)
 502            {
 2503                usedBytes += contextSize;
 2504                limited.Add(context);
 2505                continue;
 506            }
 507
 5508            if (limited.Count > 0)
 509            {
 1510                logger.LogDebug(
 1511                    "Dropping AI resolved context {ContextKind}/{ReferenceId} because resolved context exceeds the confi
 1512                    context.Kind,
 1513                    context.ReferenceId,
 1514                    maxBytes);
 1515                continue;
 516            }
 517
 4518            limited.Add(TruncateContext(context, maxBytes));
 4519            break;
 520        }
 521
 37522        return limited;
 523    }
 524
 525    private static AIResolvedContext TruncateContext(AIResolvedContext context, int maxBytes) =>
 4526        context with
 4527        {
 4528            Summary = Truncate(context.Summary, maxBytes),
 4529            Data = CreateTruncatedPayload(maxBytes),
 4530            Metadata = CreateTruncatedPayload(maxBytes)
 4531        };
 532
 533    private AIToolResult LimitToolResult(AIToolResult result)
 534    {
 12535        if (GetUtf8Size(result) <= options.Value.MaxToolResultBytes)
 11536            return result;
 537
 1538        return result with
 1539        {
 1540            Summary = Truncate(result.Summary, options.Value.MaxToolResultBytes),
 1541            Data = CreateTruncatedPayload(options.Value.MaxToolResultBytes)
 1542        };
 543    }
 544
 545    private static JsonObject CreateTruncatedPayload(int maxBytes) =>
 9546        new()
 9547        {
 9548            ["truncated"] = true,
 9549            ["maxBytes"] = maxBytes
 9550        };
 551
 552    private static int GetUtf8Size<T>(T value) =>
 19553        JsonSerializer.SerializeToUtf8Bytes(value).Length;
 554
 555    private static string Truncate(string value, int maxBytes)
 556    {
 5557        if (string.IsNullOrEmpty(value))
 0558            return value;
 559
 5560        if (maxBytes <= 0)
 0561            return "";
 562
 5563        if (Encoding.UTF8.GetByteCount(value) <= maxBytes)
 0564            return value;
 565
 5566        var low = 0;
 5567        var high = Math.Min(value.Length, maxBytes);
 34568        while (low < high)
 569        {
 29570            var candidate = (low + high + 1) / 2;
 29571            if (Encoding.UTF8.GetByteCount(value.AsSpan(0, candidate)) <= maxBytes)
 25572                low = candidate;
 573            else
 4574                high = candidate - 1;
 575        }
 576
 5577        if (low > 0 && char.IsHighSurrogate(value[low - 1]))
 1578            low--;
 579
 5580        return value[..low];
 581    }
 582
 583    private static bool TryReadToolCall(AIProviderEvent providerEvent, out ToolCall toolCall)
 584    {
 40585        toolCall = default;
 40586        if (!string.Equals(providerEvent.Type, "tool.call", StringComparison.OrdinalIgnoreCase))
 21587            return false;
 588
 19589        var name = providerEvent.Data["toolName"]?.GetValue<string>() ?? providerEvent.Data["name"]?.GetValue<string>();
 19590        if (string.IsNullOrWhiteSpace(name))
 0591            return false;
 592
 19593        var id = providerEvent.Data["id"]?.GetValue<string>() ?? Guid.NewGuid().ToString("N");
 19594        var arguments = providerEvent.Data["arguments"]?.DeepClone() as JsonObject ?? [];
 19595        toolCall = new ToolCall(id, name, arguments);
 19596        return true;
 597    }
 598
 599    private static bool TryReadAssistantContent(AIProviderEvent providerEvent, out string content)
 600    {
 40601        content = "";
 40602        if (!string.Equals(providerEvent.Type, "assistant.delta", StringComparison.OrdinalIgnoreCase))
 19603            return false;
 604
 21605        content = providerEvent.Data["content"]?.GetValue<string>() ?? "";
 21606        return !string.IsNullOrEmpty(content);
 607    }
 608
 609    private static AIMessage CreateMessage(string conversationId, AIMessageRole role, string content, long streamSequenc
 99610        new()
 99611        {
 99612            Id = Guid.NewGuid().ToString("N"),
 99613            ConversationId = conversationId,
 99614            Role = role,
 99615            Content = content,
 99616            CreatedAt = DateTimeOffset.UtcNow,
 99617            StreamSequence = streamSequence,
 99618            Metadata = metadata ?? []
 99619        };
 620
 621    private static JsonObject? CreateAssistantToolCallMetadata(IReadOnlyCollection<AIToolTurnResult> toolResults)
 622    {
 31623        if (toolResults.Count == 0)
 17624            return null;
 625
 14626        var toolCallIds = new JsonArray();
 56627        foreach (var toolResult in toolResults)
 14628            toolCallIds.Add(toolResult.ToolCallId);
 629
 14630        return new JsonObject
 14631        {
 14632            ["toolCallIds"] = toolCallIds
 14633        };
 634    }
 635
 636    private static bool HasReconnectUserMessage(AIConversation? conversation, string message)
 637    {
 4638        return conversation is { Status: AIConversationStatus.Active } &&
 4639               HasUserMessage(conversation, message);
 640    }
 641
 642    private static bool IsCompletedReconnect(AIConversation? conversation, string message) =>
 6643        conversation is { Status: AIConversationStatus.Completed or AIConversationStatus.Failed } && HasUserMessage(conv
 644
 645    private static bool HasUserMessage(AIConversation conversation, string message) =>
 10646        conversation.Messages.Any(x => x.Role == AIMessageRole.User && string.Equals(NormalizeMessage(x.Content), Normal
 647
 648    private static long GetNextSequence(IReadOnlyCollection<AIMessage> messages) =>
 14649        messages.Count == 0 ? 0 : messages.Max(x => x.StreamSequence) + 1;
 650
 651    private static List<AIToolTurnResult> RestoreToolResults(IEnumerable<AIMessage> messages)
 652    {
 41653        return messages
 47654            .Where(x => x.Role == AIMessageRole.Tool)
 41655            .Select(CreateToolTurnResult)
 41656            .OfType<AIToolTurnResult>()
 41657            .ToList();
 658    }
 659
 660    private static List<AIToolTurnResult> RestorePendingToolResults(IReadOnlyCollection<AIMessage> messages)
 661    {
 3662        return messages
 3663            .Reverse()
 4664            .TakeWhile(x => x.Role == AIMessageRole.Tool)
 3665            .Reverse()
 3666            .Select(CreateToolTurnResult)
 3667            .OfType<AIToolTurnResult>()
 3668            .ToList();
 669    }
 670
 671    private static IReadOnlyCollection<AIToolTurnResult> GetUnrepresentedToolResults(IReadOnlyCollection<AIToolTurnResul
 672    {
 40673        if (toolResults.Count == 0)
 26674            return [];
 675
 14676        var representedToolCallIds = messages
 84677            .Where(x => x.Role == AIMessageRole.Tool)
 35678            .Select(x => x.Metadata["toolCallId"]?.GetValue<string>())
 35679            .Where(x => !string.IsNullOrWhiteSpace(x))
 14680            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 681
 14682        return toolResults
 14683            .Where(x => !representedToolCallIds.Contains(x.ToolCallId))
 14684            .ToList();
 685    }
 686
 687    private static AIToolTurnResult? CreateToolTurnResult(AIMessage message)
 688    {
 2689        var toolCallId = message.Metadata["toolCallId"]?.GetValue<string>();
 2690        var toolName = message.Metadata["toolName"]?.GetValue<string>();
 691
 2692        if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
 0693            return null;
 694
 2695        var status = Enum.TryParse<AIToolInvocationStatus>(message.Metadata["status"]?.GetValue<string>(), out var parse
 2696            ? parsedStatus
 2697            : AIToolInvocationStatus.Completed;
 698
 2699        return new AIToolTurnResult
 2700        {
 2701            ToolCallId = toolCallId,
 2702            ToolName = toolName,
 2703            Result = new AIToolResult
 2704            {
 2705                Status = status,
 2706                Summary = message.Content
 2707            }
 2708        };
 709    }
 710
 711    private static string NormalizeMessage(string message) =>
 16712        message.ReplaceLineEndings("\n").Trim();
 713
 714    private static bool BelongsToTenant(AIConversation conversation, string? tenantId) =>
 10715        string.Equals(NormalizeTenantId(conversation.TenantId), NormalizeTenantId(tenantId), StringComparison.Ordinal);
 716
 717    private static bool BelongsToUser(AIConversation conversation, string userId) =>
 9718        string.IsNullOrWhiteSpace(conversation.UserId) || string.Equals(conversation.UserId, userId, StringComparison.Or
 719
 720    private static string NormalizeTenantId(string? tenantId) =>
 20721        string.IsNullOrWhiteSpace(tenantId) ? "" : tenantId;
 722
 723    private async ValueTask TrySaveConversationAsync(string conversationId, AIChatRequest request, AIConversationStatus 
 724    {
 96725        if (!options.Value.ConversationPersistenceEnabled)
 2726            return;
 727
 728        try
 729        {
 94730            var now = DateTimeOffset.UtcNow;
 94731            var retentionMode = conversation?.RetentionMode ?? AIRetentionMode.Configured;
 94732            DateTimeOffset? retentionExpiresAt = retentionMode == AIRetentionMode.Configured
 94733                ? now.Add(options.Value.ConversationRetention)
 94734                : null;
 735
 94736            await conversationStore.SaveAsync(new AIConversation
 94737            {
 94738                Id = conversationId,
 94739                TenantId = request.TenantId,
 94740                UserId = request.UserId,
 94741                Title = conversation?.Title,
 94742                Status = status,
 94743                CreatedAt = conversation is null || conversation.CreatedAt == default ? now : conversation.CreatedAt,
 94744                UpdatedAt = now,
 94745                ProviderSessionId = providerSessionId ?? conversation?.ProviderSessionId,
 94746                RetentionMode = retentionMode,
 94747                RetentionExpiresAt = retentionExpiresAt,
 94748                Messages = messages.ToList()
 94749            }, cancellationToken);
 92750        }
 2751        catch (Exception e) when (e is not OperationCanceledException)
 752        {
 2753            logger.LogWarning(e, "Failed to persist AI conversation {ConversationId} with status {ConversationStatus}.",
 2754        }
 96755    }
 756
 185757    private readonly record struct ToolCall(string Id, string Name, JsonObject Arguments);
 98758    private readonly record struct ToolExecutionResult(AIStreamEvent StreamEvent, AIToolTurnResult TurnResult);
 82759    private readonly record struct ProviderReadResult(AIProviderEvent? Event, Exception? Error);
 108760    private readonly record struct ProviderSelection(IAIProvider? Provider, AIProviderConfiguration? Configuration);
 761}

Methods/Properties

.ctor(System.Collections.Generic.IEnumerable`1<Elsa.AI.Abstractions.Contracts.IAIProvider>,Elsa.AI.Abstractions.Contracts.IAIToolRegistry,Elsa.AI.Abstractions.Contracts.IAIConversationStore,Elsa.AI.Host.Context.AIContextResolver,Elsa.AI.Host.Streaming.AIStreamEventMapper,Elsa.AI.Abstractions.Contracts.IAIAuditSink,Microsoft.Extensions.Logging.ILogger`1<Elsa.AI.Host.Services.AIOrchestrator>,Microsoft.Extensions.Options.IOptions`1<Elsa.AI.Host.Options.AIHostOptions>)
ExecuteChatAsync()
CreateEvent(System.String,System.String,System.Int64,System.Text.Json.Nodes.JsonObject)
ReadProviderEventsAsync()
SelectProvider(Elsa.AI.Abstractions.Models.AIChatRequest)
FindAgentProviderName(System.String)
RecordChatAuditAsync()
ExecuteToolCallAsync()
RecordToolAuditEventsAsync()
CreateToolAuditEvent(System.String,Elsa.AI.Abstractions.Models.AIChatRequest,System.String,Elsa.AI.Host.Services.AIOrchestrator/ToolCall)
CreateToolResultEvent(System.String,System.Int64,Elsa.AI.Host.Services.AIOrchestrator/ToolCall,Elsa.AI.Abstractions.Models.AIToolResult)
CreateToolExecutionResult(System.String,System.Int64,Elsa.AI.Host.Services.AIOrchestrator/ToolCall,Elsa.AI.Abstractions.Models.AIToolResult)
LimitResolvedContext(System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIResolvedContext>)
TruncateContext(Elsa.AI.Abstractions.Models.AIResolvedContext,System.Int32)
LimitToolResult(Elsa.AI.Abstractions.Models.AIToolResult)
CreateTruncatedPayload(System.Int32)
GetUtf8Size(T)
Truncate(System.String,System.Int32)
TryReadToolCall(Elsa.AI.Abstractions.Models.AIProviderEvent,Elsa.AI.Host.Services.AIOrchestrator/ToolCall&)
TryReadAssistantContent(Elsa.AI.Abstractions.Models.AIProviderEvent,System.String&)
CreateMessage(System.String,Elsa.AI.Abstractions.Models.AIMessageRole,System.String,System.Int64,System.Text.Json.Nodes.JsonObject)
CreateAssistantToolCallMetadata(System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIToolTurnResult>)
HasReconnectUserMessage(Elsa.AI.Abstractions.Models.AIConversation,System.String)
IsCompletedReconnect(Elsa.AI.Abstractions.Models.AIConversation,System.String)
HasUserMessage(Elsa.AI.Abstractions.Models.AIConversation,System.String)
GetNextSequence(System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIMessage>)
RestoreToolResults(System.Collections.Generic.IEnumerable`1<Elsa.AI.Abstractions.Models.AIMessage>)
RestorePendingToolResults(System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIMessage>)
GetUnrepresentedToolResults(System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIToolTurnResult>,System.Collections.Generic.IReadOnlyCollection`1<Elsa.AI.Abstractions.Models.AIMessage>)
CreateToolTurnResult(Elsa.AI.Abstractions.Models.AIMessage)
NormalizeMessage(System.String)
BelongsToTenant(Elsa.AI.Abstractions.Models.AIConversation,System.String)
BelongsToUser(Elsa.AI.Abstractions.Models.AIConversation,System.String)
NormalizeTenantId(System.String)
TrySaveConversationAsync()
get_Id()
get_StreamEvent()
get_Event()
get_Provider()