Skip to content

Commit 3168eb4

Browse files
westey-mCopilot
andauthored
.NET: [BREAKING] Add session StateBag for state storage and support multiple providers on the Agent (microsoft#3806)
* .NET: [BREAKING] Add session statebag to use for state storage instead of inside providers (microsoft#3737) * Add a StateBag to AgentSession and pass Agent and AgentSession to AIContextProvider and ChatHistoryProviders * Convert all AIContextProviders to use the statebag * Update InMemoryChatHistoryProvider to use StateBag * Update Comsos and Workflow ChatHistoryProviders * Update 3rd party chat history storage sample. * Remove serialize method from providers * Replacing provider factories with properties * Remove Providers from Session and flatten state bag serialization * Update samples to use getservice on agent * Updated additional session types to serialize statebag * Fix regression * Address PR comments * Address PR comments. * Fix formatting * Fix unit tests * Remove InMemoryAgentSession since it is not required anymore. * Address PR comments * Convert sessions for A2AAgent, ChatClientAgent, CopilotStudioAgent and GithubCopilotAgent to use regular json serialization. * Fix durable agent session jso usgae * Add jso to InMemory and Workflow ChatHistoryProviders * Update InMemoryChatHistoryProvider to use an options class for it's many optional settings. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR feedback * Fix verification bug. * Improve state bag thread safety * Address PR comments and fix unit tests * Address PR comments * Fix unit test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add a public StateKey property to providers (microsoft#3810) * .NET: [BREAKING] Update providers in such a way that they can participate in a pipeline (microsoft#3846) * Make providers pipeline capable * Fix unit tests * Move source stamping to providers from base class * Also update samples. * Address PR comments * Rename AsAgentRequestMessageSourcedMessage to WithAgentRequestMessageSource * .NET: [BREAKING] Add consistent message filtering to all providers. (microsoft#3851) * Add consistent message filtering to all providers. * Remove old chat history filtering classes * Fix merge issues * Fix unit test * Enforce non-nullable property * Fix merging bug and make troubleshooting source info easier by adding tostring implementation * .NET: [BREAKING] Add support for multiple AIContextProviders on a ChatClientAgent (microsoft#3863) * Add support for multiple AIContextProviders on a ChatClientAgent * Address PR comments and fix tests * Address PR comments. * .NET: [BREAKING]Delay AIContext Materialization until the end of the pipeline is reached. (microsoft#3883) * Delay AIContext Materialization until the end of the pipeline is reached. * Address PR comments. * Address PR comments * Modify InvokedContext to be immutable (microsoft#3888) * .NET: Address Feedback on StateBag feature branch PR (microsoft#3910) * Address Feedback on statebag feature branch PR * Update dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 4452997 commit 3168eb4

File tree

104 files changed

+5052
-4264
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+5052
-4264
lines changed

dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
using System.Runtime.CompilerServices;
88
using System.Text.Json;
9+
using System.Text.Json.Serialization;
910
using Microsoft.Agents.AI;
1011
using Microsoft.Extensions.AI;
1112
using SampleApp;
@@ -28,6 +29,8 @@ internal sealed class UpperCaseParrotAgent : AIAgent
2829
{
2930
public override string? Name => "UpperCaseParrotAgent";
3031

32+
public readonly ChatHistoryProvider ChatHistoryProvider = new InMemoryChatHistoryProvider();
33+
3134
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
3235
=> new(new CustomAgentSession());
3336

@@ -38,11 +41,11 @@ protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession
3841
throw new ArgumentException($"The provided session is not of type {nameof(CustomAgentSession)}.", nameof(session));
3942
}
4043

41-
return new(typedSession.Serialize(jsonSerializerOptions));
44+
return new(JsonSerializer.SerializeToElement(typedSession, jsonSerializerOptions));
4245
}
4346

4447
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
45-
=> new(new CustomAgentSession(serializedState, jsonSerializerOptions));
48+
=> new(serializedState.Deserialize<CustomAgentSession>(jsonSerializerOptions)!);
4649

4750
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
4851
{
@@ -56,17 +59,14 @@ protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessag
5659

5760
// Get existing messages from the store
5861
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
59-
var storeMessages = await typedSession.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
62+
var userAndChatHistoryMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
6063

6164
// Clone the input messages and turn them into response messages with upper case text.
6265
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
6366

6467
// Notify the session of the input and output messages.
65-
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages)
66-
{
67-
ResponseMessages = responseMessages
68-
};
69-
await typedSession.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
68+
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, userAndChatHistoryMessages, responseMessages);
69+
await this.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
7070

