Merge branch 'microsoft:main' into features/ai-project-2.0.0-update

This commit is contained in:
Roger Barreto
2026-02-25 18:33:42 +00:00
committed by GitHub
Unverified
1147 changed files with 57298 additions and 14699 deletions
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
#if !NET8_0_OR_GREATER
@@ -28,7 +28,7 @@ internal sealed class ExperimentalAttribute : Attribute
/// <param name="diagnosticId">Human readable explanation for marking experimental API.</param>
public ExperimentalAttribute(string diagnosticId)
{
DiagnosticId = diagnosticId;
this.DiagnosticId = diagnosticId;
}
/// <summary>
+12 -3
View File
@@ -63,7 +63,16 @@ public sealed class A2AAgent : AIAgent
/// <param name="contextId">The context id to continue.</param>
/// <returns>A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance.</returns>
public ValueTask<AgentSession> CreateSessionAsync(string contextId)
=> new(new A2AAgentSession() { ContextId = contextId });
=> new(new A2AAgentSession() { ContextId = Throw.IfNullOrWhitespace(contextId) });
/// <summary>
/// Get a new <see cref="AgentSession"/> instance using an existing context id and task id, to resume that conversation from a specific task.
/// </summary>
/// <param name="contextId">The context id to continue.</param>
/// <param name="taskId">The task id to resume from.</param>
/// <returns>A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance.</returns>
public ValueTask<AgentSession> CreateSessionAsync(string contextId, string taskId)
=> new(new A2AAgentSession() { ContextId = Throw.IfNullOrWhitespace(contextId), TaskId = Throw.IfNullOrWhitespace(taskId) });
/// <inheritdoc/>
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
@@ -72,7 +81,7 @@ public sealed class A2AAgent : AIAgent
if (session is not A2AAgentSession typedSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(A2AAgentSession)}' can be serialized by this agent.");
}
return new(typedSession.Serialize(jsonSerializerOptions));
@@ -247,7 +256,7 @@ public sealed class A2AAgent : AIAgent
if (session is not A2AAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type {session.GetType()} is not compatible with the agent. Only A2A agent created sessions are supported.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(A2AAgentSession)}' can be used by this agent.");
}
return typedSession;
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -10,7 +11,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for components that enhance AI context management during agent invocations.
/// Provides an abstract base class for components that enhance AI context during agent invocations.
/// </summary>
/// <remarks>
/// <para>
@@ -30,6 +31,32 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public abstract class AIContextProvider
{
private static IEnumerable<ChatMessage> DefaultExternalOnlyFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
/// <summary>
/// Initializes a new instance of the <see cref="AIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
protected AIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
{
this.ProvideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputMessageFilter = storeInputMessageFilter ?? DefaultExternalOnlyFilter;
}
/// <summary>
/// Gets the filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> ProvideInputMessageFilter { get; }
/// <summary>
/// Gets the filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputMessageFilter { get; }
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -58,7 +85,7 @@ public abstract class AIContextProvider
/// </para>
/// </remarks>
public ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(context, cancellationToken);
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide additional context.
@@ -76,8 +103,96 @@ public abstract class AIContextProvider
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// The default implementation of this method filters the input messages using the configured provide-input message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// then calls <see cref="ProvideAIContextAsync"/> to get additional context,
/// stamps any messages from the returned context with <see cref="AgentRequestMessageSourceType.AIContextProvider"/> source attribution,
/// and merges the returned context with the original (unfiltered) input context (concatenating instructions, messages, and tools).
/// For most scenarios, overriding <see cref="ProvideAIContextAsync"/> is sufficient to provide additional context,
/// while still benefiting from the default filtering, merging and source stamping behavior.
/// However, for scenarios that require more control over context filtering, merging or source stamping, overriding this method
/// allows you to directly control the full <see cref="AIContext"/> returned for the invocation.
/// </para>
/// </remarks>
protected abstract ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default);
protected virtual async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var inputContext = context.AIContext;
// Create a filtered context for ProvideAIContextAsync, filtering input messages
// to exclude non-external messages (e.g. chat history, other AI context provider messages).
var filteredContext = new InvokingContext(
context.Agent,
context.Session,
new AIContext
{
Instructions = inputContext.Instructions,
Messages = inputContext.Messages is not null ? this.ProvideInputMessageFilter(inputContext.Messages) : null,
Tools = inputContext.Tools
});
var provided = await this.ProvideAIContextAsync(filteredContext, cancellationToken).ConfigureAwait(false);
var mergedInstructions = (inputContext.Instructions, provided.Instructions) switch
{
(null, null) => null,
(string a, null) => a,
(null, string b) => b,
(string a, string b) => a + "\n" + b
};
var providedMessages = provided.Messages is not null
? provided.Messages.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!))
: null;
var mergedMessages = (inputContext.Messages, providedMessages) switch
{
(null, null) => null,
(var a, null) => a,
(null, var b) => b,
(var a, var b) => a.Concat(b)
};
var mergedTools = (inputContext.Tools, provided.Tools) switch
{
(null, null) => null,
(var a, null) => a,
(null, var b) => b,
(var a, var b) => a.Concat(b)
};
return new AIContext
{
Instructions = mergedInstructions,
Messages = mergedMessages,
Tools = mergedTools
};
}
/// <summary>
/// When overridden in a derived class, provides additional AI context to be merged with the input context for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync"/>.
/// Note that <see cref="InvokingCoreAsync"/> can be overridden to directly control context merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the additional context.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional context to be merged with the input,
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full merged <see cref="AIContext"/> for the invocation.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
/// with additional context to be merged with the input context.
/// </returns>
protected virtual ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<AIContext>(new AIContext());
}
/// <summary>
/// Called at the end of the agent invocation to process the invocation results.
@@ -106,7 +221,7 @@ public abstract class AIContextProvider
/// </para>
/// </remarks>
public ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
=> this.InvokedCoreAsync(context, cancellationToken);
=> this.InvokedCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the end of the agent invocation to process the invocation results.
@@ -128,9 +243,50 @@ public abstract class AIContextProvider
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// <para>
/// The default implementation of this method skips execution for any invocation failures,
/// filters the request messages using the configured store-input message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// and calls <see cref="StoreAIContextAsync"/> to process the invocation results.
/// For most scenarios, overriding <see cref="StoreAIContextAsync"/> is sufficient to process invocation results,
/// while still benefiting from the default error handling and filtering behavior.
/// However, for scenarios that require more control over error handling or message filtering, overriding this method
/// allows you to directly control the processing of invocation results.
/// </para>
/// </remarks>
protected virtual ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
=> default;
{
if (context.InvokeException is not null)
{
return default;
}
var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputMessageFilter(context.RequestMessages), context.ResponseMessages!);
return this.StoreAIContextAsync(subContext, cancellationToken);
}
/// <summary>
/// When overridden in a derived class, processes invocation results at the end of the agent invocation.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokedCoreAsync"/>.
/// Note that <see cref="InvokedCoreAsync"/> can be overridden to directly control error handling, in which case
/// it is up to the implementer to call this method as needed to process the invocation results.
/// </para>
/// <para>
/// In contrast with <see cref="InvokedCoreAsync"/>, this method only processes the invocation results,
/// while <see cref="InvokedCoreAsync"/> is also responsible for error handling.
/// </para>
/// <para>
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
/// </para>
/// </remarks>
protected virtual ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>Asks the <see cref="AIContextProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -173,6 +174,7 @@ public class AgentResponse
/// to poll for completion.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -50,6 +52,7 @@ public class AgentRunOptions
/// can be polled for completion by obtaining the token from the <see cref="AgentResponse.ContinuationToken"/> property
/// and passing it via this property on subsequent calls to <see cref="AIAgent.RunAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -39,6 +40,25 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public abstract class ChatHistoryProvider
{
private static IEnumerable<ChatMessage> DefaultExcludeChatHistoryFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? _provideOutputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputMessageFilter;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryProvider"/> class.
/// </summary>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
protected ChatHistoryProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
{
this._provideOutputMessageFilter = provideOutputMessageFilter;
this._storeInputMessageFilter = storeInputMessageFilter ?? DefaultExcludeChatHistoryFilter;
}
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -50,20 +70,16 @@ public abstract class ChatHistoryProvider
public virtual string StateKey => this.GetType().Name;
/// <summary>
/// Called at the start of agent invocation to provide messages from the chat history as context for the next agent invocation.
/// Called at the start of agent invocation to provide messages for the next agent invocation.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances in ascending chronological order (oldest first).
/// instances that will be used for the agent invocation.
/// </returns>
/// <remarks>
/// <para>
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
/// The oldest messages appear first in the collection, followed by more recent messages.
/// </para>
/// <para>
/// If the total message history becomes very large, implementations should apply appropriate strategies to manage
/// storage constraints, such as:
/// <list type="bullet">
@@ -75,23 +91,19 @@ public abstract class ChatHistoryProvider
/// </para>
/// </remarks>
public ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(context, cancellationToken);
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide messages from the chat history as context for the next agent invocation.
/// Called at the start of agent invocation to provide messages for the next agent invocation.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances in ascending chronological order (oldest first).
/// instances that will be used for the agent invocation.
/// </returns>
/// <remarks>
/// <para>
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
/// The oldest messages appear first in the collection, followed by more recent messages.
/// </para>
/// <para>
/// If the total message history becomes very large, implementations should apply appropriate strategies to manage
/// storage constraints, such as:
/// <list type="bullet">
@@ -102,11 +114,54 @@ public abstract class ChatHistoryProvider
/// </list>
/// </para>
/// <para>
/// Each <see cref="ChatHistoryProvider"/> instance should be associated with a single <see cref="AgentSession"/> to ensure proper message isolation
/// and context management.
/// The default implementation of this method, calls <see cref="ProvideChatHistoryAsync"/> to get the chat history messages, applies the optional retrieval output filter,
/// and merges the returned messages with the caller provided messages (with chat history messages appearing first) before returning the full message list to be used for the invocation.
/// For most scenarios, overriding <see cref="ProvideChatHistoryAsync"/> is sufficient to return the desired chat history messages, while still benefiting from the default merging and filtering behavior.
/// However, for scenarios that require more control over message filtering, merging or source stamping, overriding this method allows you to directly control the full set of messages returned for the invocation.
/// </para>
/// </remarks>
protected abstract ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default);
protected virtual async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var output = await this.ProvideChatHistoryAsync(context, cancellationToken).ConfigureAwait(false);
if (this._provideOutputMessageFilter is not null)
{
output = this._provideOutputMessageFilter(output);
}
return output
.Select(message => message.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!))
.Concat(context.RequestMessages);
}
/// <summary>
/// When overridden in a derived class, provides the chat history messages to be used for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync"/>.
/// Note that <see cref="InvokingCoreAsync"/> can be overridden to directly control message filtering, merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the unfiltered/unmerged chat history messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional messages to be added to the request,
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full set of messages to be used for the invocation (including caller provided messages).
/// </para>
/// <para>
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
/// The oldest messages appear first in the collection, followed by more recent messages.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances in ascending chronological order (oldest first).
/// </returns>
protected virtual ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<IEnumerable<ChatMessage>>([]);
}
/// <summary>
/// Called at the end of the agent invocation to add new messages to the chat history.
@@ -134,7 +189,7 @@ public abstract class ChatHistoryProvider
/// </para>
/// </remarks>
public ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
this.InvokedCoreAsync(context, cancellationToken);
this.InvokedCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the end of the agent invocation to add new messages to the chat history.
@@ -160,8 +215,59 @@ public abstract class ChatHistoryProvider
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// <para>
/// The default implementation of this method, skips execution for any invocation failures, filters messages using the optional storage input message filter
/// and calls <see cref="StoreChatHistoryAsync"/> to store new chat history messages.
/// For most scenarios, overriding <see cref="StoreChatHistoryAsync"/> is sufficient to store chat history messages, while still benefiting from the default error handling and filtering behavior.
/// However, for scenarios that require more control over error handling or message filtering, overriding this method allows you to directly control the messages that are stored for the invocation.
/// </para>
/// </remarks>
protected abstract ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default);
protected virtual ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
{
return default;
}
var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputMessageFilter(context.RequestMessages), context.ResponseMessages!);
return this.StoreChatHistoryAsync(subContext, cancellationToken);
}
/// <summary>
/// When overridden in a derived class, adds new messages to the chat history at the end of the agent invocation.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous add operation.</returns>
/// <remarks>
/// <para>
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
/// The <see cref="ChatHistoryProvider"/> is responsible for preserving message ordering and ensuring that subsequent calls to
/// <see cref="InvokingCoreAsync"/> return messages in the correct chronological order.
/// </para>
/// <para>
/// Implementations may perform additional processing during message addition, such as:
/// <list type="bullet">
/// <item><description>Validating message content and metadata</description></item>
/// <item><description>Applying storage optimizations or compression</description></item>
/// <item><description>Triggering background maintenance operations</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called from <see cref="InvokedCoreAsync"/>.
/// Note that <see cref="InvokedCoreAsync"/> can be overridden to directly control message filtering and error handling, in which case
/// it is up to the implementer to call this method as needed to store messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokedCoreAsync"/>, this method only stores messages,
/// while <see cref="InvokedCoreAsync"/> is also responsible for messages filtering and error handling.
/// </para>
/// <para>
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
/// </para>
/// </remarks>
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -27,14 +26,7 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
private static IEnumerable<ChatMessage> DefaultExcludeChatHistoryFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);
private readonly string _stateKey;
private readonly Func<AgentSession?, State> _stateInitializer;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storageInputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? _retrievalOutputMessageFilter;
private readonly ProviderSessionState<State> _sessionState;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class.
@@ -44,18 +36,20 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// message reduction, and serialization settings. If <see langword="null"/>, default settings will be used.
/// </param>
public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = null)
: base(
options?.ProvideOutputMessageFilter,
options?.StorageInputMessageFilter)
{
this._stateInitializer = options?.StateInitializer ?? (_ => new State());
this._sessionState = new ProviderSessionState<State>(
options?.StateInitializer ?? (_ => new State()),
options?.StateKey ?? this.GetType().Name,
options?.JsonSerializerOptions);
this.ChatReducer = options?.ChatReducer;
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
this._stateKey = options?.StateKey ?? base.StateKey;
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
this._storageInputMessageFilter = options?.StorageInputMessageFilter ?? DefaultExcludeChatHistoryFilter;
this._retrievalOutputMessageFilter = options?.RetrievalOutputMessageFilter;
}
/// <inheritdoc />
public override string StateKey => this._stateKey;
public override string StateKey => this._sessionState.StateKey;
/// <summary>
/// Gets the chat reducer used to process or reduce chat messages. If null, no reduction logic will be applied.
@@ -73,7 +67,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <param name="session">The agent session containing the state.</param>
/// <returns>A list of chat messages, or an empty list if no state is found.</returns>
public List<ChatMessage> GetMessages(AgentSession? session)
=> this.GetOrInitializeState(session).Messages;
=> this._sessionState.GetOrInitializeState(session).Messages;
/// <summary>
/// Sets the chat messages for the specified session.
@@ -85,67 +79,30 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
_ = Throw.IfNull(messages);
var state = this.GetOrInitializeState(session);
var state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
/// <summary>
/// Gets the state from the session's StateBag, or initializes it using the state initializer if not present.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <returns>The provider state, or null if no session is available.</returns>
private State GetOrInitializeState(AgentSession? session)
{
if (session?.StateBag.TryGetValue<State>(this._stateKey, out var state, this._jsonSerializerOptions) is true && state is not null)
{
return state;
}
state = this._stateInitializer(session);
if (session is not null)
{
session.StateBag.SetValue(this._stateKey, state, this._jsonSerializerOptions);
}
return state;
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
var state = this.GetOrInitializeState(context.Session);
var state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
}
IEnumerable<ChatMessage> output = state.Messages;
if (this._retrievalOutputMessageFilter is not null)
{
output = this._retrievalOutputMessageFilter(output);
}
return output
.Select(message => message.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!))
.Concat(context.RequestMessages);
return state.Messages;
}
/// <inheritdoc />
protected override async ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
if (context.InvokeException is not null)
{
return;
}
var state = this.GetOrInitializeState(context.Session);
var state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = this._storageInputMessageFilter(context.RequestMessages).Concat(context.ResponseMessages ?? []);
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
@@ -71,7 +71,7 @@ public sealed class InMemoryChatHistoryProviderOptions
/// <value>
/// When <see langword="null"/>, no filtering is applied to the output messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? RetrievalOutputMessageFilter { get; set; }
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
/// <summary>
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
@@ -0,0 +1,203 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for components that enhance AI context during agent invocations by supplying additional chat messages.
/// </summary>
/// <remarks>
/// <para>
/// A message AI context provider is a component that participates in the agent invocation lifecycle by:
/// <list type="bullet">
/// <item><description>Listening to changes in conversations</description></item>
/// <item><description>Providing additional messages to agents during invocation</description></item>
/// <item><description>Processing invocation results for state management or learning</description></item>
/// </list>
/// </para>
/// <para>
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
/// <see cref="AIContextProvider.InvokingAsync"/> to provide context, and optionally called at the end of invocation via
/// <see cref="AIContextProvider.InvokedAsync"/> to process results.
/// </para>
/// </remarks>
public abstract class MessageAIContextProvider : AIContextProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="MessageAIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing messages via <see cref="ProvideMessagesAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to request messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
protected MessageAIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: base(provideInputMessageFilter, storeInputMessageFilter)
{
}
/// <inheritdoc/>
protected override async ValueTask<AIContext> ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default)
{
// Call ProvideMessagesAsync directly to return only additional messages.
// The base AIContextProvider.InvokingCoreAsync handles merging with the original input and stamping.
return new AIContext
{
Messages = await this.ProvideMessagesAsync(
new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []),
cancellationToken).ConfigureAwait(false)
};
}
/// <summary>
/// Called at the start of agent invocation to provide additional messages.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional messages required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// </remarks>
public ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide additional messages.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional messages required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// The default implementation of this method filters the input messages using the configured provide-input message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// then calls <see cref="ProvideMessagesAsync"/> to get additional messages,
/// stamps any messages with <see cref="AgentRequestMessageSourceType.AIContextProvider"/> source attribution,
/// and merges the returned messages with the original (unfiltered) input messages.
/// For most scenarios, overriding <see cref="ProvideMessagesAsync"/> is sufficient to provide additional messages,
/// while still benefiting from the default filtering, merging and source stamping behavior.
/// However, for scenarios that require more control over message filtering, merging or source stamping, overriding this method
/// allows you to directly control the full <see cref="IEnumerable{ChatMessage}"/> returned for the invocation.
/// </para>
/// </remarks>
protected virtual async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var inputMessages = context.RequestMessages;
// Create a filtered context for ProvideMessagesAsync, filtering input messages
// to exclude non-external messages (e.g. chat history, other AI context provider messages).
var filteredContext = new InvokingContext(
context.Agent,
context.Session,
this.ProvideInputMessageFilter(inputMessages));
var providedMessages = await this.ProvideMessagesAsync(filteredContext, cancellationToken).ConfigureAwait(false);
// Stamp and merge provided messages.
providedMessages = providedMessages.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!));
return inputMessages.Concat(providedMessages);
}
/// <summary>
/// When overridden in a derived class, provides additional messages to be merged with the input messages for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// Note that <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> can be overridden to directly control messages merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the additional messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>, this method only returns additional messages to be merged with the input,
/// while <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> is responsible for returning the full merged <see cref="IEnumerable{ChatMessage}"/> for the invocation.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="IEnumerable{ChatMessage}"/>
/// with additional messages to be merged with the input messages.
/// </returns>
protected virtual ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<IEnumerable<ChatMessage>>([]);
}
/// <summary>
/// Contains the context information provided to <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about the invocation before the underlying AI model is invoked, including the messages
/// that will be used. Message AI Context providers can use this information to determine what additional messages
/// should be provided for the invocation.
/// </remarks>
public new sealed class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
/// </summary>
/// <param name="agent">The agent being invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/> or <paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokingContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the messages that will be used by the agent for this invocation. <see cref="MessageAIContextProvider"/> instances can modify
/// and return or return a new message list to add additional messages for the invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the messages that will be used by the agent for this invocation.
/// </value>
/// <remarks>
/// <para>
/// If multiple <see cref="MessageAIContextProvider"/> instances are used in the same invocation, each <see cref="MessageAIContextProvider"/>
/// will receive the messages returned by the previous <see cref="MessageAIContextProvider"/> allowing them to build on top of each other's context.
/// </para>
/// <para>
/// The first <see cref="MessageAIContextProvider"/> in the invocation pipeline will receive the
/// caller provided messages.
/// </para>
/// </remarks>
public IEnumerable<ChatMessage> RequestMessages { get; set { field = Throw.IfNull(value); } }
}
}
@@ -3,7 +3,7 @@
<PropertyGroup>
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
</PropertyGroup>
<PropertyGroup>
@@ -14,6 +14,8 @@
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides strongly-typed state management for providers, enabling reading and writing of provider-specific state
/// to and from an <see cref="AgentSession"/>'s <see cref="AgentSessionStateBag"/>.
/// </summary>
/// <typeparam name="TState">The type of the state to be maintained. Must be a reference type.</typeparam>
/// <remarks>
/// <para>
/// This class encapsulates the logic for initializing, retrieving, and persisting provider state in the session's StateBag
/// using a configurable key and JSON serialization options. It is intended to be used as a composed field within provider
/// implementations (e.g., <see cref="AIContextProvider"/> or <see cref="ChatHistoryProvider"/> subclasses) to avoid
/// duplicating state management logic across provider type hierarchies.
/// </para>
/// <para>
/// State is stored in the <see cref="AgentSession.StateBag"/> using the <see cref="StateKey"/> property as the key,
/// enabling multiple providers to maintain independent state within the same session.
/// </para>
/// </remarks>
public class ProviderSessionState<TState>
where TState : class
{
private readonly Func<AgentSession?, TState> _stateInitializer;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ProviderSessionState{TState}"/> class.
/// </summary>
/// <param name="stateInitializer">A function to initialize the state when it is not yet present in the session's StateBag.</param>
/// <param name="stateKey">The key used to store the state in the session's StateBag.</param>
/// <param name="jsonSerializerOptions">Options for JSON serialization and deserialization of the state.</param>
public ProviderSessionState(
Func<AgentSession?, TState> stateInitializer,
string stateKey,
JsonSerializerOptions? jsonSerializerOptions = null)
{
this._stateInitializer = Throw.IfNull(stateInitializer);
this.StateKey = Throw.IfNullOrWhitespace(stateKey);
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
}
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public string StateKey { get; }
/// <summary>
/// Gets the state from the session's StateBag, or initializes it using the state initializer if not present.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <returns>The provider state.</returns>
public TState GetOrInitializeState(AgentSession? session)
{
if (session?.StateBag.TryGetValue<TState>(this.StateKey, out var state, this._jsonSerializerOptions) is true && state is not null)
{
return state;
}
state = this._stateInitializer(session);
if (session is not null)
{
session.StateBag.SetValue(this.StateKey, state, this._jsonSerializerOptions);
}
return state;
}
/// <summary>
/// Saves the specified state to the session's StateBag using the configured state key and JSON serializer options.
/// If the session is null, this method does nothing.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <param name="state">The state to be saved.</param>
public void SaveState(AgentSession? session, TState state)
{
if (session is not null)
{
session.StateBag.SetValue(this.StateKey, state, this._jsonSerializerOptions);
}
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
@@ -1,20 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
namespace Microsoft.Agents.AI.AzureAI;
/// <summary>
/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using
/// Azure-specific agent capabilities.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class AzureAIProjectChatClient : DelegatingChatClient
{
private readonly ChatClientMetadata? _metadata;
@@ -2,6 +2,7 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
@@ -12,18 +13,17 @@ using Azure.AI.Projects.OpenAI;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AzureAI;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI;
using OpenAI.Responses;
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods for <see cref="AIProjectClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static partial class AzureAIProjectChatClientExtensions
{
/// <summary>
@@ -1,13 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects.OpenAI" />
@@ -60,7 +60,7 @@ public class CopilotStudioAgent : AIAgent
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be serialized by this agent.");
}
return new(typedSession.Serialize(jsonSerializerOptions));
@@ -84,7 +84,7 @@ public class CopilotStudioAgent : AIAgent
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
@@ -123,7 +123,7 @@ public class CopilotStudioAgent : AIAgent
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
@@ -21,14 +21,10 @@ namespace Microsoft.Agents.AI;
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
{
private static IEnumerable<ChatMessage> DefaultExcludeChatHistoryFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);
private readonly ProviderSessionState<State> _sessionState;
private readonly CosmosClient _cosmosClient;
private readonly Container _container;
private readonly bool _ownsClient;
private readonly string _stateKey;
private readonly Func<AgentSession?, State> _stateInitializer;
private bool _disposed;
/// <summary>
@@ -46,9 +42,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
return options;
}
/// <inheritdoc />
public override string StateKey => this._stateKey;
/// <summary>
/// Gets or sets the maximum number of messages to return in a single query batch.
/// Default is 100 for optimal performance.
@@ -84,25 +77,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// </summary>
public string ContainerId { get; init; }
/// <summary>
/// A filter function applied to request messages before they are stored
/// during <see cref="ChatHistoryProvider.InvokedAsync"/>. The default filter excludes messages with the
/// <see cref="AgentRequestMessageSourceType.ChatHistory"/> source type.
/// </summary>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StorageInputMessageFilter { get; set { field = Throw.IfNull(value); } } = DefaultExcludeChatHistoryFilter;
/// <summary>
/// Gets or sets an optional filter function applied to messages produced by this provider
/// during <see cref="ChatHistoryProvider.InvokingAsync"/>.
/// </summary>
/// <remarks>
/// This filter is only applied to the messages that the provider itself produces (from its internal storage).
/// </remarks>
/// <value>
/// When <see langword="null"/>, no filtering is applied to the output messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? RetrievalOutputMessageFilter { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class.
/// </summary>
@@ -112,6 +86,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId).</param>
/// <param name="ownsClient">Whether this instance owns the CosmosClient and should dispose it.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -120,17 +96,24 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
string containerId,
Func<AgentSession?, State> stateInitializer,
bool ownsClient = false,
string? stateKey = null)
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: base(provideOutputMessageFilter, storeInputMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
Throw.IfNull(stateInitializer),
stateKey ?? this.GetType().Name);
this._cosmosClient = Throw.IfNull(cosmosClient);
this.DatabaseId = Throw.IfNullOrWhitespace(databaseId);
this.ContainerId = Throw.IfNullOrWhitespace(containerId);
this._container = this._cosmosClient.GetContainer(databaseId, containerId);
this._stateInitializer = Throw.IfNull(stateInitializer);
this._ownsClient = ownsClient;
this._stateKey = stateKey ?? base.StateKey;
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a connection string.
/// </summary>
@@ -139,6 +122,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -146,8 +131,10 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
string databaseId,
string containerId,
Func<AgentSession?, State> stateInitializer,
string? stateKey = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey)
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputMessageFilter)
{
}
@@ -160,6 +147,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputMessageFilter">An optional filter function to apply to messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
@@ -168,32 +157,13 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
string databaseId,
string containerId,
Func<AgentSession?, State> stateInitializer,
string? stateKey = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey)
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputMessageFilter)
{
}
/// <summary>
/// Gets the state from the session's StateBag, or initializes it using the state initializer if not present.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <returns>The provider state, or null if no session is available.</returns>
private State GetOrInitializeState(AgentSession? session)
{
if (session?.StateBag.TryGetValue<State>(this._stateKey, out var state, AgentAbstractionsJsonUtilities.DefaultOptions) is true && state is not null)
{
return state;
}
state = this._stateInitializer(session);
if (session is not null)
{
session.StateBag.SetValue(this._stateKey, state, AgentAbstractionsJsonUtilities.DefaultOptions);
}
return state;
}
/// <summary>
/// Determines whether hierarchical partitioning should be used based on the state.
/// </summary>
@@ -218,7 +188,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
@@ -227,9 +197,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
_ = Throw.IfNull(context);
var state = this.GetOrInitializeState(context.Session);
var state = this._sessionState.GetOrInitializeState(context.Session);
var partitionKey = BuildPartitionKey(state);
// Fetch most recent messages in descending order when limit is set, then reverse to ascending
@@ -279,22 +247,12 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
messages.Reverse();
}
return (this.RetrievalOutputMessageFilter is not null ? this.RetrievalOutputMessageFilter(messages) : messages)
.Select(message => message.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!))
.Concat(context.RequestMessages);
return messages;
}
/// <inheritdoc />
protected override async ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
if (context.InvokeException is not null)
{
// Do not store messages if there was an exception during invocation
return;
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
@@ -302,8 +260,8 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
var state = this.GetOrInitializeState(context.Session);
var messageList = this.StorageInputMessageFilter(context.RequestMessages).Concat(context.ResponseMessages ?? []).ToList();
var state = this._sessionState.GetOrInitializeState(context.Session);
var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
{
return;
@@ -473,11 +431,11 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
var state = this.GetOrInitializeState(session);
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
// Efficient count query
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
@@ -507,11 +465,11 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
var state = this.GetOrInitializeState(session);
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
// Batch delete for efficiency
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
@@ -95,11 +95,11 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
public string ContainerId => this._container.Id;
/// <inheritdoc />
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
if (string.IsNullOrWhiteSpace(runId))
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
@@ -110,28 +110,28 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
#pragma warning restore CA1513
var checkpointId = Guid.NewGuid().ToString("N");
var checkpointInfo = new CheckpointInfo(runId, checkpointId);
var checkpointInfo = new CheckpointInfo(sessionId, checkpointId);
var document = new CosmosCheckpointDocument
{
Id = $"{runId}_{checkpointId}",
RunId = runId,
Id = $"{sessionId}_{checkpointId}",
SessionId = sessionId,
CheckpointId = checkpointId,
Value = JToken.Parse(value.GetRawText()),
ParentCheckpointId = parent?.CheckpointId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
await this._container.CreateItemAsync(document, new PartitionKey(runId)).ConfigureAwait(false);
await this._container.CreateItemAsync(document, new PartitionKey(sessionId)).ConfigureAwait(false);
return checkpointInfo;
}
/// <inheritdoc />
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
if (string.IsNullOrWhiteSpace(runId))
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
if (key is null)
@@ -146,26 +146,26 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
#pragma warning restore CA1513
var id = $"{runId}_{key.CheckpointId}";
var id = $"{sessionId}_{key.CheckpointId}";
try
{
var response = await this._container.ReadItemAsync<CosmosCheckpointDocument>(id, new PartitionKey(runId)).ConfigureAwait(false);
var response = await this._container.ReadItemAsync<CosmosCheckpointDocument>(id, new PartitionKey(sessionId)).ConfigureAwait(false);
using var document = JsonDocument.Parse(response.Resource.Value.ToString());
return document.RootElement.Clone();
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for run '{runId}' not found.");
throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for session '{sessionId}' not found.");
}
}
/// <inheritdoc />
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
if (string.IsNullOrWhiteSpace(runId))
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
@@ -176,10 +176,10 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
#pragma warning restore CA1513
QueryDefinition query = withParent == null
? new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId ORDER BY c.timestamp ASC")
.WithParameter("@runId", runId)
: new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC")
.WithParameter("@runId", runId)
? new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId ORDER BY c.timestamp ASC")
.WithParameter("@sessionId", sessionId)
: new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC")
.WithParameter("@sessionId", sessionId)
.WithParameter("@parentCheckpointId", withParent.CheckpointId);
var iterator = this._container.GetItemQueryIterator<CheckpointQueryResult>(query);
@@ -188,7 +188,7 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
while (iterator.HasMoreResults)
{
var response = await iterator.ReadNextAsync().ConfigureAwait(false);
checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.RunId, r.CheckpointId)));
checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.SessionId, r.CheckpointId)));
}
return checkpoints;
@@ -223,8 +223,8 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
[JsonProperty("id")]
public string Id { get; set; } = string.Empty;
[JsonProperty("runId")]
public string RunId { get; set; } = string.Empty;
[JsonProperty("sessionId")]
public string SessionId { get; set; } = string.Empty;
[JsonProperty("checkpointId")]
public string CheckpointId { get; set; } = string.Empty;
@@ -245,7 +245,7 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB query deserialization")]
private sealed class CheckpointQueryResult
{
public string RunId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string CheckpointId { get; set; } = string.Empty;
}
}
@@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<IsPackable>false</IsPackable>
</PropertyGroup>
@@ -32,7 +32,7 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
var workflow = sp.GetKeyedService<Workflow>(keyAsStr);
if (workflow is not null)
{
return workflow.AsAgent(name: workflow.Name);
return workflow.AsAIAgent(name: workflow.Name);
}
// another thing we can do is resolve a non-keyed workflow.
@@ -41,7 +41,7 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
workflow = sp.GetService<Workflow>();
if (workflow is not null && workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
{
return workflow.AsAgent(name: workflow.Name);
return workflow.AsAIAgent(name: workflow.Name);
}
// and it's possible to lookup at the default-registered AIAgent
@@ -14,6 +14,7 @@
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
## v1.0.0-preview.251204.1
@@ -55,7 +55,7 @@ public sealed class DurableAIAgent : AIAgent
if (session is not DurableAgentSession durableSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(DurableAgentSession)}' can be serialized by this agent.");
}
return new(durableSession.Serialize(jsonSerializerOptions));
@@ -20,7 +20,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
if (session is not DurableAgentSession durableSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(DurableAgentSession)}' can be serialized by this agent.");
}
return new(durableSession.Serialize(jsonSerializerOptions));
@@ -4,8 +4,7 @@
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries -->
<!-- MEAI001: UserInputRequestContent is experimental but used in source-generated code for AgentResponse -->
<NoWarn>$(NoWarn);CA2007;MEAI001</NoWarn>
<NoWarn>$(NoWarn);CA2007</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Internal extension methods for <see cref="AIProjectClient"/> to provide MemoryStores helper operations.
/// </summary>
internal static class AIProjectClientExtensions
{
/// <summary>
/// Creates a memory store if it doesn't already exist.
/// </summary>
internal static async Task<bool> CreateMemoryStoreIfNotExistsAsync(
this AIProjectClient client,
string memoryStoreName,
string? description,
string chatModel,
string embeddingModel,
CancellationToken cancellationToken)
{
try
{
await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false);
return false; // Store already exists
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Store doesn't exist, create it
}
MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel);
await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false);
return true;
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Provides JSON serialization utilities for the Foundry Memory provider.
/// </summary>
internal static class FoundryMemoryJsonUtilities
{
/// <summary>
/// Gets the default JSON serializer options for Foundry Memory operations.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
TypeInfoResolver = FoundryMemoryJsonContext.Default
};
}
/// <summary>
/// Source-generated JSON serialization context for Foundry Memory types.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.General,
UseStringEnumConverter = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false)]
[JsonSerializable(typeof(FoundryMemoryProviderScope))]
[JsonSerializable(typeof(FoundryMemoryProvider.State))]
internal partial class FoundryMemoryJsonContext : JsonSerializerContext;
@@ -0,0 +1,440 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Provides an Azure AI Foundry Memory backed <see cref="AIContextProvider"/> that persists conversation messages as memories
/// and retrieves related memories to augment the agent invocation context.
/// </summary>
/// <remarks>
/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories
/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages
/// to the model, prefixed by a configurable context prompt.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryMemoryProvider : AIContextProvider
{
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
private readonly ProviderSessionState<State> _sessionState;
private readonly string _contextPrompt;
private readonly string _memoryStoreName;
private readonly int _maxMemories;
private readonly int _updateDelay;
private readonly bool _enableSensitiveTelemetryData;
private readonly AIProjectClient _client;
private readonly ILogger<FoundryMemoryProvider>? _logger;
private string? _lastPendingUpdateId;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProvider"/> class.
/// </summary>
/// <param name="client">The Azure AI Project client configured for your Foundry project.</param>
/// <param name="memoryStoreName">The name of the memory store in Azure AI Foundry.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="memoryStoreName"/> is null or whitespace.</exception>
public FoundryMemoryProvider(
AIProjectClient client,
string memoryStoreName,
Func<AgentSession?, State> stateInitializer,
FoundryMemoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
{
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
this._sessionState = new ProviderSessionState<State>(
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
options?.StateKey ?? this.GetType().Name,
FoundryMemoryJsonUtilities.DefaultOptions);
FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
this._logger = loggerFactory?.CreateLogger<FoundryMemoryProvider>();
this._client = client;
this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
this._memoryStoreName = memoryStoreName;
this._maxMemories = effectiveOptions.MaxMemories;
this._updateDelay = effectiveOptions.UpdateDelay;
this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
{
State state = stateInitializer(session);
if (state is null)
{
throw new InvalidOperationException("State initializer must return a non-null state.");
}
return state;
};
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
List<ResponseItem> messageItems = (context.AIContext.Messages ?? [])
.Where(m => !string.IsNullOrWhiteSpace(m.Text))
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
.ToList();
if (messageItems.Count == 0)
{
return new AIContext();
}
try
{
MemorySearchOptions searchOptions = new(scope.Scope)
{
ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories }
};
foreach (ResponseItem item in messageItems)
{
searchOptions.Items.Add(item);
}
ClientResult<MemoryStoreSearchResponse> result = await this._client.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName,
searchOptions,
cancellationToken).ConfigureAwait(false);
MemoryStoreSearchResponse response = result.Value;
List<string> memories = response.Memories
.Select(m => m.MemoryItem?.Content ?? string.Empty)
.Where(c => !string.IsNullOrWhiteSpace(c))
.ToList();
string? outputMessageText = memories.Count == 0
? null
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
memories.Count,
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace))
{
this._logger.LogTrace(
"FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this.SanitizeLogData(outputMessageText),
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
};
}
catch (ArgumentException)
{
throw;
}
catch (Exception ex)
{
if (this._logger?.IsEnabled(LogLevel.Error) is true)
{
this._logger.LogError(
ex,
"FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
return new AIContext();
}
}
/// <inheritdoc />
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
try
{
List<ResponseItem> messageItems = context.RequestMessages
.Concat(context.ResponseMessages ?? [])
.Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text))
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
.ToList();
if (messageItems.Count == 0)
{
return;
}
MemoryUpdateOptions updateOptions = new(scope.Scope)
{
UpdateDelay = this._updateDelay
};
foreach (ResponseItem item in messageItems)
{
updateOptions.Items.Add(item);
}
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.UpdateMemoriesAsync(
this._memoryStoreName,
updateOptions,
cancellationToken).ConfigureAwait(false);
MemoryUpdateResult response = result.Value;
if (response.UpdateId is not null)
{
Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId);
}
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.",
messageItems.Count,
this._memoryStoreName,
this.SanitizeLogData(scope.Scope),
response.UpdateId);
}
}
catch (Exception ex)
{
if (this._logger?.IsEnabled(LogLevel.Error) is true)
{
this._logger.LogError(
ex,
"FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
}
/// <summary>
/// Ensures all stored memories for the configured scope are deleted.
/// This method handles cases where the scope doesn't exist (no memories stored yet).
/// </summary>
/// <param name="session">The session containing the scope state to clear memories for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default)
{
Throw.IfNull(session);
State state = this._sessionState.GetOrInitializeState(session);
FoundryMemoryProviderScope scope = state.Scope;
try
{
await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false);
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Scope doesn't exist (no memories stored yet), nothing to delete
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
}
/// <summary>
/// Ensures the memory store exists, creating it if necessary.
/// </summary>
/// <param name="chatModel">The deployment name of the chat model for memory processing.</param>
/// <param name="embeddingModel">The deployment name of the embedding model for memory search.</param>
/// <param name="description">Optional description for the memory store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task EnsureMemoryStoreCreatedAsync(
string chatModel,
string embeddingModel,
string? description = null,
CancellationToken cancellationToken = default)
{
bool created = await this._client.CreateMemoryStoreIfNotExistsAsync(
this._memoryStoreName,
description,
chatModel,
embeddingModel,
cancellationToken).ConfigureAwait(false);
if (created)
{
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.",
this._memoryStoreName);
}
}
else
{
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.",
this._memoryStoreName);
}
}
}
/// <summary>
/// Waits for all pending memory update operations to complete.
/// </summary>
/// <remarks>
/// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update
/// and returns when it has completed, failed, or been superseded. Since updates are processed in order,
/// completion of the latest update implies all prior updates have also been processed.
/// </remarks>
/// <param name="pollingInterval">The interval between status checks. Defaults to 5 seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="InvalidOperationException">Thrown if the update operation failed.</exception>
public async Task WhenUpdatesCompletedAsync(
TimeSpan? pollingInterval = null,
CancellationToken cancellationToken = default)
{
string? updateId = Volatile.Read(ref this._lastPendingUpdateId);
if (updateId is null)
{
return;
}
TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5);
await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false);
// Only clear the pending update ID after successful completion
Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId);
}
private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.GetUpdateResultAsync(
this._memoryStoreName,
updateId,
cancellationToken).ConfigureAwait(false);
MemoryUpdateResult response = result.Value;
MemoryStoreUpdateStatus status = response.Status;
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: Update status for '{UpdateId}': {Status}",
updateId,
status);
}
if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded)
{
return;
}
if (status == MemoryStoreUpdateStatus.Failed)
{
throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}");
}
if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress)
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'.");
}
}
}
private static MessageResponseItem ToResponseItem(ChatRole role, string text)
{
if (role == ChatRole.Assistant)
{
return ResponseItem.CreateAssistantMessageItem(text);
}
if (role == ChatRole.System)
{
return ResponseItem.CreateSystemMessageItem(text);
}
return ResponseItem.CreateUserMessageItem(text);
}
private static bool IsAllowedRole(ChatRole role) =>
role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System;
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
/// <summary>
/// Represents the state of a <see cref="FoundryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Initializes a new instance of the <see cref="State"/> class with the specified scope.
/// </summary>
/// <param name="scope">The scope to use for memory storage and retrieval.</param>
[JsonConstructor]
public State(FoundryMemoryProviderScope scope)
{
this.Scope = Throw.IfNull(scope);
}
/// <summary>
/// Gets the scope used for memory storage and retrieval.
/// </summary>
public FoundryMemoryProviderScope Scope { get; }
}
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Options for configuring the <see cref="FoundryMemoryProvider"/>.
/// </summary>
public sealed class FoundryMemoryProviderOptions
{
/// <summary>
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
/// </summary>
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
public string? ContextPrompt { get; set; }
/// <summary>
/// Gets or sets the maximum number of memories to retrieve during search.
/// </summary>
/// <value>Defaults to 5.</value>
public int MaxMemories { get; set; } = 5;
/// <summary>
/// Gets or sets the delay in seconds before memory updates are processed.
/// </summary>
/// <remarks>
/// Setting to 0 triggers updates immediately without waiting for inactivity.
/// Higher values allow the service to batch multiple updates together.
/// </remarks>
/// <value>Defaults to 0 (immediate).</value>
public int UpdateDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
/// </summary>
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnableSensitiveTelemetryData { get; set; }
/// <summary>
/// Gets or sets the key used to store the provider state in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
/// <value>Defaults to the provider's type name.</value>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when building the search text to use when
/// searching for relevant memories during <see cref="AIContextProvider.InvokingAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? SearchInputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when determining which messages to
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Allows scoping of memories for the <see cref="FoundryMemoryProvider"/>.
/// </summary>
/// <remarks>
/// Azure AI Foundry memories are scoped by a single string identifier that you control.
/// Common patterns include using a user ID, team ID, or other unique identifier
/// to partition memories across different contexts.
/// </remarks>
public sealed class FoundryMemoryProviderScope
{
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProviderScope"/> class with the specified scope identifier.
/// </summary>
/// <param name="scope">The scope identifier used to partition memories. Must not be null or whitespace.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="scope"/> is null or whitespace.</exception>
public FoundryMemoryProviderScope(string scope)
{
Throw.IfNullOrWhitespace(scope);
this.Scope = scope;
}
/// <summary>
/// Gets the scope identifier used to partition memories.
/// </summary>
/// <remarks>
/// This value controls how memory is partitioned in the memory store.
/// Each unique scope maintains its own isolated collection of memory items.
/// For example, use a user ID to ensure each user has their own individual memory.
/// </remarks>
public string Scope { get; }
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Azure AI Foundry Memory integration</Title>
<Description>Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.FoundryMemory.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -104,7 +104,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
if (session is not GitHubCopilotAgentSession typedSession)
{
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(GitHubCopilotAgentSession)}' can be serialized by this agent.");
}
return new(typedSession.Serialize(jsonSerializerOptions));
@@ -139,7 +139,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
if (session is not GitHubCopilotAgentSession typedSession)
{
throw new InvalidOperationException(
$"The provided session type {session.GetType()} is not compatible with the agent. Only GitHub Copilot agent created sessions are supported.");
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(GitHubCopilotAgentSession)}' can be used by this agent.");
}
// Ensure the client is started
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
@@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -43,6 +60,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -109,6 +141,37 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -144,10 +207,28 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
/// <summary>
@@ -160,6 +241,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -169,13 +261,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -198,6 +302,23 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -213,13 +334,31 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -8,6 +8,12 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
/// </summary>
public static class A2AHostingJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for A2A Hosting serialization.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
private static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions);
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context.
// AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken)
// are handled via its resolver, followed by the A2A SDK resolver for protocol types.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides context for a custom A2A run mode decision.
/// </summary>
public sealed class A2ARunDecisionContext
{
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
{
this.MessageSendParams = messageSendParams;
}
/// <summary>
/// Gets the parameters of the incoming A2A message that triggered this run.
/// </summary>
public MessageSendParams MessageSendParams { get; }
}
@@ -1,19 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
// Metadata key used to store continuation tokens for long-running background operations
// in the AgentTask.Metadata dictionary, persisted by the task store.
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
taskManager ??= new TaskManager();
taskManager.OnMessageReceived += OnMessageReceivedAsync;
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
// OnMessageReceived handles both message-only and task-based flows.
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
// so we consolidate all initial message handling here and return either
// an AgentMessage or AgentTask depending on the agent response.
// When the agent returns a ContinuationToken (long-running operation), a task is
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
// See https://github.com/a2aproject/a2a-dotnet/issues/275
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
// Task flow for subsequent updates and cancellations
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
return taskManager;
async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? null
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
var parts = response.Messages.ToParts();
return new AgentMessage
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = parts,
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
}
}
/// <summary>
@@ -74,15 +80,19 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore);
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
taskManager.OnAgentCardQuery += (context, query) =>
{
@@ -97,4 +107,203 @@ public static class AIAgentExtensions
};
return taskManager;
}
private static async Task<A2AResponse> OnMessageReceivedAsync(
MessageSendParams messageSendParams,
AIHostAgent hostAgent,
AgentRunMode runMode,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
}
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(messageSendParams);
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
return CreateMessageFromResponse(contextId, response);
}
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static async Task OnTaskUpdatedAsync(
AgentTask agentTask,
AIHostAgent hostAgent,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
try
{
// Discard any stale continuation token — the incoming user message supersedes
// any previous background operation. AF agents don't support updating existing
// background responses (long-running operations); we start a fresh run from the
// existing session using the full chat history (which includes the new message).
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await hostAgent.RunAsync(
ExtractChatMessagesFromTaskHistory(agentTask),
session: session,
options: new AgentRunOptions { AllowBackgroundResponses = true },
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is not null)
{
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
}
else
{
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
await taskManager.UpdateStatusAsync(
agentTask.Id,
TaskState.Failed,
final: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
throw;
}
}
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
{
// Remove the continuation token from metadata if present.
// The task has already been marked as cancelled by the TaskManager.
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
return Task.CompletedTask;
}
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
// Task outputs should be returned as artifacts rather than messages:
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
new()
{
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static async Task<AgentTask> InitializeTaskAsync(
string contextId,
AgentMessage originalMessage,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
// Add the original user message to the task history.
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
agentTask.History ??= [];
agentTask.History.Add(originalMessage);
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static void StoreContinuationToken(
AgentTask agentTask,
ResponseContinuationToken token,
JsonSerializerOptions continuationTokenJsonOptions)
{
// Serialize the continuation token into the task's metadata so it survives
// across requests and is cleaned up with the task itself.
agentTask.Metadata ??= [];
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
token,
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
private static async Task TransitionToWorkingAsync(
string taskId,
string contextId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
// Include any intermediate progress messages from the response as a status message.
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task CompleteWithArtifactAsync(
string taskId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
var artifact = CreateArtifactFromResponse(response);
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
{
if (agentTask.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable<AgentRunMode>
{
private const string MessageValue = "message";
private const string TaskValue = "task";
private const string DynamicValue = "dynamic";
private readonly string _value;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
{
this._value = value;
this._runInBackground = runInBackground;
}
/// <summary>
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
/// <summary>
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
/// </summary>
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
/// <summary>
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
/// message and returns a boolean specifying whether to run the agent in background mode.
/// <see langword="true"/> indicates that the agent should run in background mode and return an
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
/// non-background mode and return an <c>AgentMessage</c>.
/// </summary>
/// <param name="runInBackground">
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
/// </param>
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
{
ArgumentNullException.ThrowIfNull(runInBackground);
return new(DynamicValue, runInBackground);
}
/// <summary>
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
/// </summary>
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(false);
}
if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(true);
}
// Dynamic: delegate to custom callback.
if (this._runInBackground is not null)
{
return this._runInBackground(context, cancellationToken);
}
// No delegate provided — fall back to "message" behavior.
return ValueTask.FromResult(true);
}
/// <inheritdoc/>
public bool Equals(AgentRunMode? other) =>
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
/// <inheritdoc/>
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
/// <inheritdoc/>
public override string ToString() => this._value;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are equal.</summary>
public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
left?.Equals(right) ?? right is null;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are not equal.</summary>
public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
!(left == right);
}
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Text.Json;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -37,7 +36,7 @@ internal static class AdditionalPropertiesDictionaryExtensions
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
@@ -10,6 +10,8 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);OPENAI001;MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<RootNamespace>Microsoft.Agents.AI.Hosting.OpenAI</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
@@ -30,6 +30,6 @@ public static class HostedWorkflowBuilderExtensions
var agentName = name ?? workflowName;
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAgent(name: key));
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
}
}
@@ -14,7 +14,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Mem0;
/// <summary>
/// Provides a Mem0 backed <see cref="AIContextProvider"/> that persists conversation messages as memories
/// Provides a Mem0 backed <see cref="MessageAIContextProvider"/> that persists conversation messages as memories
/// and retrieves related memories to augment the agent invocation context.
/// </summary>
/// <remarks>
@@ -22,19 +22,13 @@ namespace Microsoft.Agents.AI.Mem0;
/// for new invocations using a semantic search endpoint. Retrieved memories are injected as user messages
/// to the model, prefixed by a configurable context prompt.
/// </remarks>
public sealed class Mem0Provider : AIContextProvider
public sealed class Mem0Provider : MessageAIContextProvider
{
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
private static IEnumerable<ChatMessage> DefaultExternalOnlyFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
private readonly ProviderSessionState<State> _sessionState;
private readonly string _contextPrompt;
private readonly bool _enableSensitiveTelemetryData;
private readonly string _stateKey;
private readonly Func<AgentSession?, State> _stateInitializer;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _searchInputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storageInputMessageFilter;
private readonly Mem0Client _client;
private readonly ILogger<Mem0Provider>? _logger;
@@ -58,70 +52,56 @@ public sealed class Mem0Provider : AIContextProvider
/// </code>
/// </remarks>
public Mem0Provider(HttpClient httpClient, Func<AgentSession?, State> stateInitializer, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
options?.StateKey ?? this.GetType().Name,
Mem0JsonUtilities.DefaultOptions);
Throw.IfNull(httpClient);
if (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
{
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
}
this._stateInitializer = Throw.IfNull(stateInitializer);
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
this._client = new Mem0Client(httpClient);
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false;
this._stateKey = options?.StateKey ?? base.StateKey;
this._searchInputMessageFilter = options?.SearchInputMessageFilter ?? DefaultExternalOnlyFilter;
this._storageInputMessageFilter = options?.StorageInputMessageFilter ?? DefaultExternalOnlyFilter;
}
/// <inheritdoc />
public override string StateKey => this._stateKey;
public override string StateKey => this._sessionState.StateKey;
/// <summary>
/// Gets the state from the session's StateBag, or initializes it using the StateInitializer if not present.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <returns>The provider state, or null if no session is available.</returns>
private State? GetOrInitializeState(AgentSession? session)
{
if (session?.StateBag.TryGetValue<State>(this._stateKey, out var state, Mem0JsonUtilities.DefaultOptions) is true && state is not null)
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
{
var state = stateInitializer(session);
if (state is null
|| state.StorageScope is null
|| (state.StorageScope.AgentId is null && state.StorageScope.ThreadId is null && state.StorageScope.UserId is null && state.StorageScope.ApplicationId is null)
|| state.SearchScope is null
|| (state.SearchScope.AgentId is null && state.SearchScope.ThreadId is null && state.SearchScope.UserId is null && state.SearchScope.ApplicationId is null))
{
throw new InvalidOperationException("State initializer must return a non-null state with valid storage and search scopes, where at least one scoping parameter is set for each.");
}
return state;
}
state = this._stateInitializer(session);
if (state is null
|| state.StorageScope is null
|| (state.StorageScope.AgentId is null && state.StorageScope.ThreadId is null && state.StorageScope.UserId is null && state.StorageScope.ApplicationId is null)
|| state.SearchScope is null
|| (state.SearchScope.AgentId is null && state.SearchScope.ThreadId is null && state.SearchScope.UserId is null && state.SearchScope.ApplicationId is null))
{
throw new InvalidOperationException("State initializer must return a non-null state with valid storage and search scopes, where at lest one scoping parameter is set for each.");
}
if (session is not null)
{
session.StateBag.SetValue(this._stateKey, state, Mem0JsonUtilities.DefaultOptions);
}
return state;
}
};
/// <inheritdoc />
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
var inputContext = context.AIContext;
var state = this.GetOrInitializeState(context.Session);
var searchScope = state?.SearchScope ?? new Mem0ProviderScope();
var state = this._sessionState.GetOrInitializeState(context.Session);
var searchScope = state.SearchScope;
string queryText = string.Join(
Environment.NewLine,
this._searchInputMessageFilter(inputContext.Messages ?? [])
context.RequestMessages
.Where(m => !string.IsNullOrWhiteSpace(m.Text))
.Select(m => m.Text));
@@ -138,9 +118,6 @@ public sealed class Mem0Provider : AIContextProvider
var outputMessageText = memories.Count == 0
? null
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
var outputMessage = memories.Count == 0
? null
: new ChatMessage(ChatRole.User, outputMessageText!).WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!);
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
@@ -165,14 +142,9 @@ public sealed class Mem0Provider : AIContextProvider
}
}
return new AIContext
{
Instructions = inputContext.Instructions,
Messages =
(inputContext.Messages ?? [])
.Concat(outputMessage is not null ? [outputMessage] : []),
Tools = inputContext.Tools
};
return outputMessageText is not null
? [new ChatMessage(ChatRole.User, outputMessageText)]
: [];
}
catch (ArgumentException)
{
@@ -190,27 +162,23 @@ public sealed class Mem0Provider : AIContextProvider
searchScope.ThreadId,
this.SanitizeLogData(searchScope.UserId));
}
return inputContext;
return [];
}
}
/// <inheritdoc />
protected override async ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
{
return; // Do not update memory on failed invocations.
}
var state = this.GetOrInitializeState(context.Session);
var storageScope = state?.StorageScope ?? new Mem0ProviderScope();
var state = this._sessionState.GetOrInitializeState(context.Session);
var storageScope = state.StorageScope;
try
{
// Persist request and response messages after invocation.
await this.PersistMessagesAsync(
storageScope,
this._storageInputMessageFilter(context.RequestMessages)
context.RequestMessages
.Concat(context.ResponseMessages ?? []),
cancellationToken).ConfigureAwait(false);
}
@@ -237,13 +205,8 @@ public sealed class Mem0Provider : AIContextProvider
public Task ClearStoredMemoriesAsync(AgentSession session, CancellationToken cancellationToken = default)
{
Throw.IfNull(session);
var state = this.GetOrInitializeState(session);
var storageScope = state?.StorageScope;
if (storageScope is null)
{
return Task.CompletedTask; // Nothing to clear if there is no state.
}
var state = this._sessionState.GetOrInitializeState(session);
var storageScope = state.StorageScope;
return this._client.ClearMemoryAsync(
storageScope.ApplicationId,
@@ -1,10 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.OpenAI;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class AsyncStreamingResponseUpdateCollectionResult : AsyncCollectionResult<StreamingResponseUpdate>
{
private readonly IAsyncEnumerable<AgentResponseUpdate> _updates;
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.OpenAI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Chat;
using OpenAI.Responses;
@@ -18,6 +20,7 @@ namespace Microsoft.Agents.AI;
/// The methods handle the conversion between OpenAI chat message types and Microsoft Extensions AI types,
/// and return OpenAI <see cref="ChatCompletion"/> objects directly from the agent's <see cref="AgentResponse"/>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIAgentWithOpenAIExtensions
{
/// <summary>
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Chat;
using OpenAI.Responses;
@@ -11,6 +13,7 @@ namespace Microsoft.Agents.AI;
/// Provides extension methods for <see cref="AgentResponse"/> and <see cref="AgentResponseUpdate"/> instances to
/// create or extract native OpenAI response objects from the Microsoft Agent Framework responses.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AgentResponseExtensions
{
/// <summary>
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace OpenAI.Assistants;
@@ -18,6 +20,7 @@ namespace OpenAI.Assistants;
/// The methods handle the conversion from OpenAI clients to <see cref="IChatClient"/> instances and then wrap them
/// in <see cref="ChatClientAgent"/> objects that implement the <see cref="AIAgent"/> interface.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIAssistants)]
public static class OpenAIAssistantClientExtensions
{
/// <summary>
@@ -1,8 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace OpenAI.Responses;
@@ -17,6 +19,7 @@ namespace OpenAI.Responses;
/// The methods handle the conversion from OpenAI clients to <see cref="IChatClient"/> instances and then wrap them
/// in <see cref="ChatClientAgent"/> objects that implement the <see cref="AIAgent"/> interface.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class OpenAIResponseClientExtensions
{
/// <summary>
@@ -89,4 +92,23 @@ public static class OpenAIResponseClientExtensions
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
/// <summary>
/// Gets an <see cref="IChatClient"/> for use with this <see cref="ResponsesClient"/> that does not store responses for later retrieval.
/// </summary>
/// <remarks>
/// This corresponds to setting the "store" property in the JSON representation to false.
/// </remarks>
/// <param name="responseClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ResponsesClient"/> that does not store responses for later retrieval.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient)
{
return Throw.IfNull(responseClient)
.AsIChatClient()
.AsBuilder()
.ConfigureOptions(x => x.RawRepresentationFactory = _ => new CreateResponseOptions() { StoredOutputEnabled = false })
.Build();
}
}
@@ -1,14 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);OPENAI001;</NoWarn>
<IsReleaseCandidate>true</IsReleaseCandidate>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>alpha</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
</PropertyGroup>
<PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
@@ -111,7 +111,9 @@ internal static class JsonDocumentExtensions
VariableType? currentType =
element.ValueKind switch
{
JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []),
JsonValueKind.Object => targetType.HasSchema
? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
: VariableType.RecordType,
JsonValueKind.String => typeof(string),
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
@@ -39,6 +39,14 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
this.Model = model;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return base.ConfigureProtocol(protocolBuilder)
// We chain to HandleAsync, so let the protocol know we have additional Send/Yield types that may not be
// available on the HandleAsync override.
.AddDelegateAttributeTypes(this.ExecuteAsync);
}
public DialogAction Model { get; }
public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); }
@@ -60,6 +68,7 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this.Model.Disabled)
@@ -4,6 +4,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Extensions.AI;
@@ -25,6 +26,7 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
return default;
}
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// No state to restore if we're starting from the beginning.
@@ -34,12 +34,28 @@ internal class DelegateActionExecutor<TMessage> : Executor<TMessage>, IResettabl
this._emitResult = emitResult;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
ProtocolBuilder baseBuilder = base.ConfigureProtocol(protocolBuilder);
if (this._emitResult)
{
baseBuilder.SendsMessage<TMessage>();
}
// We chain to the provided delegate, so let the protocol know we have additional Send/Yield types that may not be
// available on the HandleAsync override.
return (this._action != null) ? baseBuilder.AddDelegateAttributeTypes(this._action)
: baseBuilder;
}
/// <inheritdoc/>
public ValueTask ResetAsync()
{
return default;
}
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this._action is not null)
@@ -390,6 +390,27 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
protected override void Visit(InvokeFunctionTool item)
{
this.Trace(item);
// Entry point to invoke function tool - always yields for external execution
InvokeFunctionToolExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Define request-port for function tool invocation (always requires external input)
string externalInputPortId = InvokeFunctionToolExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, externalInputPortId);
// Capture response when external input is received
string resumeId = InvokeFunctionToolExecutor.Steps.Resume(action.Id);
this.ContinueWith(
new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync),
action.ParentId);
}
protected override void Visit(InvokeAzureResponse item)
{
this.NotSupported(item);
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
#region Not supported
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
protected override void Visit(DeleteActivity item) => this.NotSupported(item);
@@ -73,6 +73,7 @@ public abstract class ActionExecutor<TMessage> : Executor<TMessage>, IResettable
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._session.State), message, cancellationToken).ConfigureAwait(false);
@@ -54,6 +54,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -22,11 +22,12 @@ internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor<Conditi
{
return conditionItem.Id;
}
int index = model.Conditions.IndexOf(conditionItem);
return $"{model.Id}_Items{index}";
}
public static string Else(ConditionGroup model) => model.ElseActions.Id.Value ?? $"{model.Id}_Else";
public static string Else(ConditionGroup model) => model.ElseActions.Id.Value;
}
public ConditionGroupExecutor(ConditionGroup model, WorkflowFormulaState state)
@@ -17,6 +17,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeAzureAgent>(model, state)
{
@@ -0,0 +1,313 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
/// <summary>
/// Executor for the <see cref="InvokeFunctionTool"/> action.
/// This executor yields to the caller for function execution and resumes when results are provided.
/// </summary>
internal sealed class InvokeFunctionToolExecutor(
InvokeFunctionTool model,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
{
/// <summary>
/// Step identifiers for the function tool invocation workflow.
/// </summary>
public static class Steps
{
/// <summary>
/// Step for waiting for external input (function result).
/// </summary>
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
/// <summary>
/// Step for resuming after receiving function result.
/// </summary>
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
/// <inheritdoc/>
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
[SendsMessage(typeof(ExternalInputRequest))]
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string functionName = this.GetFunctionName();
bool requireApproval = this.GetRequireApproval();
Dictionary<string, object?>? arguments = this.GetArguments();
// Create the function call content to send to the caller
FunctionCallContent functionCall = new(
callId: this.Id,
name: functionName,
arguments: arguments);
// Build the response with the function call request
ChatMessage requestMessage = new(ChatRole.Tool, [functionCall]);
// If approval is required, add user input request content
if (requireApproval)
{
requestMessage.Contents.Add(new FunctionApprovalRequestContent(this.Id, functionCall));
}
AgentResponse agentResponse = new([requestMessage]);
// Yield to the caller - workflow halts here until external input is received
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
/// <summary>
/// Captures the function result and stores in output variables.
/// </summary>
/// <param name="context">The workflow context.</param>
/// <param name="response">The external input response containing the function result.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask CaptureResponseAsync(
IWorkflowContext context,
ExternalInputResponse response,
CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
// Extract function results from the response
IEnumerable<FunctionResultContent> functionResults = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>();
FunctionResultContent? matchingResult = functionResults
.FirstOrDefault(r => r.CallId == this.Id);
if (matchingResult is not null)
{
// Store the result in output variable
await this.AssignResultAsync(context, matchingResult).ConfigureAwait(false);
// Auto-send the result if configured
if (autoSend)
{
AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
}
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
// Note: We transform messages containing FunctionResultContent or FunctionCallContent
// to assistant text messages because workflow-generated CallIds don't correspond to
// actual AI-generated tool calls and would be rejected by the API.
if (conversationId is not null)
{
foreach (ChatMessage message in TransformConversationMessages(response.Messages))
{
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
}
}
// Completes the action after processing the function result.
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Transforms messages containing function-related content to assistant text messages.
/// Messages with FunctionResultContent are converted to assistant messages with the result as text.
/// Messages with only FunctionCallContent are excluded as they have no informational value.
/// </summary>
private static IEnumerable<ChatMessage> TransformConversationMessages(IEnumerable<ChatMessage> messages)
{
foreach (ChatMessage message in messages)
{
// Check if message contains function content
bool hasFunctionResult = message.Contents.OfType<FunctionResultContent>().Any();
bool hasFunctionCall = message.Contents.OfType<FunctionCallContent>().Any();
if (hasFunctionResult)
{
// Convert function results to assistant text message
List<AIContent> updatedContents = [];
foreach (AIContent content in message.Contents)
{
if (content is FunctionResultContent functionResult)
{
string? resultText = functionResult.Result?.ToString();
if (!string.IsNullOrEmpty(resultText))
{
updatedContents.Add(new TextContent($"[Function {functionResult.CallId} result]: {resultText}"));
}
}
else if (content is not FunctionCallContent)
{
// Keep non-function content as-is
updatedContents.Add(content);
}
}
if (updatedContents.Count > 0)
{
yield return new ChatMessage(ChatRole.Assistant, updatedContents);
}
}
else if (!hasFunctionCall)
{
// Pass through messages without function content
yield return message;
}
}
}
private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result)
{
if (this.Model.Output?.Result is null)
{
return;
}
object? resultValue = result.Result;
// Attempt to parse as JSON if it's a string
if (resultValue is string jsonString)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
await this.AssignAsync(this.Model.Output.Result?.Path, parsedValue.ToFormula(), context).ConfigureAwait(false);
return;
}
catch (JsonException)
{
// Not a valid JSON
}
}
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private string GetFunctionName() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.FunctionName,
$"{nameof(this.Model)}.{nameof(this.Model.FunctionName)}")).Value;
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
{
return null;
}
string conversationIdValue = this.Evaluator.GetValue(this.Model.ConversationId).Value;
return conversationIdValue.Length == 0 ? null : conversationIdValue;
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
{
return false;
}
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
}
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
{
return true;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private Dictionary<string, object?>? GetArguments()
{
if (this.Model.Arguments is null)
{
return null;
}
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
{
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
return result;
}
}
@@ -16,6 +16,8 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
[SendsMessage(typeof(ExternalInputResponse))]
internal sealed class QuestionExecutor(Question model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<Question>(model, state)
{
@@ -44,18 +46,17 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable);
bool hasValue = context.ReadState(variable.Path) is BlankValue;
bool alwaysPrompt = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value;
bool isValueUndefined = context.ReadState(variable.Path) is BlankValue;
bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value;
bool proceed = !alwaysPrompt || hasValue;
if (proceed)
if (!proceed)
{
SkipQuestionMode mode = this.Evaluator.GetValue(this.Model.SkipQuestionMode).Value;
proceed =
mode switch
{
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false),
SkipQuestionMode.AlwaysSkipIfVariableHasValue => hasValue,
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined && !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false),
SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined,
SkipQuestionMode.AlwaysAsk => true,
_ => true,
};
@@ -86,7 +87,7 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
FormulaValue? extractedValue = null;
if (!response.HasMessages)
{
string unrecognizedResponse = this.FormatPrompt(this.Model.UnrecognizedPrompt);
string unrecognizedResponse = this.Model.UnrecognizedPrompt is not null ? this.FormatPrompt(this.Model.UnrecognizedPrompt) : "Invalid response";
await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim()), cancellationToken).ConfigureAwait(false);
}
else
@@ -128,7 +129,7 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
}
}
await this.AssignAsync(this.Model.Variable?.Path, extractedValue, context).ConfigureAwait(false);
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false);
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -145,9 +146,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
int actualCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
if (actualCount >= repeatCount)
{
ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue);
DataValue defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value;
await this.AssignAsync(this.Model.Variable?.Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
DataValue defaultValue = DataValue.Blank();
if (this.Model.DefaultValue is not null)
{
ValueExpression defaultValueExpression = Throw.IfNull(this.Model.DefaultValue);
defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value;
}
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse);
await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
@@ -12,6 +12,8 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
[SendsMessage(typeof(ExternalInputResponse))]
internal sealed class RequestExternalInputExecutor(RequestExternalInput model, ResponseAgentProvider agentProvider, WorkflowFormulaState state)
: DeclarativeActionExecutor<RequestExternalInput>(model, state)
{
@@ -45,5 +47,7 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
}
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
}
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
bool configureProtocol = HasConfigureProtocolDefined(classSymbol);
// Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
@@ -78,7 +78,7 @@ internal static class SemanticAnalyzer
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
bool baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
ImmutableEquatableArray<string> classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
ImmutableEquatableArray<string> classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName);
@@ -96,8 +96,8 @@ internal static class SemanticAnalyzer
return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureRoutes, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, hasManualConfigureRoutes,
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, configureProtocol,
classLocation,
handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
@@ -152,7 +152,7 @@ internal static class SemanticAnalyzer
if (first.HasManualConfigureRoutes)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureRoutesAlreadyDefined,
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
@@ -175,7 +175,7 @@ internal static class SemanticAnalyzer
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
first.BaseHasConfigureRoutes,
first.BaseHasConfigureProtocol,
new ImmutableEquatableArray<HandlerInfo>(handlers),
first.ClassSendTypes,
first.ClassYieldTypes);
@@ -211,7 +211,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
@@ -240,7 +240,7 @@ internal static class SemanticAnalyzer
containingTypeChain,
isPartialClass,
derivesFromExecutor,
hasManualConfigureRoutes,
hasManualConfigureProtocol,
classLocation,
typeName,
attributeKind));
@@ -251,12 +251,16 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have protocol attributes
/// (no [MessageHandler] methods). This generates only ConfigureSentTypes/ConfigureYieldTypes overrides.
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol
/// configuration.
/// </summary>
/// <remarks>
/// This is likely to be seen combined with the basic one-method <c>Executor%lt;TIn&gt;</c> or <c>Executor&lt;TIn, TOut&gt;</c>
/// </remarks>
/// <param name="protocolInfos">The protocol info entries for the class.</param>
/// <returns>The combined analysis result.</returns>
public static AnalysisResult CombineProtocolOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
public static AnalysisResult CombineOutputOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
{
List<ClassProtocolInfo> protocols = protocolInfos.ToList();
if (protocols.Count == 0)
@@ -317,7 +321,7 @@ internal static class SemanticAnalyzer
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
BaseHasConfigureRoutes: false, // Not relevant for protocol-only
BaseHasConfigureProtocol: false, // Not relevant for protocol-only
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
@@ -394,12 +398,12 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Checks if this class directly defines ConfigureRoutes (not inherited).
/// Checks if this class directly defines ConfigureProtocol (not inherited).
/// If so, we skip generation to avoid conflicting with user's manual implementation.
/// </summary>
private static bool HasConfigureRoutesDefined(INamedTypeSymbol classSymbol)
private static bool HasConfigureProtocolDefined(INamedTypeSymbol classSymbol)
{
foreach (var member in classSymbol.GetMembers("ConfigureRoutes"))
foreach (var member in classSymbol.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract &&
SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol))
@@ -412,22 +416,22 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Checks if any base class (between this class and Executor) defines ConfigureRoutes.
/// If so, generated code should call base.ConfigureRoutes() to preserve inherited handlers.
/// Checks if any base class (between this class and Executor) defines ConfigureProtocol.
/// If so, generated code should call base.ConfigureProtocol() to preserve inherited handlers.
/// </summary>
private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol)
private static bool BaseHasConfigureProtocol(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? baseType = classSymbol.BaseType;
while (baseType != null)
{
string fullName = baseType.OriginalDefinition.ToDisplayString();
// Stop at Executor - its ConfigureRoutes is abstract/empty
// Stop at Executor - its ConfigureProtocol is abstract/empty
if (fullName == ExecutorTypeName)
{
return false;
}
foreach (var member in baseType.GetMembers("ConfigureRoutes"))
foreach (var member in baseType.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract)
{
@@ -86,10 +86,10 @@ internal static class DiagnosticDescriptors
/// <summary>
/// MAFGENWF006: ConfigureRoutes already defined.
/// </summary>
public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new(
public static readonly DiagnosticDescriptor ConfigureProtocolAlreadyDefined = Register(new(
id: "MAFGENWF006",
title: "ConfigureRoutes already defined",
messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored",
title: "ConfigureProtocol already defined",
messageFormat: "Class '{0}' already defines ConfigureProtocol; [MessageHandler] methods will be ignored",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true));
@@ -120,7 +120,7 @@ public sealed class ExecutorRouteGenerator : IIncrementalGenerator
{
if (!processedClasses.Contains(kvp.Key))
{
yield return SemanticAnalyzer.CombineProtocolOnlyResults(kvp.Value);
yield return SemanticAnalyzer.CombineOutputOnlyResults(kvp.Value);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Agents.AI.Workflows.Generators.Models;
@@ -16,6 +17,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Generation;
/// </remarks>
internal static class SourceBuilder
{
internal const string IndentUnit = " ";
/// <summary>
/// Generates the complete source file for an executor's generated partial class.
/// </summary>
@@ -53,7 +56,8 @@ internal static class SourceBuilder
{
sb.AppendLine($"{indent}partial class {containingType}");
sb.AppendLine($"{indent}{{");
indent += " ";
indent += IndentUnit;
}
}
@@ -61,30 +65,49 @@ internal static class SourceBuilder
sb.AppendLine($"{indent}partial class {info.ClassName}{info.GenericParameters}");
sb.AppendLine($"{indent}{{");
string memberIndent = indent + " ";
bool hasContent = false;
string memberIndent = indent + IndentUnit;
// Only generate ConfigureRoutes if there are handlers
if (info.Handlers.Count > 0)
// ConfigureProtocol
sb.AppendLine($"{memberIndent}protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)");
sb.AppendLine($"{memberIndent}{{");
string bodyIndent = memberIndent + IndentUnit;
if (info.BaseHasConfigureProtocol)
{
GenerateConfigureRoutes(sb, info, memberIndent);
hasContent = true;
sb.Append($"{bodyIndent}return base.ConfigureProtocol(protocolBuilder)");
bodyIndent += " ";
}
else
{
sb.Append($"{bodyIndent}return protocolBuilder");
}
// Only generate protocol overrides if [SendsMessage] or [YieldsOutput] attributes are present.
// Without these attributes, we rely on the base class defaults.
if (info.ShouldGenerateProtocolOverrides)
if (info.ShouldGenerateSentMessageRegistrations)
{
if (hasContent)
{
sb.AppendLine();
}
GenerateConfigureSentTypes(sb, info, memberIndent);
sb.AppendLine();
GenerateConfigureYieldTypes(sb, info, memberIndent);
GenerateConfigureSentTypes(sb, info, bodyIndent);
}
if (info.ShouldGenerateYieldedOutputRegistrations)
{
GenerateConfigureYieldTypes(sb, info, bodyIndent);
}
// Only generate ConfigureRoutes if there are handlers
if (info.Handlers.Count > 0)
{
GenerateConfigureRoutes(sb, info, bodyIndent);
}
else
{
sb.AppendLine(";");
}
// Close ConfigureProtocol
sb.AppendLine($"{memberIndent}}}");
// Close class
sb.AppendLine($"{indent}}}");
@@ -107,24 +130,19 @@ internal static class SourceBuilder
/// </summary>
private static void GenerateConfigureRoutes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)");
sb.AppendLine(".ConfigureRoutes(ConfigureRoutes);");
sb.AppendLine($"{indent}void ConfigureRoutes(RouteBuilder routeBuilder)");
sb.AppendLine($"{indent}{{");
string bodyIndent = indent + " ";
// If a base class has its own ConfigureRoutes, chain to it first to preserve inherited handlers.
if (info.BaseHasConfigureRoutes)
{
sb.AppendLine($"{bodyIndent}routeBuilder = base.ConfigureRoutes(routeBuilder);");
sb.AppendLine();
}
string bodyIndent = indent + IndentUnit;
// Generate handler registrations using fluent AddHandler calls.
// RouteBuilder.AddHandler<TIn> registers a void handler; AddHandler<TIn, TOut> registers one with a return value.
if (info.Handlers.Count == 1)
{
HandlerInfo handler = info.Handlers[0];
sb.AppendLine($"{bodyIndent}return routeBuilder");
sb.AppendLine($"{bodyIndent}routeBuilder");
sb.Append($"{bodyIndent} .AddHandler");
AppendHandlerGenericArgs(sb, handler);
sb.AppendLine($"(this.{handler.MethodName});");
@@ -132,7 +150,7 @@ internal static class SourceBuilder
else
{
// Multiple handlers: chain fluent calls, semicolon only on the last one.
sb.AppendLine($"{bodyIndent}return routeBuilder");
sb.AppendLine($"{bodyIndent}routeBuilder");
for (int i = 0; i < info.Handlers.Count; i++)
{
@@ -178,28 +196,24 @@ internal static class SourceBuilder
/// </remarks>
private static void GenerateConfigureSentTypes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override ISet<Type> ConfigureSentTypes()");
sb.AppendLine($"{indent}{{");
// Track types to avoid emitting duplicate Add calls (the set handles runtime dedup,
// but cleaner generated code is easier to read).
var addedTypes = new HashSet<string>();
string bodyIndent = indent + " ";
sb.AppendLine($"{bodyIndent}var types = base.ConfigureSentTypes();");
foreach (var type in info.ClassSendTypes)
foreach (var type in info.ClassSendTypes.Where(type => addedTypes.Add(type)))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
sb.AppendLine($".SendsMessage<{type}>()");
sb.Append(indent);
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.SendTypes)
foreach (var type in handler.SendTypes.Where(type => addedTypes.Add(type)))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
sb.AppendLine($".SendsMessage<{type}>()");
sb.Append(indent);
}
}
sb.AppendLine($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
/// <summary>
@@ -211,43 +225,23 @@ internal static class SourceBuilder
/// </remarks>
private static void GenerateConfigureYieldTypes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override ISet<Type> ConfigureYieldTypes()");
sb.AppendLine($"{indent}{{");
string bodyIndent = indent + " ";
sb.AppendLine($"{bodyIndent}var types = base.ConfigureYieldTypes();");
// Track types to avoid emitting duplicate Add calls (the set handles runtime dedup,
// but cleaner generated code is easier to read).
var addedTypes = new HashSet<string>();
foreach (var type in info.ClassYieldTypes)
foreach (var type in info.ClassYieldTypes.Where(type => addedTypes.Add(type)))
{
if (addedTypes.Add(type))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
sb.AppendLine($".YieldsOutput<{type}>()");
sb.Append(indent);
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.YieldTypes)
foreach (var type in handler.YieldTypes.Where(type => addedTypes.Add(type)))
{
if (addedTypes.Add(type))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
}
// Handler return types (ValueTask<T>) are implicitly yielded.
if (handler.HasOutput && handler.OutputTypeName != null && addedTypes.Add(handler.OutputTypeName))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({handler.OutputTypeName}));");
sb.AppendLine($".YieldsOutput<{type}>()");
sb.Append(indent);
}
}
sb.AppendLine($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
}
@@ -29,7 +29,7 @@
</PropertyGroup>
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <param name="GenericParameters">The generic type parameters of the class (e.g., "&lt;T, U&gt;"), or null if not generic.</param>
/// <param name="IsNested">Whether the class is nested inside another class.</param>
/// <param name="ContainingTypeChain">The chain of containing types for nested classes (e.g., "OuterClass.InnerClass"). Empty string if not nested.</param>
/// <param name="BaseHasConfigureRoutes">Whether the base class has a ConfigureRoutes method that should be called.</param>
/// <param name="BaseHasConfigureProtocol">Whether the base class has a ConfigureRoutes method that should be called.</param>
/// <param name="Handlers">The list of handler methods to register.</param>
/// <param name="ClassSendTypes">The types declared via class-level [SendsMessage] attributes.</param>
/// <param name="ClassYieldTypes">The types declared via class-level [YieldsOutput] attributes.</param>
@@ -21,19 +21,20 @@ internal sealed record ExecutorInfo(
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
bool BaseHasConfigureProtocol,
ImmutableEquatableArray<HandlerInfo> Handlers,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes)
{
/// <summary>
/// Gets whether any protocol type overrides should be generated.
/// Gets whether any "Sent" message type registrations should be generated.
/// </summary>
public bool ShouldGenerateProtocolOverrides =>
!this.ClassSendTypes.IsEmpty ||
!this.ClassYieldTypes.IsEmpty ||
this.HasHandlerWithSendTypes ||
this.HasHandlerWithYieldTypes;
public bool ShouldGenerateSentMessageRegistrations => !this.ClassSendTypes.IsEmpty || this.HasHandlerWithSendTypes;
/// <summary>
/// Gets whether any "Yielded" output type registrations should be generated.
/// </summary>
public bool ShouldGenerateYieldedOutputRegistrations => !this.ClassYieldTypes.IsEmpty || this.HasHandlerWithYieldTypes;
/// <summary>
/// Gets whether any handler has explicit Send types.
@@ -22,7 +22,7 @@ internal sealed record MethodAnalysisResult(
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
bool BaseHasConfigureProtocol,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes,
@@ -7,14 +7,14 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents an event triggered when an agent produces a response.
/// </summary>
public class AgentResponseEvent : ExecutorEvent
public sealed class AgentResponseEvent : WorkflowOutputEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
public AgentResponseEvent(string executorId, AgentResponse response) : base(executorId, data: response)
public AgentResponseEvent(string executorId, AgentResponse response) : base(response, executorId)
{
this.Response = Throw.IfNull(response);
}
@@ -8,14 +8,14 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents an event triggered when an agent run produces an update.
/// </summary>
public class AgentResponseUpdateEvent : ExecutorEvent
public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(executorId, data: update)
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(update, executorId)
{
this.Update = Throw.IfNull(update);
}
@@ -135,7 +135,7 @@ public static partial class AgentWorkflowBuilder
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInEdge(accumulators, end);
builder.AddFanInBarrierEdge(accumulators, end);
builder = builder.WithOutputFrom(end);
if (workflowName is not null)
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class SendsMessageAttribute : Attribute
{
/// <summary>
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class YieldsOutputAttribute : Attribute
{
/// <summary>
@@ -34,19 +34,29 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ChatMessage>()
.SendsMessage<List<ChatMessage>>()
.SendsMessage<ChatMessage[]>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
// remove this once we internalize the typecheck logic
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
//.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
}
}
private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
@@ -26,7 +26,7 @@ public static class ChatProtocolExtensions
/// langword="false"/>.</returns>
public static bool IsChatProtocol(this ProtocolDescriptor descriptor, bool allowCatchAll = false)
{
bool foundListChatMessageInput = false;
bool foundIEnumerableChatMessageInput = false;
bool foundTurnTokenInput = false;
if (allowCatchAll && descriptor.AcceptsAll)
@@ -40,9 +40,9 @@ public static class ChatProtocolExtensions
// output type.
foreach (Type inputType in descriptor.Accepts)
{
if (inputType == typeof(List<ChatMessage>))
if (inputType == typeof(IEnumerable<ChatMessage>))
{
foundListChatMessageInput = true;
foundIEnumerableChatMessageInput = true;
}
else if (inputType == typeof(TurnToken))
{
@@ -50,7 +50,7 @@ public static class ChatProtocolExtensions
}
}
return foundListChatMessageInput && foundTurnTokenInput;
return foundIEnumerableChatMessageInput && foundTurnTokenInput;
}
/// <summary>
@@ -67,19 +67,26 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<List<ChatMessage>>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
//.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
}
}
/// <summary>
@@ -7,14 +7,14 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a checkpoint with a unique identifier and a timestamp indicating when it was created.
/// Represents a checkpoint with a unique identifier.
/// </summary>
public sealed class CheckpointInfo : IEquatable<CheckpointInfo>
{
/// <summary>
/// Gets the unique identifier for the current run.
/// Gets the unique identifier for the current session.
/// </summary>
public string RunId { get; }
public string SessionId { get; }
/// <summary>
/// The unique identifier for the checkpoint.
@@ -22,37 +22,34 @@ public sealed class CheckpointInfo : IEquatable<CheckpointInfo>
public string CheckpointId { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CheckpointInfo"/> class with a unique identifier and the current
/// UTC timestamp.
/// Initializes a new instance of the <see cref="CheckpointInfo"/> class with a unique identifier.
/// </summary>
/// <remarks>This constructor generates a new unique identifier using a GUID in a 32-character, lowercase,
/// hexadecimal format and sets the timestamp to the current UTC time.</remarks>
internal CheckpointInfo(string runId) : this(runId, Guid.NewGuid().ToString("N")) { }
internal CheckpointInfo(string sessionId) : this(sessionId, Guid.NewGuid().ToString("N")) { }
/// <summary>
/// Initializes a new instance of the CheckpointInfo class with the specified run and checkpoint identifiers.
/// Initializes a new instance of the CheckpointInfo class with the specified session and checkpoint identifiers.
/// </summary>
/// <param name="runId">The unique identifier for the run. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier for the session. Cannot be null or empty.</param>
/// <param name="checkpointId">The unique identifier for the checkpoint. Cannot be null or empty.</param>
[JsonConstructor]
public CheckpointInfo(string runId, string checkpointId)
public CheckpointInfo(string sessionId, string checkpointId)
{
this.RunId = Throw.IfNullOrEmpty(runId);
this.SessionId = Throw.IfNullOrEmpty(sessionId);
this.CheckpointId = Throw.IfNullOrEmpty(checkpointId);
}
/// <inheritdoc/>
public bool Equals(CheckpointInfo? other) =>
other is not null &&
this.RunId == other.RunId &&
this.SessionId == other.SessionId &&
this.CheckpointId == other.CheckpointId;
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as CheckpointInfo);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.RunId, this.CheckpointId);
public override int GetHashCode() => HashCode.Combine(this.SessionId, this.CheckpointId);
/// <inheritdoc/>
public override string ToString() => $"CheckpointInfo(RunId: {this.RunId}, CheckpointId: {this.CheckpointId})";
public override string ToString() => $"CheckpointInfo(SessionId: {this.SessionId}, CheckpointId: {this.CheckpointId})";
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -49,9 +50,12 @@ public sealed class CheckpointManager : ICheckpointManager
return new(CreateImpl(marshaller, store));
}
ValueTask<CheckpointInfo> ICheckpointManager.CommitCheckpointAsync(string runId, Checkpoint checkpoint)
=> this._impl.CommitCheckpointAsync(runId, checkpoint);
ValueTask<CheckpointInfo> ICheckpointManager.CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
=> this._impl.CommitCheckpointAsync(sessionId, checkpoint);
ValueTask<Checkpoint> ICheckpointManager.LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
=> this._impl.LookupCheckpointAsync(runId, checkpointInfo);
ValueTask<Checkpoint> ICheckpointManager.LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
=> this._impl.LookupCheckpointAsync(sessionId, checkpointInfo);
ValueTask<IEnumerable<CheckpointInfo>> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent)
=> this._impl.RetrieveIndexAsync(sessionId, withParent);
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a base object for a workflow run that may support checkpointing.
/// </summary>
public abstract class CheckpointableRunBase
{
// TODO: Rename Context?
private readonly ICheckpointingHandle _checkpointingHandle;
internal CheckpointableRunBase(ICheckpointingHandle checkpointingHandle)
{
this._checkpointingHandle = checkpointingHandle;
}
/// <inheritdoc cref="ICheckpointingHandle.IsCheckpointingEnabled"/>
public bool IsCheckpointingEnabled => this._checkpointingHandle.IsCheckpointingEnabled;
/// <inheritdoc cref="ICheckpointingHandle.Checkpoints"/>
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpointingHandle.Checkpoints ?? [];
/// <summary>
/// Gets the most recent checkpoint information.
/// </summary>
public CheckpointInfo? LastCheckpoint
{
get
{
if (!this.IsCheckpointingEnabled)
{
return null;
}
var checkpoints = this.Checkpoints;
return checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null;
}
}
/// <inheritdoc cref="ICheckpointingHandle.RestoreCheckpointAsync"/>
public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
=> this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a workflow run that supports checkpointing.
/// </summary>
/// <typeparam name="TRun">The type of the underlying workflow run handle.</typeparam>
/// <seealso cref="Run"/>
/// <seealso cref="StreamingRun"/>
public sealed class Checkpointed<TRun> : IAsyncDisposable
{
private readonly ICheckpointingHandle _runner;
internal Checkpointed(TRun run, ICheckpointingHandle runner)
{
this.Run = Throw.IfNull(run);
this._runner = Throw.IfNull(runner);
}
/// <summary>
/// Gets the workflow run associated with this <see cref="Checkpointed{TRun}"/> instance.
/// </summary>
/// <seealso cref="Run"/>
/// <seealso cref="StreamingRun"/>
public TRun Run { get; }
/// <inheritdoc cref="ICheckpointingHandle.Checkpoints"/>
public IReadOnlyList<CheckpointInfo> Checkpoints => this._runner.Checkpoints;
/// <summary>
/// Gets the most recent checkpoint information.
/// </summary>
public CheckpointInfo? LastCheckpoint
{
get
{
var checkpoints = this.Checkpoints;
return checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null;
}
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (this.Run is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else if (this.Run is IDisposable disposable)
{
disposable.Dispose();
}
}
/// <inheritdoc cref="ICheckpointingHandle.RestoreCheckpointAsync"/>
public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
=> this._runner.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
}
@@ -15,7 +15,7 @@ internal sealed partial class CheckpointInfoConverter() : JsonConverterDictionar
protected override JsonTypeInfo<CheckpointInfo> TypeInfo
=> WorkflowsJsonUtilities.JsonContext.Default.CheckpointInfo;
private const string CheckpointInfoPropertyNamePattern = @"^(?<runId>(((\|\|)|([^\|]))*))\|(?<checkpointId>(((\|\|)|([^\|]))*)?)$";
private const string CheckpointInfoPropertyNamePattern = @"^(?<sessionId>(((\|\|)|([^\|]))*))\|(?<checkpointId>(((\|\|)|([^\|]))*)?)$";
#if NET
[GeneratedRegex(CheckpointInfoPropertyNamePattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
public static partial Regex CheckpointInfoPropertyNameRegex();
@@ -33,17 +33,17 @@ internal sealed partial class CheckpointInfoConverter() : JsonConverterDictionar
throw new JsonException($"Invalid CheckpointInfo property name format. Got '{propertyName}'.");
}
string runId = scopeKeyPatternMatch.Groups["runId"].Value;
string sessionId = scopeKeyPatternMatch.Groups["sessionId"].Value;
string checkpointId = scopeKeyPatternMatch.Groups["checkpointId"].Value;
return new(Unescape(runId)!, Unescape(checkpointId)!);
return new(Unescape(sessionId)!, Unescape(checkpointId)!);
}
protected override string Stringify([DisallowNull] CheckpointInfo value)
{
string? runIdEscaped = Escape(value.RunId);
string? sessionIdEscaped = Escape(value.SessionId);
string? checkpointIdEscaped = Escape(value.CheckpointId);
return $"{runIdEscaped}|{checkpointIdEscaped}";
return $"{sessionIdEscaped}|{checkpointIdEscaped}";
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -15,16 +16,19 @@ internal sealed class CheckpointManagerImpl<TStoreObject> : ICheckpointManager
this._store = store;
}
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint)
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
{
TStoreObject storeObject = this._marshaller.Marshal(checkpoint);
return this._store.CreateCheckpointAsync(runId, storeObject, checkpoint.Parent);
return this._store.CreateCheckpointAsync(sessionId, storeObject, checkpoint.Parent);
}
public async ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
public async ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
{
TStoreObject result = await this._store.RetrieveCheckpointAsync(runId, checkpointInfo).ConfigureAwait(false);
TStoreObject result = await this._store.RetrieveCheckpointAsync(sessionId, checkpointInfo).ConfigureAwait(false);
return this._marshaller.Marshal<Checkpoint>(result);
}
public ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
=> this._store.RetrieveIndexAsync(sessionId, withParent);
}
@@ -93,15 +93,15 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
}
private string GetFileNameForCheckpoint(string runId, CheckpointInfo key)
=> Path.Combine(this.Directory.FullName, $"{runId}_{key.CheckpointId}.json");
private string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
=> Path.Combine(this.Directory.FullName, $"{sessionId}_{key.CheckpointId}.json");
private CheckpointInfo GetUnusedCheckpointInfo(string runId)
private CheckpointInfo GetUnusedCheckpointInfo(string sessionId)
{
CheckpointInfo key;
do
{
key = new(runId);
key = new(sessionId);
} while (!this.CheckpointIndex.Add(key));
return key;
@@ -110,12 +110,12 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
/// <inheritdoc/>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1835:Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'",
Justification = "Memory-based overload is missing for 4.7.2")]
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
this.CheckDisposed();
CheckpointInfo key = this.GetUnusedCheckpointInfo(runId);
string fileName = this.GetFileNameForCheckpoint(runId, key);
CheckpointInfo key = this.GetUnusedCheckpointInfo(sessionId);
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
try
{
using Stream checkpointStream = File.Open(fileName, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -145,10 +145,10 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
/// <inheritdoc/>
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
this.CheckDisposed();
string fileName = this.GetFileNameForCheckpoint(runId, key);
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
if (!this.CheckpointIndex.Contains(key) ||
!File.Exists(fileName))
@@ -163,7 +163,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
/// <inheritdoc/>
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
this.CheckDisposed();
@@ -13,18 +13,30 @@ internal interface ICheckpointManager
/// <summary>
/// Commits the specified checkpoint and returns information that can be used to retrieve it later.
/// </summary>
/// <param name="runId">The identifier for the current run or execution context.</param>
/// <param name="sessionId">The identifier for the current session or execution context.</param>
/// <param name="checkpoint">The checkpoint to commit.</param>
/// <returns>A <see cref="CheckpointInfo"/> representing the incoming checkpoint.</returns>
ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint);
ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint);
/// <summary>
/// Retrieves the checkpoint associated with the specified checkpoint information.
/// </summary>
/// <param name="runId">The identifier for the current run of execution context.</param>
/// <param name="sessionId">The identifier for the current session of execution context.</param>
/// <param name="checkpointInfo">The information used to identify the checkpoint.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> representing the asynchronous operation. The result contains the <see
/// cref="Checkpoint"/> associated with the specified <paramref name="checkpointInfo"/>.</returns>
/// <exception cref="KeyNotFoundException">Thrown if the checkpoint is not found.</exception>
ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo);
ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo);
/// <summary>
/// Asynchronously retrieves the collection of checkpoint information for the specified session identifier, optionally
/// filtered by a parent checkpoint.
/// </summary>
/// <param name="sessionId">The unique identifier of the session for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
/// returned; otherwise, all checkpoints for the session are included.</param>
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
/// found.</returns>
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
}
@@ -6,44 +6,41 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <summary>
/// Defines a contract for storing and retrieving checkpoints associated with a specific run and key.
/// Defines a contract for storing and retrieving checkpoints associated with a specific session and key.
/// </summary>
/// <remarks>Implementations of this interface enable durable or in-memory storage of checkpoints, which can be
/// used to resume or audit long-running processes. The interface is generic to support different storage object types
/// depending on the application's requirements.</remarks>
/// <typeparam name="TStoreObject">The type of object to be stored as the value for each checkpoint.</typeparam>
public interface ICheckpointStore<TStoreObject>
{
/// <summary>
/// Asynchronously retrieves the collection of checkpoint information for the specified run identifier, optionally
/// Asynchronously retrieves the collection of checkpoint information for the specified session identifier, optionally
/// filtered by a parent checkpoint.
/// </summary>
/// <param name="runId">The unique identifier of the run for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
/// returned; otherwise, all checkpoints for the run are included.</param>
/// returned; otherwise, all checkpoints for the session are included.</param>
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
/// cref="CheckpointInfo"/> objects associated with the specified run. The collection is empty if no checkpoints are
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
/// found.</returns>
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null);
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
/// <summary>
/// Asynchronously creates a checkpoint for the specified run and key, associating it with the provided value and
/// Asynchronously creates a checkpoint for the specified session and key, associating it with the provided value and
/// optional parent checkpoint.
/// </summary>
/// <param name="runId">The unique identifier of the run for which the checkpoint is being created. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which the checkpoint is being created. Cannot be null or empty.</param>
/// <param name="value">The value to associate with the checkpoint. Cannot be null.</param>
/// <param name="parent">The optional parent checkpoint information. If specified, the new checkpoint will be linked as a child of this
/// parent.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the <see cref="CheckpointInfo"/>
/// object representing this stored checkpoint.</returns>
ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, TStoreObject value, CheckpointInfo? parent = null);
ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, TStoreObject value, CheckpointInfo? parent = null);
/// <summary>
/// Asynchronously retrieves a checkpoint object associated with the specified run and checkpoint key.
/// Asynchronously retrieves a checkpoint object associated with the specified session and checkpoint key.
/// </summary>
/// <param name="runId">The unique identifier of the run for which the checkpoint is to be retrieved. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which the checkpoint is to be retrieved. Cannot be null or empty.</param>
/// <param name="key">The key identifying the specific checkpoint to retrieve. Cannot be null.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the checkpoint object associated
/// with the specified run and key.</returns>
ValueTask<TStoreObject> RetrieveCheckpointAsync(string runId, CheckpointInfo key);
/// with the specified session and key.</returns>
ValueTask<TStoreObject> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key);
}
@@ -8,8 +8,21 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal interface ICheckpointingHandle
{
// TODO: Convert this to a multi-timeline (e.g.: Live timeline + forks for orphaned checkpoints due to timetravel)
/// <summary>
/// Gets a value indicating whether checkpointing is enabled for the current operation or process.
/// </summary>
bool IsCheckpointingEnabled { get; }
/// <summary>
/// Gets a read-only list of checkpoint information associated with the current context.
/// </summary>
IReadOnlyList<CheckpointInfo> Checkpoints { get; }
/// <summary>
/// Restores the system state from the specified checkpoint asynchronously.
/// </summary>
/// <param name="checkpointInfo">The checkpoint information that identifies the state to restore. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the restore operation.</param>
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous restore operation.</returns>
ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default);
}
@@ -13,51 +13,54 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal sealed class InMemoryCheckpointManager : ICheckpointManager
{
[JsonInclude]
internal Dictionary<string, RunCheckpointCache<Checkpoint>> Store { get; } = [];
internal Dictionary<string, SessionCheckpointCache<Checkpoint>> Store { get; } = [];
public InMemoryCheckpointManager() { }
[JsonConstructor]
internal InMemoryCheckpointManager(Dictionary<string, RunCheckpointCache<Checkpoint>> store)
internal InMemoryCheckpointManager(Dictionary<string, SessionCheckpointCache<Checkpoint>> store)
{
this.Store = store;
}
private RunCheckpointCache<Checkpoint> GetRunStore(string runId)
private SessionCheckpointCache<Checkpoint> GetSessionStore(string sessionId)
{
if (!this.Store.TryGetValue(runId, out RunCheckpointCache<Checkpoint>? runStore))
if (!this.Store.TryGetValue(sessionId, out SessionCheckpointCache<Checkpoint>? sessionStore))
{
runStore = this.Store[runId] = new();
sessionStore = this.Store[sessionId] = new();
}
return runStore;
return sessionStore;
}
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint)
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
{
RunCheckpointCache<Checkpoint> runStore = this.GetRunStore(runId);
SessionCheckpointCache<Checkpoint> sessionStore = this.GetSessionStore(sessionId);
CheckpointInfo key;
do
{
key = new(runId);
} while (!runStore.Add(key, checkpoint));
key = new(sessionId);
} while (!sessionStore.Add(key, checkpoint));
return new(key);
}
public ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
public ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
{
if (!this.GetRunStore(runId).TryGet(checkpointInfo, out Checkpoint? value))
if (!this.GetSessionStore(sessionId).TryGet(checkpointInfo, out Checkpoint? value))
{
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {checkpointInfo.CheckpointId} for run {runId}");
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {checkpointInfo.CheckpointId} for session {sessionId}");
}
return new(value);
}
internal bool HasCheckpoints(string runId) => this.GetRunStore(runId).HasCheckpoints;
internal bool HasCheckpoints(string sessionId) => this.GetSessionStore(sessionId).HasCheckpoints;
public bool TryGetLastCheckpoint(string runId, [NotNullWhen(true)] out CheckpointInfo? checkpoint)
=> this.GetRunStore(runId).TryGetLastCheckpointInfo(out checkpoint);
public bool TryGetLastCheckpoint(string sessionId, [NotNullWhen(true)] out CheckpointInfo? checkpoint)
=> this.GetSessionStore(sessionId).TryGetLastCheckpointInfo(out checkpoint);
public ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
=> new(this.GetSessionStore(sessionId).CheckpointIndex.AsReadOnly());
}
@@ -18,11 +18,11 @@ public abstract class JsonCheckpointStore : ICheckpointStore<JsonElement>
protected static JsonTypeInfo<CheckpointInfo> KeyTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointInfo;
/// <inheritdoc/>
public abstract ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null);
public abstract ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null);
/// <inheritdoc/>
public abstract ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key);
public abstract ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key);
/// <inheritdoc/>
public abstract ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null);
public abstract ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
}
@@ -25,6 +25,7 @@ internal sealed class PortableMessageEnvelope
{
this.MessageType = envelope.MessageType;
this.Message = new PortableValue(envelope.Message);
this.Source = envelope.Source;
this.TargetId = envelope.TargetId;
}
@@ -6,7 +6,7 @@ using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal sealed class RunCheckpointCache<TStoreObject>
internal sealed class SessionCheckpointCache<TStoreObject>
{
[JsonInclude]
internal List<CheckpointInfo> CheckpointIndex { get; } = [];
@@ -14,10 +14,10 @@ internal sealed class RunCheckpointCache<TStoreObject>
[JsonInclude]
internal Dictionary<CheckpointInfo, TStoreObject> Cache { get; } = [];
public RunCheckpointCache() { }
public SessionCheckpointCache() { }
[JsonConstructor]
internal RunCheckpointCache(List<CheckpointInfo> checkpointIndex, Dictionary<CheckpointInfo, TStoreObject> cache)
internal SessionCheckpointCache(List<CheckpointInfo> checkpointIndex, Dictionary<CheckpointInfo, TStoreObject> cache)
{
this.CheckpointIndex = checkpointIndex;
this.Cache = cache;
@@ -29,13 +29,13 @@ internal sealed class RunCheckpointCache<TStoreObject>
public bool IsInIndex(CheckpointInfo key) => this.Cache.ContainsKey(key);
public bool TryGet(CheckpointInfo key, [MaybeNullWhen(false)] out TStoreObject value) => this.Cache.TryGetValue(key, out value);
public CheckpointInfo Add(string runId, TStoreObject value)
public CheckpointInfo Add(string sessionId, TStoreObject value)
{
CheckpointInfo key;
do
{
key = new(runId);
key = new(sessionId);
} while (!this.Add(key, value));
return key;
@@ -16,7 +16,7 @@ public static class ConfigurationExtensions
/// <param name="configured">The existing configuration for the subject type to be upcast to its parent type. Cannot be null.</param>
/// <returns>A new <see cref="Configured{TParent}"/> instance that applies the original configuration logic to the parent type.</returns>
public static Configured<TParent> Super<TSubject, TParent>(this Configured<TSubject> configured) where TSubject : TParent
=> new(async (config, runId) => await configured.FactoryAsync(config, runId).ConfigureAwait(false), configured.Id, configured.Raw);
=> new(async (config, sessionId) => await configured.FactoryAsync(config, sessionId).ConfigureAwait(false), configured.Id, configured.Raw);
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
@@ -79,7 +79,7 @@ public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> fact
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.FactoryAsync(this.Configuration, runId);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.FactoryAsync(this.Configuration, sessionId);
}
/// <summary>
@@ -122,20 +122,20 @@ public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, Value
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.CreateValidatingMemoizedFactory()(this.Configuration, runId);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
async ValueTask<TSubject> FactoryAsync(Config configuration, string runId)
async ValueTask<TSubject> FactoryAsync(Config configuration, string sessionId)
{
if (this.Id != configuration.Id)
{
throw new InvalidOperationException($"Requested instance ID '{configuration.Id}' does not match configured ID '{this.Id}'.");
}
TSubject subject = await this.FactoryAsync(this.Configuration, runId).ConfigureAwait(false);
TSubject subject = await this.FactoryAsync(this.Configuration, sessionId).ConfigureAwait(false);
if (this.Id is not null && subject is IIdentified identified && identified.Id != this.Id)
{
@@ -44,7 +44,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
}
}
public string RunId => this._stepRunner.RunId;
public string SessionId => this._stepRunner.SessionId;
public bool IsCheckpointingEnabled => this._checkpointingHandle.IsCheckpointingEnabled;
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpointingHandle.Checkpoints;
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -8,12 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal static class AsyncRunHandleExtensions
{
public static async ValueTask<Checkpointed<TRunType>> WithCheckpointingAsync<TRunType>(this AsyncRunHandle runHandle, Func<ValueTask<TRunType>> prepareFunc)
{
TRunType run = await prepareFunc().ConfigureAwait(false);
return new Checkpointed<TRunType>(run, runHandle);
}
public static async ValueTask<StreamingRun> EnqueueAndStreamAsync<TInput>(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellationToken = default)
{
await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false);
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -9,10 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) :
EdgeRunner<DirectEdgeData>(runContext, edgeData)
{
private async ValueTask<Executor> FindRouterAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, tracer)
.ConfigureAwait(false);
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
using var activity = this.StartActivity();
activity?
@@ -35,8 +33,11 @@ internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData
return null;
}
Executor target = await this.FindRouterAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
Type? messageType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken)
.ConfigureAwait(false);
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer, cancellationToken).ConfigureAwait(false);
if (CanHandle(target, messageType))
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.Delivered);
return new DeliveryMapping(envelope, target);
@@ -4,6 +4,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -65,7 +66,7 @@ internal sealed class EdgeMap
this._stepTracer = stepTracer;
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message, CancellationToken cancellationToken = default)
{
EdgeId id = edge.Data.Id;
if (!this._edgeRunners.TryGetValue(id, out EdgeRunner? edgeRunner))
@@ -73,25 +74,25 @@ internal sealed class EdgeMap
throw new InvalidOperationException($"Edge {edge} not found in the edge map.");
}
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
public bool TryRegisterPort(IRunnerContext runContext, string executorId, RequestPort port)
=> this._portEdgeRunners.TryAdd(port.Id, ResponseEdgeRunner.ForPort(runContext, executorId, port));
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message, CancellationToken cancellationToken = default)
{
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response)
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
{
if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out ResponseEdgeRunner? portRunner))
{
throw new InvalidOperationException($"Port {response.PortInfo.PortId} not found in the edge map.");
}
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer);
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
}
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()

Some files were not shown because too many files have changed in this diff Show More