Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
// detects the pending message before the next service call and includes the injected message in the request.
//
// To use end-of-run persistence instead (atomic run semantics), remove the
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
Expand Down Expand Up @@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};

// This tool demonstrates message injection during the function execution loop.
// When called, it checks travel advisories for a city. If an advisory is active, it uses
// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
// asking for alternative destinations. The model will process this injected message on the next
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
[Description("Check current travel advisories for a city.")]
static string CheckTravelAdvisory([Description("The city name.")] string city)
{
// Simulated travel advisory data.
var advisory = city.ToUpperInvariant() switch
{
"LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
"SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
_ => null
};

if (advisory is null)
{
return $"{city}: No active travel advisories.";
}

// When an advisory is found, inject a follow-up question so the model automatically
// suggests alternatives without the user needing to ask.
var runContext = AIAgent.CurrentRunContext!;
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
runContext.Session!,
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);

return advisory;
}

// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
Expand All @@ -65,10 +101,11 @@ static string GetTime([Description("The city name.")] string city) =>
{
Name = "WeatherAssistant",
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
},
});

Expand Down Expand Up @@ -109,6 +146,18 @@ async Task RunNonStreamingAsync()
response = await agent.RunAsync(FollowUp2, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);

// Fourth turn — demonstrates message injection during the function loop.
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);

response = await agent.RunAsync(TravelPrompt, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}

async Task RunStreamingAsync()
Expand Down Expand Up @@ -181,6 +230,30 @@ async Task RunStreamingAsync()

Console.WriteLine();
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);

// Fourth turn — demonstrates message injection during the function loop (streaming).
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);

Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();

await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
{
Console.Write(update);

// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}

Console.WriteLine();
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}

void PrintUserMessage(string message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to include a <see cref="MessageInjectingChatClient"/>
/// in the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/>, a <see cref="MessageInjectingChatClient"/> is added to the pipeline
/// between the <see cref="FunctionInvokingChatClient"/> and the inner client. This enables external code
/// (such as tool delegates) to inject messages into the function execution loop via the
/// <see cref="MessageInjectingChatClient"/> class, which can be resolved from the chat client using
/// <c>GetService&lt;MessageInjectingChatClient&gt;()</c>.
/// </para>
/// <para>
/// This setting can be used independently of <see cref="RequirePerServiceCallChatHistoryPersistence"/>,
/// however it is recommended to also enable per-service-call persistence when using message injection
/// so that injected messages are persisted to chat history between service calls.
/// </para>
/// <para>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="EnableMessageInjection"/> to <see langword="true"/>, ensure that your custom chat client stack
/// includes a <see cref="MessageInjectingChatClient"/>. You can add one manually via the
/// <see cref="ChatClientBuilderExtensions.UseMessageInjection"/> extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }

/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
Expand All @@ -168,5 +198,6 @@ public ChatClientAgentOptions Clone()
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,38 @@ public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this Cha
{
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}

/// <summary>
/// Adds a <see cref="MessageInjectingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator enables external code (such as tool delegates) to inject messages into the function
/// execution loop. It should be positioned between the <see cref="FunctionInvokingChatClient"/> and
/// the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
/// in the pipeline.
/// </para>
/// <para>
/// The <see cref="MessageInjectingChatClient"/> can be retrieved from the chat client via
/// <c>GetService&lt;MessageInjectingChatClient&gt;</c> to enqueue messages from tool delegates or other code.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
/// exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
}
20 changes: 14 additions & 6 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,21 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
});
}

// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
// MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
// By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
// MessageInjectingChatClient enables injecting messages during the function loop and looping when needed.
if (options?.EnableMessageInjection is true)
{
chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}

// PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client.
// The resulting pipeline is:
// FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient
// PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history
// before each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
Expand Down
Loading
Loading