using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.BeyondFactsLab; public sealed partial class BflResearchRunner { /// /// How many agents of the research on people work at once, over all people and preparations, so that a long list /// stays within the rate limits of the provider. /// public const int MAX_PERSON_AGENTS_AT_ONCE = 3; /// /// Lets no more than MAX_PERSON_AGENTS_AT_ONCE agents of the research on people work at once. /// private readonly SemaphoreSlim personAgents = new(MAX_PERSON_AGENTS_AT_ONCE, MAX_PERSON_AGENTS_AT_ONCE); /// /// The research on people which runs right now, each with the stage its agent works on, or null while it waits for /// an agent. Guarded by the lock of the research on the topics. /// private readonly Dictionary runningPeople = []; /// /// When the research on each person which runs right now started. Guarded by the lock of the research on the topics. /// private readonly Dictionary runningPeopleSince = []; /// /// Happens when the research on a person starts, moves to its next stage, stores a stage, or ends. /// /// /// Called on the thread of the request. A page renders through InvokeAsync. /// public event Func? PersonResearchChanged; /// /// Tells whether the research on a person runs right now, or waits for an agent. /// /// The research on the person. public bool IsRunning(BflPersonLocation location) { lock (this.runningLock) return this.runningPeople.ContainsKey(location); } /// /// Gets the stage whose agent works on a person right now. /// /// The research on the person. /// The stage, or null while the research waits for an agent or does not run. public BflPersonResearchStage? RunningStageOf(BflPersonLocation location) { lock (this.runningLock) return this.runningPeople.GetValueOrDefault(location); } /// /// Gets when the research on a person which runs right now started, so that a page can tell the stages it found /// from those of the earlier research, which it replaces once its first stage is done. /// /// The research on the person. /// The start, or null while the research does not run. public DateTimeOffset? RunningSinceOf(BflPersonLocation location) { lock (this.runningLock) return this.runningPeopleSince.TryGetValue(location, out var since) ? since : null; } /// /// Lets the agents research several people from the list of participants, each person replacing their earlier /// research. No more than MAX_PERSON_AGENTS_AT_ONCE agents work at once; the others wait. /// /// The ID of the preparation. /// The names of the people, as the list of participants gives them. /// The provider of the preparation. public Task RunPeopleAsync(Guid projectId, IEnumerable names, AIStudio.Settings.Provider provider) => Task.WhenAll(names.Select(name => this.RunPersonAsync(projectId, name, provider))); /// /// Lets the agents research one person from the list of participants, stage by stage, and stores what each stage /// found, replacing the earlier research once the first stage is done. /// /// /// /// Every agent receives nothing but the public details about the event, the entry of the person in the list of /// participants, and what the earlier stages found, see BflPromptComposer.ComposePersonResearch. Only a person on /// the stored list is researched, so that no name which the person typed in elsewhere, or which a model derived /// from the preparation, gets into a query. /// /// /// Each stage waits for a free agent. The forms and the list are read anew before each stage, since the person may /// change them meanwhile. What goes wrong is reported through the message bus; the method itself does not fail. /// /// /// The ID of the preparation. /// The name of the person, as the list of participants gives it. /// The provider of the preparation. public async Task RunPersonAsync(Guid projectId, string name, AIStudio.Settings.Provider provider) { var location = BflPersonLocation.Of(projectId, name); if (!await conversations.CheckProviderAsync(new BflStepThreadLocation(projectId, BflStep.STRATEGY), BflThreadFactory.CreateHidden("research-person"), provider)) return; var availability = await this.CheckAvailabilityAsync(provider); if (!availability.CanResearch) { await messageBus.SendError(new(AppIcons.WEB_RESEARCH, availability.Message)); return; } lock (this.runningLock) { if (!this.runningPeople.TryAdd(location, null)) return; this.runningPeopleSince[location] = DateTimeOffset.UtcNow; } var sessionKey = SessionKey(location); var sessionId = Guid.Empty; var status = AssistantSessionStatus.FAILED; try { var cancellation = new CancellationTokenSource(); sessionId = (await sessionService.TryBeginAsync(sessionKey, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.Name, cancellation, null, new(StringComparer.Ordinal))).SessionId; await this.NotifyAsync(this.PersonResearchChanged, location); var research = new BflPersonResearch(); foreach (var stage in BflPersonResearchStage.All) { await this.personAgents.WaitAsync(cancellation.Token); try { if (!await this.ResearchStageAsync(location, name, stage, research, provider, cancellation.Token)) return; } finally { this.personAgents.Release(); } } status = AssistantSessionStatus.COMPLETED; } catch (OperationCanceledException) { status = AssistantSessionStatus.CANCELED; } catch (Exception exception) when (IsReportedFailure(exception)) { await this.ReportFailureAsync(exception, location.Key); } finally { lock (this.runningLock) { this.runningPeople.Remove(location); this.runningPeopleSince.Remove(location); } if (sessionId != Guid.Empty) { await sessionService.CompleteAsync(sessionKey, sessionId, status, string.Empty, null, new(StringComparer.Ordinal)); await sessionService.ClearAsync(sessionKey); } await this.NotifyAsync(this.PersonResearchChanged, location); } } /// /// Stops the research on a person, or keeps it from starting while it waits for an agent. The stages done so far /// stay stored. /// /// The research on the person. public Task CancelAsync(BflPersonLocation location) => sessionService.CancelAsync(SessionKey(location)); /// /// Lets the agent of one stage research a person and stores what it found. /// /// The research on the person. /// The name of the person. /// The stage. /// The research of this run so far, which takes the new stage. /// The provider of the preparation. /// Stops the agent. /// Whether the stage is done, so that the next one may follow. /// The person stopped the research. private async Task ResearchStageAsync(BflPersonLocation location, string name, BflPersonResearchStage stage, BflPersonResearch research, AIStudio.Settings.Provider provider, CancellationToken token) { if (await this.LoadPublicDetailsAsync(location.ProjectId) is not { } details) return false; var participants = (await store.LoadResearchAsync(location.ProjectId, BflResearchTopic.PARTICIPANTS, token))?.Participants ?? []; if (participants.FirstOrDefault(candidate => candidate.HasName(name)) is not { } participant) { await messageBus.SendError(new(AppIcons.WEB_RESEARCH, TA("This person is no longer on the list of participants, so the lab does not research them. Please research the participants again."))); return false; } lock (this.runningLock) this.runningPeople[location] = stage; await this.NotifyAsync(this.PersonResearchChanged, location); var language = (await settingsManager.GetActiveLanguagePlugin()).LangName; var parts = await BflPromptLibrary.LoadPersonResearchPartsAsync(stage, conversations.PromptOverridesDirectory, token); var hidden = BflThreadFactory.CreateHidden($"research-person-{stage.FileStem}"); BflThreadFactory.PrepareForRequest(hidden, provider, BflPromptComposer.ComposePersonResearch(parts, details, participant, research.Stages, language), TOOL_IDS); // Neither the material nor the guidelines of the organization: the research learns public content only. var answer = await BflHiddenRequest.AskForAnswerAsync(hidden, provider, REQUEST, [], string.Empty, token); token.ThrowIfCancellationRequested(); // A notice of the app in place of the answer is no stage, and the stages found so far stay: if (answer.EndedWithoutAnswer) { logger.LogWarning("The agent of the stage '{Stage}' of the research '{Location}' of the BeyondFacts Lab used up its tools without an answer.", stage, location.Key); await messageBus.SendError(new(AppIcons.ERROR, NoAnswerMessage)); return false; } var found = BflResearch.Of(answer.Text, answer.ToolInvocations, answer.Sources, details, DateTimeOffset.UtcNow, BflPersonResearch.MAX_STAGE_TEXT_CHARACTERS); if (found.Text.Length is 0) { logger.LogWarning("The agent wrote no text for the stage '{Stage}' of the research '{Location}' of the BeyondFacts Lab.", stage, location.Key); await messageBus.SendError(new(AppIcons.ERROR, TA("The research ended without a result. Please try again, or try another provider."))); return false; } research.Name = participant.Name; research.Set(stage, found); await store.SavePersonResearchAsync(location.ProjectId, research, token); await this.NotifyAsync(this.PersonResearchChanged, location); return true; } /// /// Gets the key under which the research on a person registers in AssistantSessionService. /// /// The research on the person. private static AssistantSessionKey SessionKey(BflPersonLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.Key); }