// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// /// A text search context provider that performs a search over external knowledge /// and injects the formatted results into the AI invocation context, or exposes a search tool for on-demand use. /// This provider can be used to enable Retrieval Augmented Generation (RAG) on an agent. /// /// /// /// The provider supports two behaviors controlled via : /// /// – Automatically performs a search prior to every AI invocation and injects results as additional messages. /// – Exposes a function tool that the model may invoke to retrieve contextual information when needed. /// /// /// /// When is greater than zero the provider will retain the most recent /// user and assistant messages (up to the configured limit) across invocations and prepend them (in chronological order) /// to the current request messages when forming the search input. This can improve search relevance by providing /// multi-turn context to the retrieval layer without permanently altering the conversation history. /// /// /// Security considerations: Search results retrieved from external sources are injected into the LLM context and may /// contain adversarial content designed to manipulate LLM behavior via indirect prompt injection. Developers should be aware that: /// /// The search query may be constructed from user input or LLM-generated content, both of which are untrusted. /// Implementers of the search delegate should validate search inputs and apply appropriate access controls to search results. /// Retrieved documents are formatted and injected as messages in the AI request context. If the external data source /// is compromised, adversarial content could influence the LLM's responses. /// When using , the AI model controls /// when and what to search for — the search query text is AI-generated and should be treated as untrusted input by the search implementation. /// /// /// public sealed class TextSearchProvider : MessageAIContextProvider { private const string DefaultPluginSearchFunctionName = "Search"; private const string DefaultPluginSearchFunctionDescription = "Allows searching for additional information to help answer the user question."; private const string DefaultContextPrompt = "## Additional Context\nConsider the following information from source documents when responding to the user:"; private const string DefaultCitationsPrompt = "Include citations to the source document with document name and link if document name and link is available."; private readonly ProviderSessionState _sessionState; private IReadOnlyList? _stateKeys; private readonly Func>> _searchAsync; private readonly ILogger? _logger; private readonly AITool[] _tools; private readonly List _recentMessageRolesIncluded; private readonly int _recentMessageMemoryLimit; private readonly TextSearchProviderOptions.TextSearchBehavior _searchTime; private readonly string _contextPrompt; private readonly string _citationsPrompt; private readonly Func, string>? _contextFormatter; private readonly Redactor _redactor; /// /// Initializes a new instance of the class. /// /// Delegate that executes the search logic. Must not be . /// Optional configuration options. /// Optional logger factory. /// Thrown when is . public TextSearchProvider( Func>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null) : base(options?.SearchInputMessageFilter, options?.StorageInputRequestMessageFilter, options?.StorageInputResponseMessageFilter) { this._sessionState = new ProviderSessionState( _ => new TextSearchProviderState(), options?.StateKey ?? this.GetType().Name, AgentJsonUtilities.DefaultOptions); // Validate and assign parameters this._searchAsync = Throw.IfNull(searchAsync); this._logger = loggerFactory?.CreateLogger(); this._recentMessageMemoryLimit = Throw.IfLessThan(options?.RecentMessageMemoryLimit ?? 0, 0); this._recentMessageRolesIncluded = options?.RecentMessageRolesIncluded ?? [ChatRole.User]; this._searchTime = options?.SearchTime ?? TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke; this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt; this._contextFormatter = options?.ContextFormatter; this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor("")); // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) this._tools = [ AIFunctionFactory.Create( this.SearchAsync, name: options?.FunctionToolName ?? DefaultPluginSearchFunctionName, description: options?.FunctionToolDescription ?? DefaultPluginSearchFunctionDescription) ]; } /// public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; /// protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) { if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { // Expose the search tool for on-demand invocation. return new AIContext { Tools = this._tools }; } return new AIContext { Messages = await this.ProvideMessagesAsync( new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []), cancellationToken).ConfigureAwait(false) }; } /// protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) { // This code path is invoked using InvokingAsync on MessageAIContextProvider, which does not support tools and instructions, // and OnDemandFunctionCalling requires tools. if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { throw new InvalidOperationException($"Using the {nameof(TextSearchProvider)} as a {nameof(MessageAIContextProvider)} is not supported when {nameof(TextSearchProviderOptions.SearchTime)} is set to {TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling}."); } return base.InvokingCoreAsync(context, cancellationToken); } /// protected override async ValueTask> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default) { // Retrieve recent messages from the session state. var recentMessagesText = this._sessionState.GetOrInitializeState(context.Session).RecentMessagesText ?? []; // Aggregate text from memory + current request messages. var sbInput = new StringBuilder(); var requestMessagesText = (context.RequestMessages ?? []) .Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text); foreach (var messageText in recentMessagesText.Concat(requestMessagesText)) { if (sbInput.Length > 0) { sbInput.Append('\n'); } sbInput.Append(messageText); } string input = sbInput.ToString(); try { // Search var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false); IList materialized = results as IList ?? results.ToList(); if (this._logger?.IsEnabled(LogLevel.Information) is true) { this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count); } if (materialized.Count == 0) { return []; } // Format search results string formatted = this.FormatResults(materialized); if (this._logger?.IsEnabled(LogLevel.Trace) is true) { this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", this.SanitizeLogData(input), this.SanitizeLogData(formatted)); } return [new ChatMessage(ChatRole.User, formatted)]; } catch (Exception ex) { this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error"); return []; } } /// protected override ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) { int limit = this._recentMessageMemoryLimit; if (limit <= 0) { return default; // Memory disabled. } if (context.Session is null) { return default; // No session to store state in. } // Retrieve existing recent messages from the session state. var recentMessagesText = this._sessionState.GetOrInitializeState(context.Session).RecentMessagesText ?? []; var newMessagesText = context.RequestMessages .Concat(context.ResponseMessages ?? []) .Where(m => this._recentMessageRolesIncluded.Contains(m.Role) && !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text); // Combine existing messages with new messages, then take the most recent up to the limit. var allMessages = recentMessagesText.Concat(newMessagesText).ToList(); var updatedMessages = allMessages.Count > limit ? allMessages.Skip(allMessages.Count - limit).ToList() : allMessages; // Store updated state back to the session. this._sessionState.SaveState( context.Session, new TextSearchProviderState { RecentMessagesText = updatedMessages }); return default; } /// /// Function callable by the AI model (when enabled) to perform an ad-hoc search. /// /// The query text. /// Cancellation token. /// Formatted search results. internal async Task SearchAsync(string userQuestion, CancellationToken cancellationToken = default) { var results = await this._searchAsync(userQuestion, cancellationToken).ConfigureAwait(false); IList materialized = results as IList ?? results.ToList(); string outputText = this.FormatResults(materialized); if (this._logger?.IsEnabled(LogLevel.Information) is true) { this._logger.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count); if (this._logger.IsEnabled(LogLevel.Trace)) { this._logger.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", this.SanitizeLogData(userQuestion), this.SanitizeLogData(outputText)); } } return outputText; } /// /// Formats search results into an output string for model consumption. /// /// The results. /// Formatted string (may be empty). private string FormatResults(IList results) { if (this._contextFormatter is not null) { return this._contextFormatter(results) ?? string.Empty; } if (results.Count == 0) { return string.Empty; // No extra context. } var sb = new StringBuilder(); sb.AppendLine(this._contextPrompt); for (int i = 0; i < results.Count; i++) { var result = results[i]; if (!string.IsNullOrWhiteSpace(result.SourceName)) { sb.AppendLine($"SourceDocName: {result.SourceName}"); } if (!string.IsNullOrWhiteSpace(result.SourceLink)) { sb.AppendLine($"SourceDocLink: {result.SourceLink}"); } sb.AppendLine($"Contents: {result.Text}"); sb.AppendLine("----"); } sb.AppendLine(this._citationsPrompt); sb.AppendLine(); return sb.ToString(); } /// /// Represents a single retrieved text search result. /// public sealed class TextSearchResult { /// /// Gets or sets the display name of the source document (optional). /// public string? SourceName { get; set; } /// /// Gets or sets a link/URL to the source document (optional). /// public string? SourceLink { get; set; } /// /// Gets or sets the textual content of the retrieved chunk. /// public string Text { get; set; } = string.Empty; /// /// Gets or sets the raw representation of the search result from the data source. /// /// /// If a is created to represent some underlying object from another object /// model, this property can be used to store that original object. This can be useful for debugging or /// for enabling the to access the underlying object model if needed. /// public object? RawRepresentation { get; set; } } private string SanitizeLogData(string? data) => this._redactor.Redact(data); /// /// Represents the per-session state of a stored in the . /// public sealed class TextSearchProviderState { /// /// Gets or sets the list of recent message texts retained for multi-turn search context. /// public List? RecentMessagesText { get; set; } } }