7171
return new AgentResponse
7272
{
@@ -88,17 +88,14 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
8888

8989
// Get existing messages from the store
9090
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
91-
var storeMessages = await typedSession.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
91+
var userAndChatHistoryMessages = await this.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
9292

9393
// Clone the input messages and turn them into response messages with upper case text.
9494
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
9595

9696
// Notify the session of the input and output messages.
97-
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages)
98-
{
99-
ResponseMessages = responseMessages
100-
};
101-
await typedSession.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
97+
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, userAndChatHistoryMessages, responseMessages);
98+
await this.ChatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
10299

103100
foreach (var message in responseMessages)
104101
{
@@ -140,15 +137,16 @@ private static IEnumerable<ChatMessage> CloneAndToUpperCase(IEnumerable<ChatMess
140137
/// <summary>
141138
/// A session type for our custom agent that only supports in memory storage of messages.
142139
/// </summary>
143-
internal sealed class CustomAgentSession : InMemoryAgentSession
140+
internal sealed class CustomAgentSession : AgentSession
144141
{
145-
internal CustomAgentSession() { }
146-
147-
internal CustomAgentSession(JsonElement serializedSessionState, JsonSerializerOptions? jsonSerializerOptions = null)
148-
: base(serializedSessionState, jsonSerializerOptions) { }
142+
internal CustomAgentSession()
143+
{
144+
}
149145

150-
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
151-
=> base.Serialize(jsonSerializerOptions);
146+
[JsonConstructor]
147+
internal CustomAgentSession(AgentSessionStateBag stateBag) : base(stateBag)
148+
{
149+
}
152150
}
153151
}
154152
}

dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,21 @@
3737
{
3838
ChatOptions = new() { Instructions = "You are good at telling jokes." },
3939
Name = "Joker",
40-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new ChatHistoryMemoryProvider(
40+
AIContextProviders = [new ChatHistoryMemoryProvider(
4141
vectorStore,
4242
collectionName: "chathistory",
4343
vectorDimensions: 3072,
44-
// Configure the scope values under which chat messages will be stored.
45-
// In this case, we are using a fixed user ID and a unique session ID for each new session.
46-
storageScope: new() { UserId = "UID1", SessionId = Guid.NewGuid().ToString() },
47-
// Configure the scope which would be used to search for relevant prior messages.
48-
// In this case, we are searching for any messages for the user across all sessions.
49-
searchScope: new() { UserId = "UID1" }))
44+
// Callback to configure the initial state of the ChatHistoryMemoryProvider.
45+
// The ChatHistoryMemoryProvider stores its state in the AgentSession and this callback
46+
// will be called whenever the ChatHistoryMemoryProvider cannot find existing state in the session,
47+
// typically the first time it is used with a new session.
48+
session => new ChatHistoryMemoryProvider.State(
49+
// Configure the scope values under which chat messages will be stored.
50+
// In this case, we are using a fixed user ID and a unique session ID for each new session.
51+
storageScope: new() { UserId = "UID1", SessionId = Guid.NewGuid().ToString() },
52+
// Configure the scope which would be used to search for relevant prior messages.
53+
// In this case, we are searching for any messages for the user across all sessions.
54+
searchScope: new() { UserId = "UID1" }))]
5055
});
5156

5257
// Start a new session for the agent conversation.

dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,21 @@
3434
.AsAIAgent(new ChatClientAgentOptions()
3535
{
3636
ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." },
37-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(ctx.SerializedState.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined
38-
// If each session should have its own Mem0 scope, you can create a new id per session here:
39-
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() })
40-
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
41-
? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
42-
// For cases where we are restoring from serialized state:
43-
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions))
37+
// The stateInitializer can be used to customize the Mem0 scope per session and it will be called each time a session
38+
// is encountered by the Mem0Provider that does not already have Mem0Provider state stored on the session.
39+
// If each session should have its own Mem0 scope, you can create a new id per session via the stateInitializer, e.g.:
40+
// new Mem0Provider(mem0HttpClient, stateInitializer: _ => new(new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() }))
41+
// In our case we are storing memories scoped by application and user instead so that memories are retained across threads.
42+
AIContextProviders = [new Mem0Provider(mem0HttpClient, stateInitializer: _ => new(new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" }))]
4443
});
4544

4645
AgentSession session = await agent.CreateSessionAsync();
4746

4847
// Clear any existing memories for this scope to demonstrate fresh behavior.
49-
Mem0Provider mem0Provider = session.GetService<Mem0Provider>()!;
50-
await mem0Provider.ClearStoredMemoriesAsync();
48+
// Note that the ClearStoredMemoriesAsync method will clear memories
49+
// using the scope stored in the session, or provided via the stateInitializer.
50+
Mem0Provider mem0Provider = agent.GetService<Mem0Provider>()!;
51+
await mem0Provider.ClearStoredMemoriesAsync(session);
5152

5253
Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session));
5354
Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session));

dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
3737
{
3838
ChatOptions = new() { Instructions = "You are a friendly assistant. Always address the user by their name." },
39-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new UserInfoMemory(chatClient.AsIChatClient(), ctx.SerializedState, ctx.JsonSerializerOptions))
39+
AIContextProviders = [new UserInfoMemory(chatClient.AsIChatClient())]
4040
});
4141

4242
// Create a new session for the conversation.
@@ -58,23 +58,23 @@
5858
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
5959
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
6060

61-
Console.WriteLine("\n>> Read memories from memory component\n");
61+
Console.WriteLine("\n>> Read memories using memory component\n");
6262

63-
// It's possible to access the memory component via the session's GetService method.
64-
var userInfo = deserializedSession.GetService<UserInfoMemory>()?.UserInfo;
63+
// It's possible to access the memory component via the agent's GetService method.
64+
var userInfo = agent.GetService<UserInfoMemory>()?.GetUserInfo(deserializedSession);
6565

6666
// Output the user info that was captured by the memory component.
6767
Console.WriteLine($"MEMORY - User Name: {userInfo?.UserName}");
6868
Console.WriteLine($"MEMORY - User Age: {userInfo?.UserAge}");
6969

7070
Console.WriteLine("\n>> Use new session with previously created memories\n");
7171

72-
// It is also possible to set the memories in a memory component on an individual session.
72+
// It is also possible to set the memories using a memory component on an individual session.
7373
// This is useful if we want to start a new session, but have it share the same memories as a previous session.
7474
var newSession = await agent.CreateSessionAsync();
75-
if (userInfo is not null && newSession.GetService<UserInfoMemory>() is UserInfoMemory newSessionMemory)
75+
if (userInfo is not null && agent.GetService<UserInfoMemory>() is UserInfoMemory newSessionMemory)
7676
{
77-
newSessionMemory.UserInfo = userInfo;
77+
newSessionMemory.SetUserInfo(newSession, userInfo);
7878
}
7979

