using MeetingAssistant.Summary; using MeetingAssistant.Speakers; using Microsoft.Extensions.AI; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace MeetingAssistant.Workflow; #pragma warning disable MAAI001 public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPipeline { private readonly MeetingAssistantOptions options; private readonly ILoggerFactory loggerFactory; private readonly ILogger logger; private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder; private readonly IDbContextFactory speakerIdentityDbContextFactory; private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue; public WorkflowRulesEditorChatPipeline( IOptions options, ILoggerFactory loggerFactory, ILogger logger, IWorkflowRulesEditorInstructionBuilder instructionBuilder, IDbContextFactory speakerIdentityDbContextFactory, IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue) { this.options = options.Value; this.loggerFactory = loggerFactory; this.logger = logger; this.instructionBuilder = instructionBuilder; this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory; this.samplePlaybackQueue = samplePlaybackQueue; } public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userMessage)) { return new WorkflowRulesEditorChatResult("", conversation); } var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent); var key = ResolveApiKey(agentOptions, "workflow rules editor"); var tools = new WorkflowRulesEditorTools( options, speakerIdentityDbContextFactory, samplePlaybackQueue); var messages = conversation .Select(ToChatMessage) .Append(new ChatMessage(ChatRole.User, userMessage.Trim())) .ToList(); var instructions = await instructionBuilder.BuildAsync(options, cancellationToken); using var compactionSummaryClient = new LiteLlmResponsesChatClient( new Uri(agentOptions.Endpoint), key, agentOptions.Model, agentOptions.EnableThinking, ToReasoningEffortValue(agentOptions.ReasoningEffort), agentOptions.ReconnectionAttempts, agentOptions.ReconnectionDelay, compactionOptions: null, logger, firstRequestIsUser: false); var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient); using var chatClient = new LiteLlmResponsesChatClient( new Uri(agentOptions.Endpoint), key, agentOptions.Model, agentOptions.EnableThinking, ToReasoningEffortValue(agentOptions.ReasoningEffort), agentOptions.ReconnectionAttempts, agentOptions.ReconnectionDelay, compactionOptions, logger, firstRequestIsUser: true); var functionClient = chatClient .AsBuilder() .UseFunctionInvocation(loggerFactory) .Build(); var response = await functionClient.GetResponseAsync( messages, CreateChatOptions(agentOptions, tools, instructions), cancellationToken); var responseText = string.IsNullOrWhiteSpace(response.Text) ? "(No response text returned.)" : response.Text.Trim(); var nextConversation = conversation .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim())) .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText)) .ToList(); return new WorkflowRulesEditorChatResult(responseText, nextConversation); } private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message) { return new ChatMessage( message.Role == WorkflowRulesEditorChatRole.Agent ? ChatRole.Assistant : ChatRole.User, message.Content); } private static ChatOptions CreateChatOptions( AgentOptions options, WorkflowRulesEditorTools tools, string instructions) { return new ChatOptions { ModelId = options.Model, Instructions = instructions, MaxOutputTokens = options.MaxOutputTokens, AllowMultipleToolCalls = true, ToolMode = ChatToolMode.Auto, Tools = [ AIFunctionFactory.Create( tools.ReadRules, "read_rules", "Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."), AIFunctionFactory.Create( tools.WriteRules, "write_rules", "Append to the configured workflow rules YAML file by default after validating that the complete file parses as a workflow rules document. Set replace_file=true only when intentionally replacing the whole rules file."), AIFunctionFactory.Create( tools.Search, "search", "Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file and supports an optional file_pattern glob."), AIFunctionFactory.Create( tools.ReadConfig, "read_config", "Read the local appsettings JSON configuration file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."), AIFunctionFactory.Create( tools.WriteConfig, "write_config", "Replace the local appsettings JSON configuration file after validating that the supplied content is valid JSON. Configuration stores environment variable names, not secret values."), AIFunctionFactory.Create( tools.ReadConfigDocs, "read_config_docs", "Read Meeting Assistant configuration documentation from docs/meeting-assistant-configuration.md. Supports from/to or tail. Use this before explaining or changing unfamiliar configuration settings."), AIFunctionFactory.Create( tools.ReadLogs, "read_logs", "Read the application-owned log files from the temp log folder. Defaults to tailing the current log. Set logFile to meeting-assistant.log.1 through .4 for rotated files, or supply from/to for a clamped 1-based line range."), AIFunctionFactory.Create( tools.SearchLogs, "search_logs", "Search current and rotated application-owned log files with .NET regular expression syntax. Supports an optional file_pattern glob and returns filename:line text matches."), AIFunctionFactory.Create( tools.SearchSpec, "search_spec", "Search copied OpenSpec specification markdown files with .NET regular expression syntax. Use file_pattern to limit searched files by glob. Returns relative/path.md:line text matches scoped to openspec/specs."), AIFunctionFactory.Create( tools.ReadSpecFile, "read_spec_file", "Read one copied OpenSpec specification markdown file by path relative to openspec/specs. Supports from/to or tail."), AIFunctionFactory.Create( tools.CreateProject, "create_project", "Create a new project folder under the configured projects folder. Use seed=recommended for PROJECT.md, JOURNAL.md, DECISIONS.md, and AGENTS.md from Project-AGENTS.md; seed=agents_md with agents_md to write AGENTS.md directly; or seed=none for an empty folder."), AIFunctionFactory.Create( tools.ListProjects, "list_projects", "List all existing project folders under the configured projects folder."), AIFunctionFactory.Create( tools.ListProjectFiles, "list_projectfiles", "List files inside an existing project folder."), AIFunctionFactory.Create( tools.ReadProjectFile, "read_projectfile", "Read a file inside an existing project folder. With no line arguments, read the whole file. With from/to, read that clamped inclusive 1-based line range. With tail, read the last lines."), AIFunctionFactory.Create( tools.WriteProjectFile, "write_projectfile", "Create or update a file inside an existing project folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."), AIFunctionFactory.Create( tools.SearchProjects, "search_projects", "Search files in all existing projects with .NET regular expression syntax. Use file_pattern to limit searched files by glob, for example *.md or **/*.md."), AIFunctionFactory.Create( tools.SearchIdentities, "search_identities", "Search or list speaker identities. Optional query matches canonical names, aliases, and candidate names. Returns JSON summaries."), AIFunctionFactory.Create( tools.ReadIdentity, "read_identity", "Read one speaker identity by numeric identity id, including aliases, candidate names, references, and sample metadata."), AIFunctionFactory.Create( tools.CreateIdentity, "create_identity", "Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."), AIFunctionFactory.Create( tools.UpdateIdentity, "update_identity", "Replace a speaker identity's canonicalName, aliases, and candidateNames by numeric identity id. Omitted aliases/candidateNames become empty."), AIFunctionFactory.Create( tools.DeleteIdentity, "delete_identity", "Delete a speaker identity and all linked aliases, candidate names, references, and samples by numeric identity id."), AIFunctionFactory.Create( tools.MergeIdentities, "merge_identities", "Merge sourceIdentityId into targetIdentityId, preserving target canonical name, adding source names as aliases, moving references and samples, then deleting the source."), AIFunctionFactory.Create( tools.ListIdentitySamples, "list_identity_samples", "List audio samples for a speaker identity by numeric identity id. Returns sample ids and metadata, not audio bytes."), AIFunctionFactory.Create( tools.ReadIdentitySample, "read_identity_sample", "Read one audio sample by numeric sample id. Returns metadata and base64 WAV bytes."), AIFunctionFactory.Create( tools.DeleteIdentitySample, "delete_identity_sample", "Delete one audio sample by numeric sample id."), AIFunctionFactory.Create( tools.QueuePlayIdentitySample, "queue_play_identity_sample", "Queue one audio sample by numeric sample id for local playback. This is asynchronous and does not block until playback finishes.") ], Reasoning = options.EnableThinking ? new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) } : null }; } private static LiteLlmResponsesCompactionOptions CreateCompactionOptions( AgentOptions options, IChatClient? summaryClient) { return new LiteLlmResponsesCompactionOptions { Enabled = options.EnableCompaction, ContextWindowTokens = options.ContextWindowTokens, MaxOutputTokens = options.MaxOutputTokens, RemainingRatio = options.CompactionRemainingRatio, CompactPath = options.ResponsesCompactPath, FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests( options.ContextWindowTokens, options.MaxOutputTokens, options.CompactionRemainingRatio, summaryClient) }; } private static string ResolveApiKey(AgentOptions options, string agentName) { if (!string.IsNullOrWhiteSpace(options.Key)) { return options.Key; } var value = Environment.GetEnvironmentVariable(options.KeyEnv); if (!string.IsNullOrWhiteSpace(value)) { return value; } throw new InvalidOperationException( $"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'."); } private static string ToReasoningEffortValue(ReasoningEffortOption effort) { return effort switch { ReasoningEffortOption.None => "none", ReasoningEffortOption.Low => "low", ReasoningEffortOption.High => "high", ReasoningEffortOption.ExtraHigh => "xhigh", _ => "medium" }; } private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort) { return effort switch { ReasoningEffortOption.None => ReasoningEffort.None, ReasoningEffortOption.Low => ReasoningEffort.Low, ReasoningEffortOption.High => ReasoningEffort.High, ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh, _ => ReasoningEffort.Medium }; } } #pragma warning restore MAAI001