using MeetingAssistant.MeetingNotes; using MeetingAssistant.Transcription; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Extensions.Options; namespace MeetingAssistant.Summary; #pragma warning disable MAAI001 public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline { private readonly MeetingAssistantOptions options; private readonly ILoggerFactory loggerFactory; private readonly ILogger logger; private readonly IServiceProvider services; private readonly IMeetingSummaryFailureWriter failureWriter; private readonly IMeetingSummaryInstructionBuilder instructionBuilder; private readonly IDictationWordStore dictationWordStore; public OpenAiMeetingSummaryAgentPipeline( IOptions options, ILoggerFactory loggerFactory, ILogger logger, IServiceProvider services, IMeetingSummaryFailureWriter failureWriter, IMeetingSummaryInstructionBuilder instructionBuilder, IDictationWordStore dictationWordStore) { this.options = options.Value; this.loggerFactory = loggerFactory; this.logger = logger; this.services = services; this.failureWriter = failureWriter; this.instructionBuilder = instructionBuilder; this.dictationWordStore = dictationWordStore; } public async Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { return await RunAsync(artifacts, options, cancellationToken); } public async Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { var agentOptions = options.Agent; var key = ResolveApiKey(agentOptions); var writeAudit = new SummaryAgentWriteAudit(artifacts); var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit); var tools = CreateTools(meetingTools); var instructions = await instructionBuilder.BuildAsync(artifacts, 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: false); var agent = chatClient .AsBuilder() .UseFunctionInvocation() .Build() .AsAIAgent( instructions, "meeting_summary", "Writes the markdown summary note for a captured meeting.", tools, loggerFactory: loggerFactory, services: services); try { var response = await agent.RunAsync( "Write the summary for this meeting. Use the tools; do not invent missing information.", session: null, options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)), cancellationToken: cancellationToken); if (!meetingTools.SummaryWasWritten) { throw CreateMissingSummaryWriteException(response); } await writeAudit.AppendToAssistantContextAsync(cancellationToken); return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { logger.LogError( exception, "Meeting summary generation failed for {SummaryPath}; writing failure summary", artifacts.SummaryPath); var result = await failureWriter.WriteAsync(artifacts, exception, options, cancellationToken); await writeAudit.AppendToAssistantContextAsync(cancellationToken); return result; } } private static List CreateTools(MeetingSummaryTools tools) { return [ AIFunctionFactory.Create( tools.ReadMeetingNote, "read_meetingnote", "Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), AIFunctionFactory.Create( tools.ReadTranscript, "read_transcript", "Read the transcript markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), AIFunctionFactory.Create( tools.ReadContext, "read_context", "Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), AIFunctionFactory.Create( tools.ReadUserNotes, "read_usernotes", "Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. With from and to, read that clamped inclusive 1-based body line range."), AIFunctionFactory.Create( tools.WriteContext, "write_context", "Write internal assistant notes to the assistant context body. With no line arguments, append to the body. Set replace_file=true only when intentionally replacing the whole body. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."), AIFunctionFactory.Create( tools.ReadGlossary, "read_glossary", "Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."), AIFunctionFactory.Create( tools.AddDictationWord, "add_dictation_word", "Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."), AIFunctionFactory.Create( tools.AddAttendee, "add_attendee", "Add one attendee to the current meeting note frontmatter when transcript or OCR evidence clearly shows they attended."), AIFunctionFactory.Create( tools.RemoveAttendee, "remove_attendee", "Remove one attendee from the current meeting note frontmatter when evidence clearly shows they did not attend. Do not remove attendees solely because a screenshot contains only partial participant evidence."), AIFunctionFactory.Create( tools.OverrideSpeaker, "override_speaker", "Replace a transcript speaker label with a named speaker only when the evidence is very certain. If the replacement speaker already exists, set merge=true only when both labels are certainly the same identity."), AIFunctionFactory.Create( tools.DeleteIdentity, "delete_identity", "Remove a wrongfully matched speaker identity and relabel transcript mentions to Removed-n only when the evidence is certain."), AIFunctionFactory.Create( tools.WriteSummary, "write_summary", "Write the finished summary markdown to the configured summary note. Provide title when the meeting note has no title."), AIFunctionFactory.Create( tools.ListProjects, "list_projects", "List the configured project folders bound to the current meeting note frontmatter."), AIFunctionFactory.Create( tools.ListProjectFiles, "list_projectfiles", "List markdown and other files in a bound project. The project parameter is a project folder name."), AIFunctionFactory.Create( tools.ReadProjectFile, "read_projectfile", "Read a bound project file, optionally clamping to from and to inclusive line numbers."), AIFunctionFactory.Create( tools.WriteProjectFile, "write_projectfile", "Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."), AIFunctionFactory.Create( tools.Search, "search", "Run ripgrep syntax keywords against the requested bound projects, or all projects bound to the meeting note.") ]; } private static InvalidOperationException CreateMissingSummaryWriteException(AgentResponse response) { var message = string.IsNullOrWhiteSpace(response.Text) ? "Summary agent completed without calling write_summary." : $"Summary agent completed without calling write_summary. Response: {response.Text}"; return new InvalidOperationException(message); } private static ChatOptions CreateChatOptions(AgentOptions options) { var chatOptions = new ChatOptions { ModelId = options.Model, MaxOutputTokens = options.MaxOutputTokens, AllowMultipleToolCalls = true, ToolMode = ChatToolMode.Auto }; if (options.EnableThinking) { chatOptions.Reasoning = new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) }; } return chatOptions; } 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 = CreateFallbackCompactionStrategy( options.ContextWindowTokens, options.MaxOutputTokens, options.CompactionRemainingRatio, summaryClient) }; } internal static CompactionStrategy CreateFallbackCompactionStrategyForTests( int contextWindowTokens, int maxOutputTokens, double remainingRatio, IChatClient? summaryClient) { return CreateFallbackCompactionStrategy( contextWindowTokens, maxOutputTokens, remainingRatio, summaryClient); } private static CompactionStrategy CreateFallbackCompactionStrategy( int contextWindowTokens, int maxOutputTokens, double remainingRatio, IChatClient? summaryClient) { var contextWindow = Math.Max(1, contextWindowTokens); var outputReserve = Math.Clamp(maxOutputTokens, 0, contextWindow - 1); var inputBudget = contextWindow - outputReserve; var triggerTokens = Math.Max(1, (int)Math.Floor(inputBudget * (1 - Math.Clamp(remainingRatio, 0.01, 0.90)))); var target = CompactionTriggers.TokensBelow(Math.Max(1, triggerTokens - 1)); var trigger = CompactionTriggers.TokensExceed(triggerTokens); var strategies = new List { new ToolResultCompactionStrategy(trigger, minimumPreservedGroups: 4, target) }; if (summaryClient is not null) { strategies.Add(new SummarizationCompactionStrategy( summaryClient, trigger, minimumPreservedGroups: 4, target: target)); } strategies.Add(new SlidingWindowCompactionStrategy( trigger, minimumPreservedTurns: 4, target)); strategies.Add(new TruncationCompactionStrategy( trigger, minimumPreservedGroups: 1, target)); return new PipelineCompactionStrategy(strategies); } 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 }; } private static string ResolveApiKey(AgentOptions options) { if (!string.IsNullOrWhiteSpace(options.Key)) { return options.Key; } var value = Environment.GetEnvironmentVariable(options.KeyEnv); if (!string.IsNullOrWhiteSpace(value)) { return value; } throw new InvalidOperationException( $"No agent API key configured. Set MeetingAssistant:Agent:Key or environment variable '{options.KeyEnv}'."); } } #pragma warning restore MAAI001