8080
// Invoke the agent and output the text result.
@@ -89,28 +89,27 @@ namespace SampleApp
8989
internal sealed class UserInfoMemory : AIContextProvider
9090
{
9191
private readonly IChatClient _chatClient;
92+
private readonly Func<AgentSession?, UserInfo> _stateInitializer;
9293

93-
public UserInfoMemory(IChatClient chatClient, UserInfo? userInfo = null)
94+
public UserInfoMemory(IChatClient chatClient, Func<AgentSession?, UserInfo>? stateInitializer = null)
9495
{
9596
this._chatClient = chatClient;
96-
this.UserInfo = userInfo ?? new UserInfo();
97+
this._stateInitializer = stateInitializer ?? (_ => new UserInfo());
9798
}
9899

99-
public UserInfoMemory(IChatClient chatClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
100-
{
101-
this._chatClient = chatClient;
102-
103-
this.UserInfo = serializedState.ValueKind == JsonValueKind.Object ?
104-
serializedState.Deserialize<UserInfo>(jsonSerializerOptions)! :
105-
new UserInfo();
106-
}
100+
public UserInfo GetUserInfo(AgentSession session)
101+
=> session.StateBag.GetValue<UserInfo>(nameof(UserInfoMemory)) ?? new UserInfo();
107102

108-
public UserInfo UserInfo { get; set; }
103+
public void SetUserInfo(AgentSession session, UserInfo userInfo)
104+
=> session.StateBag.SetValue(nameof(UserInfoMemory), userInfo);
109105

110106
protected override async ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
111107
{
108+
var userInfo = context.Session?.StateBag.GetValue<UserInfo>(nameof(UserInfoMemory))
109+
?? this._stateInitializer.Invoke(context.Session);
110+
112111
// Try and extract the user name and age from the message if we don't have it already and it's a user message.
113-
if ((this.UserInfo.UserName is null || this.UserInfo.UserAge is null) && context.RequestMessages.Any(x => x.Role == ChatRole.User))
112+
if ((userInfo.UserName is null || userInfo.UserAge is null) && context.RequestMessages.Any(x => x.Role == ChatRole.User))
114113
{
115114
var result = await this._chatClient.GetResponseAsync<UserInfo>(
116115
context.RequestMessages,
@@ -120,36 +119,43 @@ protected override async ValueTask InvokedCoreAsync(InvokedContext context, Canc
120119
},
121120
cancellationToken: cancellationToken);
122121

123-
this.UserInfo.UserName ??= result.Result.UserName;
124-
this.UserInfo.UserAge ??= result.Result.UserAge;
122+
userInfo.UserName ??= result.Result.UserName;
123+
userInfo.UserAge ??= result.Result.UserAge;
125124
}
125+
126+
context.Session?.StateBag.SetValue(nameof(UserInfoMemory), userInfo);
126127
}
127128

128129
protected override ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
129130
{
131+
var inputContext = context.AIContext;
132+
var userInfo = context.Session?.StateBag.GetValue<UserInfo>(nameof(UserInfoMemory))
133+
?? this._stateInitializer.Invoke(context.Session);
134+
130135
StringBuilder instructions = new();
136+
if (!string.IsNullOrEmpty(inputContext.Instructions))
137+
{
138+
instructions.AppendLine(inputContext.Instructions);
139+
}
131140

132141
// If we don't already know the user's name and age, add instructions to ask for them, otherwise just provide what we have to the context.
133142
instructions
134143
.AppendLine(
135-
this.UserInfo.UserName is null ?
144+
userInfo.UserName is null ?
136145
"Ask the user for their name and politely decline to answer any questions until they provide it." :
137-
$"The user's name is {this.UserInfo.UserName}.")
146+
$"The user's name is {userInfo.UserName}.")
138147
.AppendLine(
139-
this.UserInfo.UserAge is null ?
148+
userInfo.UserAge is null ?
140149
"Ask the user for their age and politely decline to answer any questions until they provide it." :
141-
$"The user's age is {this.UserInfo.UserAge}.");
150+
$"The user's age is {userInfo.UserAge}.");
142151

143152
return new ValueTask<AIContext>(new AIContext
144153
{
145-
Instructions = instructions.ToString()
154+
Instructions = instructions.ToString(),
155+
Messages = inputContext.Messages,
156+
Tools = inputContext.Tools
146157
});
147158
}
148-
149-
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
150-
{
151-
return JsonSerializer.SerializeToElement(this.UserInfo, jsonSerializerOptions);
152-
}
153159
}
154160

155161
internal sealed class UserInfo

dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,16 @@
6565
.AsAIAgent(new ChatClientAgentOptions
6666
{
6767
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
68-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)),
69-
// Since we are using ChatCompletion which stores chat history locally, we can also add a message removal policy
68+
AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)],
69+
// Since we are using ChatCompletion which stores chat history locally, we can also add a message filter
7070
// that removes messages produced by the TextSearchProvider before they are added to the chat history, so that
7171
// we don't bloat chat history with all the search result messages.
72-
ChatHistoryProviderFactory = (ctx, ct) => new ValueTask<ChatHistoryProvider>(new InMemoryChatHistoryProvider(ctx.SerializedState, ctx.JsonSerializerOptions)
73-
.WithAIContextProviderMessageRemoval()),
72+
// By default the chat history provider will store all messages, except for those that came from chat history in the first place.
73+
// We also want to maintain that exclusion here.
74+
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
75+
{
76+
StorageInputMessageFilter = messages => messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider && m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
77+
}),
7478
});
7579

7680
AgentSession session = await agent.CreateSessionAsync();

dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
.AsAIAgent(new ChatClientAgentOptions
7575
{
7676
ChatOptions = new() { Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief." },
77-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions))
77+
AIContextProviders = [new TextSearchProvider(SearchAdapter, textSearchOptions)]
7878
});
7979

8080
AgentSession session = await agent.CreateSessionAsync();

dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
.AsAIAgent(new ChatClientAgentOptions
3333
{
3434
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
35-
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions))
35+
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
3636
});
3737

3838
AgentSession session = await agent.CreateSessionAsync();

0 commit comments

Comments
 (0)