mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [BREAKING] Add session statebag to use for state storage instead of inside providers (#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>
This commit is contained in:
committed by
GitHub
Unverified
parent
977c3adfb2
commit
b12ff578af
@@ -80,7 +80,7 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new A2AAgentSession(serializedState, jsonSerializerOptions));
|
||||
=> new(A2AAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -1,66 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Session for A2A based agents.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class A2AAgentSession : AgentSession
|
||||
{
|
||||
internal A2AAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
internal A2AAgentSession(JsonElement serializedSessionState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
[JsonConstructor]
|
||||
internal A2AAgentSession(string? contextId, string? taskId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
{
|
||||
if (serializedSessionState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedSessionState));
|
||||
}
|
||||
|
||||
var state = serializedSessionState.Deserialize(
|
||||
A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentSessionState))) as A2AAgentSessionState;
|
||||
|
||||
if (state?.ContextId is string contextId)
|
||||
{
|
||||
this.ContextId = contextId;
|
||||
}
|
||||
|
||||
if (state?.TaskId is string taskId)
|
||||
{
|
||||
this.TaskId = taskId;
|
||||
}
|
||||
this.ContextId = contextId;
|
||||
this.TaskId = taskId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID for the current conversation with the A2A agent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contextId")]
|
||||
public string? ContextId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID for the task the agent is currently working on.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public string? TaskId { get; internal set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var state = new A2AAgentSessionState
|
||||
{
|
||||
ContextId = this.ContextId,
|
||||
TaskId = this.TaskId
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentSessionState)));
|
||||
var jso = jsonSerializerOptions ?? A2AJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(A2AAgentSession)));
|
||||
}
|
||||
|
||||
internal sealed class A2AAgentSessionState
|
||||
internal static A2AAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
public string? ContextId { get; set; }
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
public string? TaskId { get; set; }
|
||||
var jso = jsonSerializerOptions ?? A2AJsonUtilities.DefaultOptions;
|
||||
return serializedState.Deserialize(jso.GetTypeInfo(typeof(A2AAgentSession))) as A2AAgentSession
|
||||
?? new A2AAgentSession();
|
||||
}
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public static partial class A2AJsonUtilities
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
|
||||
// A2A agent types
|
||||
[JsonSerializable(typeof(A2AAgentSession.A2AAgentSessionState))]
|
||||
[JsonSerializable(typeof(A2AAgentSession))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -130,13 +129,18 @@ public abstract class AIContextProvider
|
||||
/// <para>
|
||||
/// Implementers can use the request and response messages in the provided <paramref name="context"/> to:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Update internal state based on conversation outcomes</description></item>
|
||||
/// <item><description>Update state based on conversation outcomes</description></item>
|
||||
/// <item><description>Extract and store memories or preferences from user messages</description></item>
|
||||
/// <item><description>Log or audit conversation details</description></item>
|
||||
/// <item><description>Perform cleanup or finalization tasks</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="AIContextProvider"/> is passed a reference to the <see cref="AgentSession"/> via <see cref="InvokingContext"/> and <see cref="InvokedContext"/>
|
||||
/// allowing it to store state in the <see cref="AgentSession.StateBag"/>. Since an <see cref="AIContextProvider"/> is used with many different sessions, it should
|
||||
/// not store any session-specific information within its own instance fields. Instead, any session-specific state should be stored in the associated <see cref="AgentSession.StateBag"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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>
|
||||
@@ -168,18 +172,6 @@ public abstract class AIContextProvider
|
||||
protected virtual ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use for the serialization process.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state, or a default <see cref="JsonElement"/> if the provider has no serializable state.</returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns a default <see cref="JsonElement"/>. Override this method if the provider
|
||||
/// maintains state that should be preserved across sessions or distributed scenarios.
|
||||
/// </remarks>
|
||||
public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> 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>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
@@ -80,9 +81,9 @@ public static partial class AgentAbstractionsJsonUtilities
|
||||
[JsonSerializable(typeof(AgentResponse[]))]
|
||||
[JsonSerializable(typeof(AgentResponseUpdate))]
|
||||
[JsonSerializable(typeof(AgentResponseUpdate[]))]
|
||||
[JsonSerializable(typeof(ServiceIdAgentSession.ServiceIdAgentSessionState))]
|
||||
[JsonSerializable(typeof(InMemoryAgentSession.InMemoryAgentSessionState))]
|
||||
[JsonSerializable(typeof(InMemoryChatHistoryProvider.State))]
|
||||
[JsonSerializable(typeof(AgentSessionStateBag))]
|
||||
[JsonSerializable(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>))]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -44,6 +46,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <seealso cref="AIAgent"/>
|
||||
/// <seealso cref="AIAgent.CreateSessionAsync(System.Threading.CancellationToken)"/>
|
||||
/// <seealso cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract class AgentSession
|
||||
{
|
||||
/// <summary>
|
||||
@@ -53,6 +56,20 @@ public abstract class AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSession"/> class.
|
||||
/// </summary>
|
||||
protected AgentSession(AgentSessionStateBag stateBag)
|
||||
{
|
||||
this.StateBag = Throw.IfNull(stateBag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets any arbitrary state associated with this session.
|
||||
/// </summary>
|
||||
[JsonPropertyName("stateBag")]
|
||||
public AgentSessionStateBag StateBag { get; protected set; } = new();
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSession"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
@@ -82,4 +99,7 @@ public abstract class AgentSession
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay => $"StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a thread-safe key-value store for managing session-scoped state with support for type-safe access and JSON
|
||||
/// serialization options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SessionState enables storing and retrieving objects associated with a session using string keys.
|
||||
/// Values can be accessed in a type-safe manner and are serialized or deserialized using configurable JSON serializer
|
||||
/// options. This class is designed for concurrent access and is safe to use across multiple threads.
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(AgentSessionStateBagJsonConverter))]
|
||||
public class AgentSessionStateBag
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AgentSessionStateBagValue> _state;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSessionStateBag"/> class.
|
||||
/// </summary>
|
||||
public AgentSessionStateBag()
|
||||
{
|
||||
this._state = new ConcurrentDictionary<string, AgentSessionStateBagValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSessionStateBag"/> class.
|
||||
/// </summary>
|
||||
/// <param name="state">The initial state dictionary.</param>
|
||||
internal AgentSessionStateBag(ConcurrentDictionary<string, AgentSessionStateBagValue>? state)
|
||||
{
|
||||
this._state = state ?? new ConcurrentDictionary<string, AgentSessionStateBagValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of key-value pairs contained in the session state.
|
||||
/// </summary>
|
||||
public int Count => this._state.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a value from the session state.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
|
||||
/// <param name="key">The key from which to retrieve the value.</param>
|
||||
/// <param name="value">The value if found and convertible to the required type; otherwise, null.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing/deserializing the value.</param>
|
||||
/// <returns><see langword="true"/> if the value was successfully retrieved, <see langword="false"/> otherwise.</returns>
|
||||
public bool TryGetValue<T>(string key, out T? value, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
where T : class
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(key);
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
if (this._state.TryGetValue(key, out var stateValue))
|
||||
{
|
||||
return stateValue.TryReadDeserializedValue(out value, jso);
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the session state.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of value to get.</typeparam>
|
||||
/// <param name="key">The key from which to retrieve the value.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing/deserialing the value.</param>
|
||||
/// <returns>The retrieved value or null if not found.</returns>
|
||||
/// <exception cref="InvalidOperationException">The value could not be deserialized into the required type.</exception>
|
||||
public T? GetValue<T>(string key, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
where T : class
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(key);
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
if (this._state.TryGetValue(key, out var stateValue))
|
||||
{
|
||||
return stateValue.ReadDeserializedValue<T>(jso);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value in the session state.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to set.</typeparam>
|
||||
/// <param name="key">The key to store the value under.</param>
|
||||
/// <param name="value">The value to set.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing the value.</param>
|
||||
public void SetValue<T>(string key, T? value, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
where T : class
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(key);
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
var stateValue = this._state.GetOrAdd(key, _ =>
|
||||
new AgentSessionStateBagValue(value, typeof(T), jso));
|
||||
|
||||
stateValue.SetDeserialized(value, typeof(T), jso);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to remove a value from the session state.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the value to remove.</param>
|
||||
/// <returns><see langword="true"/> if the value was successfully removed; otherwise, <see langword="false"/>.</returns>
|
||||
public bool TryRemoveValue(string key)
|
||||
=> this._state.TryRemove(Throw.IfNullOrWhitespace(key), out _);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes all session state values to a JSON object.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="JsonElement"/> representing the serialized session state.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when a session state value is not properly initialized.</exception>
|
||||
public JsonElement Serialize()
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(this._state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON object into an <see cref="AgentSessionStateBag"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="jsonElement">The element to deserialize.</param>
|
||||
/// <returns>The deserialized <see cref="AgentSessionStateBag"/>.</returns>
|
||||
public static AgentSessionStateBag Deserialize(JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
{
|
||||
return new AgentSessionStateBag();
|
||||
}
|
||||
|
||||
return new AgentSessionStateBag(
|
||||
jsonElement.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>))) as ConcurrentDictionary<string, AgentSessionStateBagValue>
|
||||
?? new ConcurrentDictionary<string, AgentSessionStateBagValue>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON converter for <see cref="AgentSessionStateBag"/> that serializes and deserializes
|
||||
/// the internal dictionary contents rather than the container object's public properties.
|
||||
/// </summary>
|
||||
public sealed class AgentSessionStateBagJsonConverter : JsonConverter<AgentSessionStateBag>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override AgentSessionStateBag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var element = JsonElement.ParseValue(ref reader);
|
||||
return AgentSessionStateBag.Deserialize(element);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, AgentSessionStateBag value, JsonSerializerOptions options)
|
||||
{
|
||||
var element = value.Serialize();
|
||||
element.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Used to store a value in session state.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(AgentSessionStateBagValueJsonConverter))]
|
||||
internal class AgentSessionStateBagValue
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private DeserializedCache? _cache;
|
||||
private JsonElement _jsonValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SessionStateValue class with the specified value.
|
||||
/// </summary>
|
||||
/// <param name="jsonValue">The serialized value to associate with the session state.</param>
|
||||
public AgentSessionStateBagValue(JsonElement jsonValue)
|
||||
{
|
||||
this.JsonValue = jsonValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SessionStateValue class with the specified value.
|
||||
/// </summary>
|
||||
/// <param name="deserializedValue">The value to associate with the session state. Can be any object, including null.</param>
|
||||
/// <param name="valueType">The type of the value.</param>
|
||||
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing the value.</param>
|
||||
public AgentSessionStateBagValue(object? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
this._cache = new DeserializedCache(deserializedValue, valueType, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value associated with this instance.
|
||||
/// </summary>
|
||||
public JsonElement JsonValue
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
// We are assuming here that JsonValue will only be read when the object is being serialized,
|
||||
// which means that we will only call SerializeToElement when serializing and therefore it's
|
||||
// OK to serialize on each read if the cache is set.
|
||||
if (this._cache is { } cache)
|
||||
{
|
||||
this._jsonValue = JsonSerializer.SerializeToElement(cache.Value, cache.Options.GetTypeInfo(cache.ValueType));
|
||||
}
|
||||
|
||||
return this._jsonValue;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
this._jsonValue = value;
|
||||
this._cache = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the deserialized value of this session state value.
|
||||
/// Returns false if the value could not be deserialized into the required type, or if the value is undefined.
|
||||
/// Returns true and sets the out parameter to null if the value is null.
|
||||
/// </summary>
|
||||
public bool TryReadDeserializedValue<T>(out T? value, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
where T : class
|
||||
{
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
lock (this._lock)
|
||||
{
|
||||
if (this._cache is { } cache)
|
||||
{
|
||||
value = cache.Value as T;
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (this._jsonValue)
|
||||
{
|
||||
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Undefined:
|
||||
value = null;
|
||||
return false;
|
||||
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Null:
|
||||
value = null;
|
||||
return true;
|
||||
default:
|
||||
T? result = this._jsonValue.Deserialize(jso.GetTypeInfo(typeof(T))) as T;
|
||||
if (result is null)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this._cache = new DeserializedCache(result, typeof(T), jso);
|
||||
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the deserialized value of this session state value, throwing an exception if the value could not be deserialized into the required type or is undefined.
|
||||
/// </summary>
|
||||
public T? ReadDeserializedValue<T>(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
where T : class
|
||||
{
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
lock (this._lock)
|
||||
{
|
||||
if (this._cache is { } cache)
|
||||
{
|
||||
return cache.Value as T;
|
||||
}
|
||||
|
||||
switch (this._jsonValue)
|
||||
{
|
||||
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Null || jsonElement.ValueKind == JsonValueKind.Undefined:
|
||||
return null;
|
||||
default:
|
||||
T? result = this._jsonValue.Deserialize(jso.GetTypeInfo(typeof(T))) as T;
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize session state value to type {typeof(T).FullName}.");
|
||||
}
|
||||
|
||||
this._cache = new DeserializedCache(result, typeof(T), jso);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the deserialized value of this session state value, updating the cache accordingly.
|
||||
/// This does not update the JsonValue directly; the JsonValue will be updated on the next read or when the object is serialized.
|
||||
/// </summary>
|
||||
public void SetDeserialized(object? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
this._cache = new DeserializedCache(deserializedValue, valueType, jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct DeserializedCache
|
||||
{
|
||||
public DeserializedCache(object? value, Type valueType, JsonSerializerOptions options)
|
||||
{
|
||||
this.Value = value;
|
||||
this.ValueType = valueType;
|
||||
this.Options = options;
|
||||
}
|
||||
|
||||
public object? Value { get; }
|
||||
|
||||
public Type ValueType { get; }
|
||||
|
||||
public JsonSerializerOptions Options { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON converter for <see cref="AgentSessionStateBagValue"/> that serializes and deserializes
|
||||
/// the <see cref="AgentSessionStateBagValue.JsonValue"/> directly rather than wrapping it in a container object.
|
||||
/// </summary>
|
||||
internal sealed class AgentSessionStateBagValueJsonConverter : JsonConverter<AgentSessionStateBagValue>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override AgentSessionStateBagValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var element = JsonElement.ParseValue(ref reader);
|
||||
return new AgentSessionStateBagValue(element);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, AgentSessionStateBagValue value, JsonSerializerOptions options)
|
||||
{
|
||||
value.JsonValue.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -27,10 +26,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// <item><description>Storing chat messages with proper ordering and metadata preservation</description></item>
|
||||
/// <item><description>Retrieving messages in chronological order for agent context</description></item>
|
||||
/// <item><description>Managing storage limits through truncation, summarization, or other strategies</description></item>
|
||||
/// <item><description>Supporting serialization for thread persistence and migration</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="ChatHistoryProvider"/> is passed a reference to the <see cref="AgentSession"/> via <see cref="InvokingContext"/> and <see cref="InvokedContext"/>
|
||||
/// allowing it to store state in the <see cref="AgentSession.StateBag"/>. Since a <see cref="ChatHistoryProvider"/> is used with many different sessions, it should
|
||||
/// not store any session-specific information within its own instance fields. Instead, any session-specific state should be stored in the associated <see cref="AgentSession.StateBag"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <see cref="ChatHistoryProvider"/> is only relevant for scenarios where the underlying AI service that the agent is using
|
||||
/// does not use in-service chat history storage.
|
||||
/// </para>
|
||||
@@ -80,10 +83,6 @@ public abstract class ChatHistoryProvider
|
||||
/// <item><description>Archiving old messages while keeping active conversation context</description></item>
|
||||
/// </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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -198,13 +197,6 @@ public abstract class ChatHistoryProvider
|
||||
/// </remarks>
|
||||
protected abstract ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
public abstract JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null);
|
||||
|
||||
/// <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>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -65,10 +64,4 @@ public sealed class ChatHistoryProviderMessageFilter : ChatHistoryProvider
|
||||
|
||||
return this._innerProvider.InvokedAsync(context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return this._innerProvider.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for an <see cref="AgentSession"/> that maintain all chat history in local memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="InMemoryAgentSession"/> is designed for scenarios where chat history should be stored locally
|
||||
/// rather than in external services or databases. This approach provides high performance and simplicity while
|
||||
/// maintaining full control over the conversation data.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In-memory threads do not persist conversation data across application restarts
|
||||
/// unless explicitly serialized and restored.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract class InMemoryAgentSession : AgentSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryAgentSession"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatHistoryProvider">
|
||||
/// An optional <see cref="InMemoryChatHistoryProvider"/> instance to use for storing chat messages.
|
||||
/// If <see langword="null"/>, a new empty <see cref="InMemoryChatHistoryProvider"/> will be created.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// This constructor allows sharing of <see cref="ChatHistoryProvider"/> between sessions or providing pre-configured
|
||||
/// <see cref="ChatHistoryProvider"/> with specific reduction or processing logic.
|
||||
/// </remarks>
|
||||
protected InMemoryAgentSession(InMemoryChatHistoryProvider? chatHistoryProvider = null)
|
||||
{
|
||||
this.ChatHistoryProvider = chatHistoryProvider ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryAgentSession"/> class.
|
||||
/// </summary>
|
||||
/// <param name="messages">The initial messages to populate the conversation history.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor is useful for initializing sessions with existing conversation history or
|
||||
/// for migrating conversations from other storage systems.
|
||||
/// </remarks>
|
||||
protected InMemoryAgentSession(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
this.ChatHistoryProvider = [.. messages];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="chatHistoryProviderFactory">
|
||||
/// Optional factory function to create the <see cref="InMemoryChatHistoryProvider"/> from its serialized state.
|
||||
/// If not provided, a default factory will be used that creates a basic <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor enables restoration of in-memory threads from previously saved state, allowing
|
||||
/// conversations to be resumed across application restarts or migrated between different instances.
|
||||
/// </remarks>
|
||||
protected InMemoryAgentSession(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, InMemoryChatHistoryProvider>? chatHistoryProviderFactory = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentSessionState))) as InMemoryAgentSessionState;
|
||||
|
||||
this.ChatHistoryProvider =
|
||||
chatHistoryProviderFactory?.Invoke(state?.ChatHistoryProviderState ?? default, jsonSerializerOptions) ??
|
||||
new InMemoryChatHistoryProvider(state?.ChatHistoryProviderState ?? default, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="InMemoryChatHistoryProvider"/> used by this thread.
|
||||
/// </summary>
|
||||
public InMemoryChatHistoryProvider ChatHistoryProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
protected internal virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var chatHistoryProviderState = this.ChatHistoryProvider.Serialize(jsonSerializerOptions);
|
||||
|
||||
var state = new InMemoryAgentSessionState
|
||||
{
|
||||
ChatHistoryProviderState = chatHistoryProviderState,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentSessionState)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
base.GetService(serviceType, serviceKey) ?? this.ChatHistoryProvider?.GetService(serviceType, serviceKey);
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay => $"Count = {this.ChatHistoryProvider.Count}";
|
||||
|
||||
internal sealed class InMemoryAgentSessionState
|
||||
{
|
||||
public JsonElement? ChatHistoryProviderState { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -14,99 +13,40 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory implementation of <see cref="ChatHistoryProvider"/> with support for message reduction and collection semantics.
|
||||
/// Provides an in-memory implementation of <see cref="ChatHistoryProvider"/> with support for message reduction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> stores chat messages entirely in local memory, providing fast access and manipulation
|
||||
/// capabilities. It implements both <see cref="ChatHistoryProvider"/> for agent integration and <see cref="IList{ChatMessage}"/>
|
||||
/// for direct collection manipulation.
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> stores chat messages in the <see cref="AgentSession.StateBag"/>,
|
||||
/// providing fast access and manipulation capabilities integrated with session state management.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This <see cref="ChatHistoryProvider"/> maintains all messages in memory. For long-running conversations or high-volume scenarios, consider using
|
||||
/// message reduction strategies or alternative storage implementations.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("Count = {Count}")]
|
||||
[DebuggerTypeProxy(typeof(DebugView))]
|
||||
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider, IList<ChatMessage>, IReadOnlyList<ChatMessage>
|
||||
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
private List<ChatMessage> _messages;
|
||||
private const string DefaultStateBagKey = "InMemoryChatHistoryProvider.State";
|
||||
|
||||
private readonly string _stateKey;
|
||||
private readonly Func<AgentSession?, State> _stateInitializer;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor creates a basic in-memory <see cref="ChatHistoryProvider"/> without message reduction capabilities.
|
||||
/// Messages will be stored exactly as added without any automatic processing or reduction.
|
||||
/// </remarks>
|
||||
public InMemoryChatHistoryProvider()
|
||||
{
|
||||
this._messages = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the provider.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not a valid JSON object or cannot be deserialized.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor enables restoration of messages from previously saved state, allowing
|
||||
/// conversation history to be preserved across application restarts or migrated between instances.
|
||||
/// </remarks>
|
||||
public InMemoryChatHistoryProvider(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: this(null, serializedState, jsonSerializerOptions, ChatReducerTriggerEvent.BeforeMessagesRetrieval)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatReducer">
|
||||
/// A <see cref="IChatReducer"/> instance used to process, reduce, or optimize chat messages.
|
||||
/// This can be used to implement strategies like message summarization, truncation, or cleanup.
|
||||
/// <param name="options">
|
||||
/// Optional configuration options that control the provider's behavior, including state initialization,
|
||||
/// message reduction, and serialization settings. If <see langword="null"/>, default settings will be used.
|
||||
/// </param>
|
||||
/// <param name="reducerTriggerEvent">
|
||||
/// Specifies when the message reducer should be invoked. The default is <see cref="ChatReducerTriggerEvent.BeforeMessagesRetrieval"/>,
|
||||
/// which applies reduction logic when messages are retrieved for agent consumption.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="chatReducer"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// Message reducers enable automatic management of message storage by implementing strategies to
|
||||
/// keep memory usage under control while preserving important conversation context.
|
||||
/// </remarks>
|
||||
public InMemoryChatHistoryProvider(IChatReducer chatReducer, ChatReducerTriggerEvent reducerTriggerEvent = ChatReducerTriggerEvent.BeforeMessagesRetrieval)
|
||||
: this(chatReducer, default, null, reducerTriggerEvent)
|
||||
public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(chatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class, with an existing state from a serialized JSON element.
|
||||
/// </summary>
|
||||
/// <param name="chatReducer">An optional <see cref="IChatReducer"/> instance used to process or reduce chat messages. If null, no reduction logic will be applied.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the provider.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="reducerTriggerEvent">The event that should trigger the reducer invocation.</param>
|
||||
public InMemoryChatHistoryProvider(IChatReducer? chatReducer, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, ChatReducerTriggerEvent reducerTriggerEvent = ChatReducerTriggerEvent.BeforeMessagesRetrieval)
|
||||
{
|
||||
this.ChatReducer = chatReducer;
|
||||
this.ReducerTriggerEvent = reducerTriggerEvent;
|
||||
|
||||
if (serializedState.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
var state = serializedState.Deserialize(
|
||||
jso.GetTypeInfo(typeof(State))) as State;
|
||||
if (state?.Messages is { } messages)
|
||||
{
|
||||
this._messages = messages;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._messages = [];
|
||||
this._stateInitializer = options?.StateInitializer ?? (_ => new State());
|
||||
this.ChatReducer = options?.ChatReducer;
|
||||
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
|
||||
this._stateKey = options?.StateKey ?? DefaultStateBagKey;
|
||||
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,19 +57,49 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider, IList<Cha
|
||||
/// <summary>
|
||||
/// Gets the event that triggers the reducer invocation in this provider.
|
||||
/// </summary>
|
||||
public ChatReducerTriggerEvent ReducerTriggerEvent { get; }
|
||||
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => this._messages.Count;
|
||||
/// <summary>
|
||||
/// Gets the chat messages stored for the specified session.
|
||||
/// </summary>
|
||||
/// <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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsReadOnly => ((IList)this._messages).IsReadOnly;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChatMessage this[int index]
|
||||
/// <summary>
|
||||
/// Sets the chat messages for the specified session.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session containing the state.</param>
|
||||
/// <param name="messages">The messages to store.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
|
||||
{
|
||||
get => this._messages[index];
|
||||
set => this._messages[index] = value;
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
var state = this.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 />
|
||||
@@ -137,12 +107,14 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider, IList<Cha
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
{
|
||||
this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
}
|
||||
|
||||
return this._messages;
|
||||
return state.Messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -155,94 +127,27 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider, IList<Cha
|
||||
return;
|
||||
}
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
this._messages.AddRange(allNewMessages);
|
||||
state.Messages.AddRange(allNewMessages);
|
||||
|
||||
if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
State state = new()
|
||||
{
|
||||
Messages = this._messages,
|
||||
};
|
||||
|
||||
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(State)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int IndexOf(ChatMessage item)
|
||||
=> this._messages.IndexOf(item);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(int index, ChatMessage item)
|
||||
=> this._messages.Insert(index, item);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveAt(int index)
|
||||
=> this._messages.RemoveAt(index);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Add(ChatMessage item)
|
||||
=> this._messages.Add(item);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Clear()
|
||||
=> this._messages.Clear();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Contains(ChatMessage item)
|
||||
=> this._messages.Contains(item);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CopyTo(ChatMessage[] array, int arrayIndex)
|
||||
=> this._messages.CopyTo(array, arrayIndex);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Remove(ChatMessage item)
|
||||
=> this._messages.Remove(item);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<ChatMessage> GetEnumerator()
|
||||
=> this._messages.GetEnumerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
=> this.GetEnumerator();
|
||||
|
||||
internal sealed class State
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of chat messages.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public List<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
public enum ChatReducerTriggerEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Trigger the reducer when a new message is added.
|
||||
/// <see cref="InvokedCoreAsync(InvokedContext, CancellationToken)"/> will only complete when reducer processing is done.
|
||||
/// </summary>
|
||||
AfterMessageAdded,
|
||||
|
||||
/// <summary>
|
||||
/// Trigger the reducer before messages are retrieved from the provider.
|
||||
/// The reducer will process the messages before they are returned to the caller.
|
||||
/// </summary>
|
||||
BeforeMessagesRetrieval
|
||||
}
|
||||
|
||||
private sealed class DebugView(InMemoryChatHistoryProvider provider)
|
||||
{
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
|
||||
public ChatMessage[] Items => provider._messages.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents configuration options for <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional delegate that initializes the provider state on the first invocation.
|
||||
/// If <see langword="null"/>, a default initializer that creates an empty state will be used.
|
||||
/// </summary>
|
||||
public Func<AgentSession?, InMemoryChatHistoryProvider.State>? StateInitializer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional <see cref="IChatReducer"/> instance used to process, reduce, or optimize chat messages.
|
||||
/// This can be used to implement strategies like message summarization, truncation, or cleanup.
|
||||
/// </summary>
|
||||
public IChatReducer? ChatReducer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the message reducer should be invoked.
|
||||
/// The default is <see cref="ChatReducerTriggerEvent.BeforeMessagesRetrieval"/>,
|
||||
/// which applies reduction logic when messages are retrieved for agent consumption.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message reducers enable automatic management of message storage by implementing strategies to
|
||||
/// keep memory usage under control while preserving important conversation context.
|
||||
/// </remarks>
|
||||
public ChatReducerTriggerEvent ReducerTriggerEvent { get; set; } = ChatReducerTriggerEvent.BeforeMessagesRetrieval;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.
|
||||
/// If <see langword="null"/>, a default key will be used.
|
||||
/// </summary>
|
||||
public string? StateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional JSON serializer options for serializing the state of this provider.
|
||||
/// This is valuable for cases like when the chat history contains custom <see cref="AIContent"/> types
|
||||
/// and source generated serializers are required, or Native AOT / Trimming is required.
|
||||
/// </summary>
|
||||
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
public enum ChatReducerTriggerEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Trigger the reducer when a new message is added.
|
||||
/// <see cref="AIContextProvider.InvokedAsync"/> will only complete when reducer processing is done.
|
||||
/// </summary>
|
||||
AfterMessageAdded,
|
||||
|
||||
/// <summary>
|
||||
/// Trigger the reducer before messages are retrieved from the provider.
|
||||
/// The reducer will process the messages before they are returned to the caller.
|
||||
/// </summary>
|
||||
BeforeMessagesRetrieval
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a base class for agent sessions that store conversation state remotely in a service and maintain only an identifier reference locally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class is designed for scenarios where conversation state is managed by an external service (such as a cloud-based AI service)
|
||||
/// rather than being stored locally. The session maintains only the service identifier needed to reference the remote conversation state.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("ServiceSessionId = {ServiceSessionId}")]
|
||||
public abstract class ServiceIdAgentSession : AgentSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServiceIdAgentSession"/> class without a service session identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When using this constructor, the <see cref="ServiceSessionId"/> will be <see langword="null"/> initially
|
||||
/// and should be set by derived classes when the remote conversation is created.
|
||||
/// </remarks>
|
||||
protected ServiceIdAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServiceIdAgentSession"/> class with the specified service session identifier.
|
||||
/// </summary>
|
||||
/// <param name="serviceSessionId">The unique identifier that references the conversation state stored in the remote service.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceSessionId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="serviceSessionId"/> is empty or contains only whitespace.</exception>
|
||||
protected ServiceIdAgentSession(string serviceSessionId)
|
||||
{
|
||||
this.ServiceSessionId = Throw.IfNullOrEmpty(serviceSessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServiceIdAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor enables restoration of a service-backed session from serialized state, typically used
|
||||
/// when deserializing session information that was previously saved or transmitted across application boundaries.
|
||||
/// </remarks>
|
||||
protected ServiceIdAgentSession(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentSessionState))) as ServiceIdAgentSessionState;
|
||||
|
||||
if (state?.ServiceSessionId is string serviceSessionId)
|
||||
{
|
||||
this.ServiceSessionId = serviceSessionId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier that references the conversation state stored in the remote service.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A string identifier that uniquely identifies the conversation within the remote service,
|
||||
/// or <see langword="null"/> if no remote conversation has been established yet.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// This identifier is used by derived classes to reference the remote conversation state when making
|
||||
/// API calls to the backing service. The exact format and meaning of this identifier depends on the
|
||||
/// specific service implementation.
|
||||
/// </remarks>
|
||||
protected string? ServiceSessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
protected internal virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var state = new ServiceIdAgentSessionState
|
||||
{
|
||||
ServiceSessionId = this.ServiceSessionId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentSessionState)));
|
||||
}
|
||||
|
||||
internal sealed class ServiceIdAgentSessionState
|
||||
{
|
||||
public string? ServiceSessionId { get; set; }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -191,8 +191,8 @@ public static class PersistentAgentsClientExtensions
|
||||
Name = options.Name ?? persistentAgentMetadata.Name,
|
||||
Description = options.Description ?? persistentAgentMetadata.Description,
|
||||
ChatOptions = options.ChatOptions,
|
||||
AIContextProviderFactory = options.AIContextProviderFactory,
|
||||
ChatHistoryProviderFactory = options.ChatHistoryProviderFactory,
|
||||
AIContextProvider = options.AIContextProvider,
|
||||
ChatHistoryProvider = options.ChatHistoryProvider,
|
||||
UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs
|
||||
};
|
||||
|
||||
|
||||
@@ -589,8 +589,8 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools);
|
||||
if (options is not null)
|
||||
{
|
||||
agentOptions.AIContextProviderFactory = options.AIContextProviderFactory;
|
||||
agentOptions.ChatHistoryProviderFactory = options.ChatHistoryProviderFactory;
|
||||
agentOptions.AIContextProvider = options.AIContextProvider;
|
||||
agentOptions.ChatHistoryProvider = options.ChatHistoryProvider;
|
||||
agentOptions.UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ public class CopilotStudioAgent : AIAgent
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new CopilotStudioAgentSession(serializedState, jsonSerializerOptions));
|
||||
=> new(CopilotStudioAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.CopilotStudio;
|
||||
|
||||
/// <summary>
|
||||
/// Session for CopilotStudio based agents.
|
||||
/// </summary>
|
||||
public sealed class CopilotStudioAgentSession : ServiceIdAgentSession
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class CopilotStudioAgentSession : AgentSession
|
||||
{
|
||||
internal CopilotStudioAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
internal CopilotStudioAgentSession(JsonElement serializedSessionState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedSessionState, jsonSerializerOptions)
|
||||
[JsonConstructor]
|
||||
internal CopilotStudioAgentSession(string? conversationId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
{
|
||||
this.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID for the current conversation with the Copilot Studio agent.
|
||||
/// </summary>
|
||||
public string? ConversationId
|
||||
{
|
||||
get { return this.ServiceSessionId; }
|
||||
internal set { this.ServiceSessionId = value; }
|
||||
}
|
||||
[JsonPropertyName("serviceSessionId")]
|
||||
public string? ConversationId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var jso = jsonSerializerOptions ?? CopilotStudioJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(CopilotStudioAgentSession)));
|
||||
}
|
||||
|
||||
internal static CopilotStudioAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var jso = jsonSerializerOptions ?? CopilotStudioJsonUtilities.DefaultOptions;
|
||||
return serializedState.Deserialize(jso.GetTypeInfo(typeof(CopilotStudioAgentSession))) as CopilotStudioAgentSession
|
||||
?? new CopilotStudioAgentSession();
|
||||
}
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
$"ConversationId = {this.ConversationId}, StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.CopilotStudio;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utility methods and configurations for JSON serialization operations within the Copilot Studio agent implementation.
|
||||
/// </summary>
|
||||
internal static partial class CopilotStudioJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for JSON serialization operations.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates and configures the default JSON serialization options.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
// Copy the configuration from the source generated context.
|
||||
JsonSerializerOptions options = new(JsonContext.Default.Options)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and our source generated context.
|
||||
options.TypeInfoResolverChain.Clear();
|
||||
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(CopilotStudioAgentSession))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -21,17 +21,15 @@ namespace Microsoft.Agents.AI;
|
||||
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
{
|
||||
private const string DefaultStateBagKey = "CosmosChatHistoryProvider.State";
|
||||
|
||||
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;
|
||||
|
||||
// Hierarchical partition key support
|
||||
private readonly string? _tenantId;
|
||||
private readonly string? _userId;
|
||||
private readonly PartitionKey _partitionKey;
|
||||
private readonly bool _useHierarchicalPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Cached JSON serializer options for .NET 9.0 compatibility.
|
||||
/// </summary>
|
||||
@@ -72,11 +70,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// </summary>
|
||||
public int? MessageTtlSeconds { get; set; } = 86400;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation ID associated with this provider.
|
||||
/// </summary>
|
||||
public string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the database ID associated with this provider.
|
||||
/// </summary>
|
||||
@@ -88,36 +81,31 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
public string ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal primary constructor used by all public constructors.
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <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="tenantId">Optional tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">Optional user identifier for hierarchical partitioning.</param>
|
||||
internal CosmosChatHistoryProvider(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId, bool ownsClient, string? tenantId = null, string? userId = null)
|
||||
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</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(
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId,
|
||||
Func<AgentSession?, State> stateInitializer,
|
||||
bool ownsClient = false,
|
||||
string? stateKey = null)
|
||||
{
|
||||
this._cosmosClient = Throw.IfNull(cosmosClient);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
|
||||
this.DatabaseId = databaseId;
|
||||
this.ContainerId = containerId;
|
||||
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;
|
||||
|
||||
// Initialize partitioning mode
|
||||
this._tenantId = tenantId;
|
||||
this._userId = userId;
|
||||
this._useHierarchicalPartitioning = tenantId != null && userId != null;
|
||||
|
||||
this._partitionKey = this._useHierarchicalPartitioning
|
||||
? new PartitionKeyBuilder()
|
||||
.Add(tenantId!)
|
||||
.Add(userId!)
|
||||
.Add(conversationId)
|
||||
.Build()
|
||||
: new PartitionKey(conversationId);
|
||||
this._stateKey = stateKey ?? DefaultStateBagKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,24 +114,17 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <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>
|
||||
/// <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(string connectionString, string databaseId, string containerId)
|
||||
: this(connectionString, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</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(string connectionString, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
public CosmosChatHistoryProvider(
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId,
|
||||
Func<AgentSession?, State> stateInitializer,
|
||||
string? stateKey = null)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -154,136 +135,63 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <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>
|
||||
/// <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(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
: this(accountEndpoint, tokenCredential, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
public CosmosChatHistoryProvider(
|
||||
string accountEndpoint,
|
||||
TokenCredential tokenCredential,
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a TokenCredential for authentication.
|
||||
/// Gets the state from the session's StateBag, or initializes it using the state initializer if not present.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</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(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
/// <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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatHistoryProvider(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
: this(cosmosClient, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatHistoryProvider(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId)
|
||||
: this(cosmosClient, databaseId, containerId, conversationId, ownsClient: false)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a connection string with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</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(string connectionString, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a TokenCredential for authentication with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</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(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using an existing <see cref="CosmosClient"/> with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatHistoryProvider(CosmosClient cosmosClient, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(cosmosClient, databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: false, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="CosmosChatHistoryProvider"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the provider.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosChatHistoryProvider"/> initialized from the serialized state.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the serialized state cannot be deserialized.</exception>
|
||||
public static CosmosChatHistoryProvider CreateFromSerializedState(CosmosClient cosmosClient, JsonElement serializedState, string databaseId, string containerId, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
Throw.IfNull(cosmosClient);
|
||||
Throw.IfNullOrWhitespace(databaseId);
|
||||
Throw.IfNullOrWhitespace(containerId);
|
||||
|
||||
if (serializedState.ValueKind is not JsonValueKind.Object)
|
||||
if (session?.StateBag.TryGetValue<State>(this._stateKey, out var state, AgentAbstractionsJsonUtilities.DefaultOptions) is true && state is not null)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedState));
|
||||
return state;
|
||||
}
|
||||
|
||||
var state = serializedState.Deserialize<State>(jsonSerializerOptions);
|
||||
if (state?.ConversationIdentifier is not { } conversationId)
|
||||
state = this._stateInitializer(session);
|
||||
if (session is not null)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedState));
|
||||
session.StateBag.SetValue(this._stateKey, state, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
// Use the internal constructor with all parameters to ensure partition key logic is centralized
|
||||
return state.UseHierarchicalPartitioning && state.TenantId != null && state.UserId != null
|
||||
? new CosmosChatHistoryProvider(cosmosClient, databaseId, containerId, conversationId, ownsClient: false, state.TenantId, state.UserId)
|
||||
: new CosmosChatHistoryProvider(cosmosClient, databaseId, containerId, conversationId, ownsClient: false);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether hierarchical partitioning should be used based on the state.
|
||||
/// </summary>
|
||||
private static bool UseHierarchicalPartitioning(State state) =>
|
||||
state.TenantId is not null && state.UserId is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the partition key from the state.
|
||||
/// </summary>
|
||||
private static PartitionKey BuildPartitionKey(State state)
|
||||
{
|
||||
if (UseHierarchicalPartitioning(state))
|
||||
{
|
||||
return new PartitionKeyBuilder()
|
||||
.Add(state.TenantId)
|
||||
.Add(state.UserId)
|
||||
.Add(state.ConversationId)
|
||||
.Build();
|
||||
}
|
||||
|
||||
return new PartitionKey(state.ConversationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -296,15 +204,20 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var partitionKey = BuildPartitionKey(state);
|
||||
|
||||
// Fetch most recent messages in descending order when limit is set, then reverse to ascending
|
||||
var orderDirection = this.MaxMessagesToRetrieve.HasValue ? "DESC" : "ASC";
|
||||
var query = new QueryDefinition($"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type ORDER BY c.timestamp {orderDirection}")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@conversationId", state.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<CosmosMessageDocument>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
PartitionKey = partitionKey,
|
||||
MaxItemCount = this.MaxItemCount // Configurable query performance
|
||||
});
|
||||
|
||||
@@ -364,27 +277,30 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList();
|
||||
if (messageList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var partitionKey = BuildPartitionKey(state);
|
||||
|
||||
// Use transactional batch for atomic operations
|
||||
if (messageList.Count > 1)
|
||||
{
|
||||
await this.AddMessagesInBatchAsync(messageList, cancellationToken).ConfigureAwait(false);
|
||||
await this.AddMessagesInBatchAsync(partitionKey, state, messageList, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AddSingleMessageAsync(messageList.First(), cancellationToken).ConfigureAwait(false);
|
||||
await this.AddSingleMessageAsync(partitionKey, state, messageList.First(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple messages using transactional batch operations for atomicity.
|
||||
/// </summary>
|
||||
private async Task AddMessagesInBatchAsync(List<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
private async Task AddMessagesInBatchAsync(PartitionKey partitionKey, State state, List<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
@@ -392,7 +308,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
for (int i = 0; i < messages.Count; i += this.MaxBatchSize)
|
||||
{
|
||||
var batchMessages = messages.Skip(i).Take(this.MaxBatchSize).ToList();
|
||||
await this.ExecuteBatchOperationAsync(batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(partitionKey, state, batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,13 +316,13 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// Executes a single batch operation with enhanced error handling.
|
||||
/// Cosmos SDK handles throttling (429) retries automatically.
|
||||
/// </summary>
|
||||
private async Task ExecuteBatchOperationAsync(List<ChatMessage> messages, long timestamp, CancellationToken cancellationToken)
|
||||
private async Task ExecuteBatchOperationAsync(PartitionKey partitionKey, State state, List<ChatMessage> messages, long timestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
// Create all documents upfront for validation and batch operation
|
||||
var documents = new List<CosmosMessageDocument>(messages.Count);
|
||||
foreach (var message in messages)
|
||||
{
|
||||
documents.Add(this.CreateMessageDocument(message, timestamp));
|
||||
documents.Add(this.CreateMessageDocument(state, message, timestamp));
|
||||
}
|
||||
|
||||
// Defensive check: Verify all messages share the same partition key values
|
||||
@@ -414,7 +330,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
// In simple partitioning, this means same conversationId
|
||||
if (documents.Count > 0)
|
||||
{
|
||||
if (this._useHierarchicalPartitioning)
|
||||
if (UseHierarchicalPartitioning(state))
|
||||
{
|
||||
// Verify all documents have matching hierarchical partition key components
|
||||
var firstDoc = documents[0];
|
||||
@@ -436,7 +352,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
|
||||
// All messages in this store share the same partition key by design
|
||||
// Transactional batches require all items to share the same partition key
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
var batch = this._container.CreateTransactionalBatch(partitionKey);
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
@@ -457,7 +373,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
if (messages.Count == 1)
|
||||
{
|
||||
// Can't split further, use single operation
|
||||
await this.AddSingleMessageAsync(messages[0], cancellationToken).ConfigureAwait(false);
|
||||
await this.AddSingleMessageAsync(partitionKey, state, messages[0], cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,21 +382,21 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
var firstHalf = messages.Take(midpoint).ToList();
|
||||
var secondHalf = messages.Skip(midpoint).ToList();
|
||||
|
||||
await this.ExecuteBatchOperationAsync(firstHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(secondHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(partitionKey, state, firstHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(partitionKey, state, secondHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single message to the store.
|
||||
/// </summary>
|
||||
private async Task AddSingleMessageAsync(ChatMessage message, CancellationToken cancellationToken)
|
||||
private async Task AddSingleMessageAsync(PartitionKey partitionKey, State state, ChatMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = this.CreateMessageDocument(message, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
var document = this.CreateMessageDocument(state, message, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
|
||||
try
|
||||
{
|
||||
await this._container.CreateItemAsync(document, this._partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await this._container.CreateItemAsync(document, partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
|
||||
{
|
||||
@@ -495,12 +411,14 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// <summary>
|
||||
/// Creates a message document with enhanced metadata.
|
||||
/// </summary>
|
||||
private CosmosMessageDocument CreateMessageDocument(ChatMessage message, long timestamp)
|
||||
private CosmosMessageDocument CreateMessageDocument(State state, ChatMessage message, long timestamp)
|
||||
{
|
||||
var useHierarchical = UseHierarchicalPartitioning(state);
|
||||
|
||||
return new CosmosMessageDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ConversationId = this.ConversationId,
|
||||
ConversationId = state.ConversationId,
|
||||
Timestamp = timestamp,
|
||||
MessageId = message.MessageId,
|
||||
Role = message.Role.Value,
|
||||
@@ -508,41 +426,20 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
Type = "ChatMessage", // Type discriminator
|
||||
Ttl = this.MessageTtlSeconds, // Configurable TTL
|
||||
// Include hierarchical metadata when using hierarchical partitioning
|
||||
TenantId = this._useHierarchicalPartitioning ? this._tenantId : null,
|
||||
UserId = this._useHierarchicalPartitioning ? this._userId : null,
|
||||
SessionId = this._useHierarchicalPartitioning ? this.ConversationId : null
|
||||
TenantId = useHierarchical ? state.TenantId : null,
|
||||
UserId = useHierarchical ? state.UserId : null,
|
||||
SessionId = useHierarchical ? state.ConversationId : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = new State
|
||||
{
|
||||
ConversationIdentifier = this.ConversationId,
|
||||
TenantId = this._tenantId,
|
||||
UserId = this._userId,
|
||||
UseHierarchicalPartitioning = this._useHierarchicalPartitioning
|
||||
};
|
||||
|
||||
var options = jsonSerializerOptions ?? s_defaultJsonOptions;
|
||||
return JsonSerializer.SerializeToElement(state, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session to get state from.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages in the conversation.</returns>
|
||||
public async Task<int> GetMessageCountAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<int> GetMessageCountAsync(AgentSession? session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
@@ -551,14 +448,17 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = this.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")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@conversationId", state.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<int>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey
|
||||
PartitionKey = partitionKey
|
||||
});
|
||||
|
||||
// COUNT queries always return a result
|
||||
@@ -570,9 +470,10 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
/// Deletes all messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session to get state from.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages deleted.</returns>
|
||||
public async Task<int> ClearMessagesAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<int> ClearMessagesAsync(AgentSession? session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
@@ -581,14 +482,17 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = this.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")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@conversationId", state.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<string>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
PartitionKey = partitionKey,
|
||||
MaxItemCount = this.MaxItemCount
|
||||
});
|
||||
|
||||
@@ -597,7 +501,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
var batch = this._container.CreateTransactionalBatch(partitionKey);
|
||||
var batchItemCount = 0;
|
||||
|
||||
foreach (var itemId in response)
|
||||
@@ -632,12 +536,38 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class State
|
||||
/// <summary>
|
||||
/// Represents the per-session state of a <see cref="CosmosChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
public string ConversationIdentifier { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public bool UseHierarchicalPartitioning { get; set; }
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="State"/> class.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">Optional user identifier for hierarchical partitioning.</param>
|
||||
public State(string conversationId, string? tenantId = null, string? userId = null)
|
||||
{
|
||||
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
|
||||
this.TenantId = tenantId;
|
||||
this.UserId = userId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation ID associated with this state.
|
||||
/// </summary>
|
||||
public string ConversationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier for hierarchical partitioning, if any.
|
||||
/// </summary>
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user identifier for hierarchical partitioning, if any.
|
||||
/// </summary>
|
||||
public string? UserId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
@@ -13,6 +12,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// </summary>
|
||||
public static class CosmosDBChatExtensions
|
||||
{
|
||||
private static readonly Func<AgentSession?, CosmosChatHistoryProvider.State> s_defaultStateInitializer =
|
||||
_ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with connection string authentication.
|
||||
/// </summary>
|
||||
@@ -20,6 +22,7 @@ public static class CosmosDBChatExtensions
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
@@ -29,14 +32,16 @@ public static class CosmosDBChatExtensions
|
||||
this ChatClientAgentOptions options,
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
string containerId,
|
||||
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatHistoryProviderFactory = (context, ct) => new ValueTask<ChatHistoryProvider>(new CosmosChatHistoryProvider(connectionString, databaseId, containerId));
|
||||
options.ChatHistoryProvider =
|
||||
new CosmosChatHistoryProvider(connectionString, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -48,6 +53,7 @@ public static class CosmosDBChatExtensions
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="tokenCredential"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
@@ -58,7 +64,8 @@ public static class CosmosDBChatExtensions
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId,
|
||||
TokenCredential tokenCredential)
|
||||
TokenCredential tokenCredential,
|
||||
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
@@ -70,7 +77,8 @@ public static class CosmosDBChatExtensions
|
||||
throw new ArgumentNullException(nameof(tokenCredential));
|
||||
}
|
||||
|
||||
options.ChatHistoryProviderFactory = (context, ct) => new ValueTask<ChatHistoryProvider>(new CosmosChatHistoryProvider(accountEndpoint, tokenCredential, databaseId, containerId));
|
||||
options.ChatHistoryProvider =
|
||||
new CosmosChatHistoryProvider(accountEndpoint, tokenCredential, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -81,6 +89,7 @@ public static class CosmosDBChatExtensions
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
@@ -90,14 +99,16 @@ public static class CosmosDBChatExtensions
|
||||
this ChatClientAgentOptions options,
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
string containerId,
|
||||
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatHistoryProviderFactory = (context, ct) => new ValueTask<ChatHistoryProvider>(new CosmosChatHistoryProvider(cosmosClient, databaseId, containerId));
|
||||
options.ChatHistoryProvider =
|
||||
new CosmosChatHistoryProvider(cosmosClient, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,22 @@ using System.Text.Json.Serialization;
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// An agent thread implementation for durable agents.
|
||||
/// An <see cref="AgentSession"/> implementation for durable agents.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{SessionId}")]
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class DurableAgentSession : AgentSession
|
||||
{
|
||||
[JsonConstructor]
|
||||
internal DurableAgentSession(AgentSessionId sessionId)
|
||||
{
|
||||
this.SessionId = sessionId;
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
internal DurableAgentSession(AgentSessionId sessionId, AgentSessionStateBag stateBag) : base(stateBag)
|
||||
{
|
||||
this.SessionId = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session ID.
|
||||
/// </summary>
|
||||
@@ -28,9 +33,8 @@ public sealed class DurableAgentSession : AgentSession
|
||||
/// <inheritdoc/>
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(
|
||||
this,
|
||||
DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(DurableAgentSession)));
|
||||
var jso = jsonSerializerOptions ?? DurableAgentJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(DurableAgentSession)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +53,11 @@ public sealed class DurableAgentSession : AgentSession
|
||||
|
||||
string sessionIdString = sessionIdElement.GetString() ?? throw new JsonException("sessionId property is null.");
|
||||
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
|
||||
return new DurableAgentSession(sessionId);
|
||||
AgentSessionStateBag stateBag = serializedSession.TryGetProperty("stateBag", out JsonElement stateBagElement)
|
||||
? AgentSessionStateBag.Deserialize(stateBagElement)
|
||||
: new AgentSessionStateBag();
|
||||
|
||||
return new DurableAgentSession(sessionId, stateBag);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -68,4 +76,8 @@ public sealed class DurableAgentSession : AgentSession
|
||||
{
|
||||
return this.SessionId.ToString();
|
||||
}
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
$"SessionId = {this.SessionId}, StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(new GitHubCopilotAgentSession(serializedState, jsonSerializerOptions));
|
||||
=> new(GitHubCopilotAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session for a GitHub Copilot agent conversation.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class GitHubCopilotAgentSession : AgentSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the session ID for the GitHub Copilot conversation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string? SessionId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -21,35 +26,32 @@ public sealed class GitHubCopilotAgentSession : AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GitHubCopilotAgentSession"/> class from serialized data.
|
||||
/// </summary>
|
||||
/// <param name="serializedThread">The serialized thread data.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serialization options.</param>
|
||||
internal GitHubCopilotAgentSession(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
[JsonConstructor]
|
||||
internal GitHubCopilotAgentSession(string? sessionId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
{
|
||||
// The JSON serialization uses camelCase
|
||||
if (serializedThread.TryGetProperty("sessionId", out JsonElement sessionIdElement))
|
||||
{
|
||||
this.SessionId = sessionIdElement.GetString();
|
||||
}
|
||||
this.SessionId = sessionId;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
State state = new()
|
||||
{
|
||||
SessionId = this.SessionId
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(
|
||||
state,
|
||||
GitHubCopilotJsonUtilities.DefaultOptions.GetTypeInfo(typeof(State)));
|
||||
var jso = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(GitHubCopilotAgentSession)));
|
||||
}
|
||||
|
||||
internal sealed class State
|
||||
internal static GitHubCopilotAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var jso = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
|
||||
return serializedState.Deserialize(jso.GetTypeInfo(typeof(GitHubCopilotAgentSession))) as GitHubCopilotAgentSession
|
||||
?? new GitHubCopilotAgentSession();
|
||||
}
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
$"SessionId = {this.SessionId}, StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ internal static partial class GitHubCopilotJsonUtilities
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(GitHubCopilotAgentSession.State))]
|
||||
[JsonSerializable(typeof(GitHubCopilotAgentSession))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public static partial class Mem0JsonUtilities
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
|
||||
// Agent abstraction types
|
||||
[JsonSerializable(typeof(Mem0Provider.Mem0State))]
|
||||
[JsonSerializable(typeof(Mem0Provider.State))]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -26,24 +25,24 @@ namespace Microsoft.Agents.AI.Mem0;
|
||||
public sealed class Mem0Provider : AIContextProvider
|
||||
{
|
||||
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
|
||||
private const string DefaultStateBagKey = "Mem0Provider.State";
|
||||
|
||||
private readonly string _contextPrompt;
|
||||
private readonly bool _enableSensitiveTelemetryData;
|
||||
private readonly string _stateKey;
|
||||
private readonly Func<AgentSession?, State> _stateInitializer;
|
||||
|
||||
private readonly Mem0Client _client;
|
||||
private readonly ILogger<Mem0Provider>? _logger;
|
||||
|
||||
private readonly Mem0ProviderScope _storageScope;
|
||||
private readonly Mem0ProviderScope _searchScope;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Mem0Provider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
|
||||
/// <param name="storageScope">Optional values to scope the memory storage with.</param>
|
||||
/// <param name="searchScope">Optional values to scope the memory search with. Defaults to <paramref name="storageScope"/> if not provided.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the storage and search scopes.</param>
|
||||
/// <param name="options">Provider options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="httpClient"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The base address of the required mem0 service, and any authentication headers, should be set on the <paramref name="httpClient"/>
|
||||
/// already, when passed as a parameter here. E.g.:
|
||||
@@ -51,83 +50,55 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
/// using var httpClient = new HttpClient();
|
||||
/// httpClient.BaseAddress = new Uri("https://api.mem0.ai");
|
||||
/// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "<Your APIKey>");
|
||||
/// new Mem0AIContextProvider(httpClient);
|
||||
/// new Mem0Provider(httpClient);
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public Mem0Provider(HttpClient httpClient, Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
public Mem0Provider(HttpClient httpClient, Func<AgentSession?, State> stateInitializer, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
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._storageScope = new Mem0ProviderScope(Throw.IfNull(storageScope));
|
||||
this._searchScope = searchScope ?? storageScope;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(this._storageScope.ApplicationId)
|
||||
&& string.IsNullOrWhiteSpace(this._storageScope.AgentId)
|
||||
&& string.IsNullOrWhiteSpace(this._storageScope.ThreadId)
|
||||
&& string.IsNullOrWhiteSpace(this._storageScope.UserId))
|
||||
{
|
||||
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(this._searchScope.ApplicationId)
|
||||
&& string.IsNullOrWhiteSpace(this._searchScope.AgentId)
|
||||
&& string.IsNullOrWhiteSpace(this._searchScope.ThreadId)
|
||||
&& string.IsNullOrWhiteSpace(this._searchScope.UserId))
|
||||
{
|
||||
throw new ArgumentException("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.");
|
||||
}
|
||||
this._stateKey = options?.StateKey ?? DefaultStateBagKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Mem0Provider"/> class, with existing state from a serialized JSON element.
|
||||
/// Gets the state from the session's StateBag, or initializes it using the StateInitializer if not present.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Configured <see cref="HttpClient"/> (base address + auth).</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the store.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="options">Provider options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentException"></exception>
|
||||
/// <remarks>
|
||||
/// The base address of the required mem0 service, and any authentication headers, should be set on the <paramref name="httpClient"/>
|
||||
/// already, when passed as a parameter here. E.g.:
|
||||
/// <code>
|
||||
/// using var httpClient = new HttpClient();
|
||||
/// httpClient.BaseAddress = new Uri("https://api.mem0.ai");
|
||||
/// httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", "<Your APIKey>");
|
||||
/// new Mem0AIContextProvider(httpClient, state);
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, Mem0ProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
/// <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 (string.IsNullOrWhiteSpace(httpClient.BaseAddress?.AbsoluteUri))
|
||||
if (session?.StateBag.TryGetValue<State>(this._stateKey, out var state, Mem0JsonUtilities.DefaultOptions) is true && state is not null)
|
||||
{
|
||||
throw new ArgumentException("The HttpClient BaseAddress must be set for Mem0 operations.", nameof(httpClient));
|
||||
return state;
|
||||
}
|
||||
|
||||
this._logger = loggerFactory?.CreateLogger<Mem0Provider>();
|
||||
this._client = new Mem0Client(httpClient);
|
||||
state = this._stateInitializer(session);
|
||||
|
||||
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
|
||||
this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false;
|
||||
|
||||
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
|
||||
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(Mem0State))) as Mem0State;
|
||||
|
||||
if (state == null || state.StorageScope == null || state.SearchScope == null)
|
||||
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("The Mem0Provider state did not contain the required scope properties.");
|
||||
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.");
|
||||
}
|
||||
|
||||
this._storageScope = state.StorageScope;
|
||||
this._searchScope = state.SearchScope;
|
||||
if (session is not null)
|
||||
{
|
||||
session.StateBag.SetValue(this._stateKey, state, Mem0JsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -135,6 +106,9 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
{
|
||||
Throw.IfNull(context);
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var searchScope = state?.SearchScope ?? new Mem0ProviderScope();
|
||||
|
||||
string queryText = string.Join(
|
||||
Environment.NewLine,
|
||||
context.RequestMessages
|
||||
@@ -145,10 +119,10 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
try
|
||||
{
|
||||
var memories = (await this._client.SearchAsync(
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.ThreadId,
|
||||
this._searchScope.UserId,
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.ThreadId,
|
||||
searchScope.UserId,
|
||||
queryText,
|
||||
cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
@@ -161,10 +135,10 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
this._logger.LogInformation(
|
||||
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.",
|
||||
memories.Count,
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.ThreadId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.ThreadId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
|
||||
if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
@@ -172,10 +146,10 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.",
|
||||
this.SanitizeLogData(queryText),
|
||||
this.SanitizeLogData(outputMessageText),
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.ThreadId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.ThreadId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +169,10 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.",
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.ThreadId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.ThreadId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
}
|
||||
return new AIContext();
|
||||
}
|
||||
@@ -212,10 +186,14 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
return; // Do not update memory on failed invocations.
|
||||
}
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var storageScope = state?.StorageScope ?? new Mem0ProviderScope();
|
||||
|
||||
try
|
||||
{
|
||||
// Persist request and response messages after invocation.
|
||||
await this.PersistMessagesAsync(
|
||||
storageScope,
|
||||
context.RequestMessages
|
||||
.Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External)
|
||||
.Concat(context.ResponseMessages ?? []),
|
||||
@@ -228,36 +206,39 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.",
|
||||
this._storageScope.ApplicationId,
|
||||
this._storageScope.AgentId,
|
||||
this._storageScope.ThreadId,
|
||||
this.SanitizeLogData(this._storageScope.UserId));
|
||||
storageScope.ApplicationId,
|
||||
storageScope.AgentId,
|
||||
storageScope.ThreadId,
|
||||
this.SanitizeLogData(storageScope.UserId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears stored memories for the configured scopes.
|
||||
/// Clears stored memories for the specified scope.
|
||||
/// </summary>
|
||||
/// <param name="session">The session containing the scope state to clear memories for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) =>
|
||||
this._client.ClearMemoryAsync(
|
||||
this._storageScope.ApplicationId,
|
||||
this._storageScope.AgentId,
|
||||
this._storageScope.ThreadId,
|
||||
this._storageScope.UserId,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
public Task ClearStoredMemoriesAsync(AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = new Mem0State(this._storageScope, this._searchScope);
|
||||
Throw.IfNull(session);
|
||||
var state = this.GetOrInitializeState(session);
|
||||
var storageScope = state?.StorageScope;
|
||||
|
||||
var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(Mem0State)));
|
||||
if (storageScope is null)
|
||||
{
|
||||
return Task.CompletedTask; // Nothing to clear if there is no state.
|
||||
}
|
||||
|
||||
return this._client.ClearMemoryAsync(
|
||||
storageScope.ApplicationId,
|
||||
storageScope.AgentId,
|
||||
storageScope.ThreadId,
|
||||
storageScope.UserId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PersistMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
private async Task PersistMessagesAsync(Mem0ProviderScope storageScope, IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var message in messages)
|
||||
{
|
||||
@@ -277,27 +258,42 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
}
|
||||
|
||||
await this._client.CreateMemoryAsync(
|
||||
this._storageScope.ApplicationId,
|
||||
this._storageScope.AgentId,
|
||||
this._storageScope.ThreadId,
|
||||
this._storageScope.UserId,
|
||||
storageScope.ApplicationId,
|
||||
storageScope.AgentId,
|
||||
storageScope.ThreadId,
|
||||
storageScope.UserId,
|
||||
message.Text,
|
||||
message.Role.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Mem0State
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="Mem0Provider"/> 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 storage and search scopes.
|
||||
/// </summary>
|
||||
/// <param name="storageScope">The scope to use when storing memories.</param>
|
||||
/// <param name="searchScope">The scope to use when searching for memories. If null, the storage scope will be used for searching as well.</param>
|
||||
[JsonConstructor]
|
||||
public Mem0State(Mem0ProviderScope storageScope, Mem0ProviderScope searchScope)
|
||||
public State(Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = null)
|
||||
{
|
||||
this.StorageScope = storageScope;
|
||||
this.SearchScope = searchScope;
|
||||
this.StorageScope = Throw.IfNull(storageScope);
|
||||
this.SearchScope = searchScope ?? storageScope;
|
||||
}
|
||||
|
||||
public Mem0ProviderScope StorageScope { get; set; }
|
||||
public Mem0ProviderScope SearchScope { get; set; }
|
||||
/// <summary>
|
||||
/// Gets the scope used when storing memories.
|
||||
/// </summary>
|
||||
public Mem0ProviderScope StorageScope { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope used when searching memories.
|
||||
/// </summary>
|
||||
public Mem0ProviderScope SearchScope { get; }
|
||||
}
|
||||
|
||||
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
|
||||
|
||||
@@ -18,4 +18,10 @@ public sealed class Mem0ProviderOptions
|
||||
/// </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 "Mem0Provider.State".</value>
|
||||
public string? StateKey { get; set; }
|
||||
}
|
||||
|
||||
@@ -204,8 +204,8 @@ public static class OpenAIAssistantClientExtensions
|
||||
Name = options.Name ?? assistantMetadata.Name,
|
||||
Description = options.Description ?? assistantMetadata.Description,
|
||||
ChatOptions = options.ChatOptions,
|
||||
AIContextProviderFactory = options.AIContextProviderFactory,
|
||||
ChatHistoryProviderFactory = options.ChatHistoryProviderFactory,
|
||||
AIContextProvider = options.AIContextProvider,
|
||||
ChatHistoryProvider = options.ChatHistoryProvider,
|
||||
UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs
|
||||
};
|
||||
|
||||
|
||||
@@ -6,48 +6,54 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
private int _bookmark;
|
||||
private readonly List<ChatMessage> _chatMessages = [];
|
||||
private const string DefaultStateBagKey = "WorkflowChatHistoryProvider.State";
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public WorkflowChatHistoryProvider()
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WorkflowChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">
|
||||
/// Optional JSON serializer options for serializing the state of this provider.
|
||||
/// This is valuable for cases like when the chat history contains custom <see cref="AIContent"/> types
|
||||
/// and source generated serializers are required, or Native AOT / Trimming is required.
|
||||
/// </param>
|
||||
public WorkflowChatHistoryProvider(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
}
|
||||
|
||||
public WorkflowChatHistoryProvider(StoreState state)
|
||||
{
|
||||
this.ImportStoreState(Throw.IfNull(state));
|
||||
}
|
||||
|
||||
private void ImportStoreState(StoreState state, bool clearMessages = false)
|
||||
{
|
||||
if (clearMessages)
|
||||
{
|
||||
this._chatMessages.Clear();
|
||||
}
|
||||
|
||||
if (state?.Messages is not null)
|
||||
{
|
||||
this._chatMessages.AddRange(state.Messages);
|
||||
}
|
||||
this._bookmark = state?.Bookmark ?? 0;
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
}
|
||||
|
||||
internal sealed class StoreState
|
||||
{
|
||||
public int Bookmark { get; set; }
|
||||
public IList<ChatMessage> Messages { get; set; } = [];
|
||||
public List<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
|
||||
internal void AddMessages(params IEnumerable<ChatMessage> messages) => this._chatMessages.AddRange(messages);
|
||||
private StoreState GetOrInitializeState(AgentSession? session)
|
||||
{
|
||||
if (session?.StateBag.TryGetValue<StoreState>(DefaultStateBagKey, out var state, this._jsonSerializerOptions) is true && state is not null)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
state = new();
|
||||
if (session is not null)
|
||||
{
|
||||
session.StateBag.SetValue(DefaultStateBagKey, state, this._jsonSerializerOptions);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
internal void AddMessages(AgentSession session, params IEnumerable<ChatMessage> messages)
|
||||
=> this.GetOrInitializeState(session).Messages.AddRange(messages);
|
||||
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(this._chatMessages.AsReadOnly());
|
||||
=> new(this.GetOrInitializeState(context.Session).Messages.AsReadOnly());
|
||||
|
||||
protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -57,28 +63,24 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
|
||||
}
|
||||
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
this._chatMessages.AddRange(allNewMessages);
|
||||
this.GetOrInitializeState(context.Session).Messages.AddRange(allNewMessages);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public IEnumerable<ChatMessage> GetFromBookmark()
|
||||
public IEnumerable<ChatMessage> GetFromBookmark(AgentSession session)
|
||||
{
|
||||
for (int i = this._bookmark; i < this._chatMessages.Count; i++)
|
||||
var state = this.GetOrInitializeState(session);
|
||||
|
||||
for (int i = state.Bookmark; i < state.Messages.Count; i++)
|
||||
{
|
||||
yield return this._chatMessages[i];
|
||||
yield return state.Messages[i];
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateBookmark() => this._bookmark = this._chatMessages.Count;
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
public void UpdateBookmark(AgentSession session)
|
||||
{
|
||||
StoreState state = this.ExportStoreState();
|
||||
|
||||
return JsonSerializer.SerializeToElement(state,
|
||||
WorkflowsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState)));
|
||||
var state = this.GetOrInitializeState(session);
|
||||
state.Bookmark = state.Messages.Count;
|
||||
}
|
||||
|
||||
internal StoreState ExportStoreState() => new() { Bookmark = this._bookmark, Messages = this._chatMessages };
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
|
||||
// For workflow threads, messages are added directly via the internal AddMessages method
|
||||
// The MessageStore methods are used for agent invocation scenarios
|
||||
workflowSession.ChatHistoryProvider.AddMessages(messages);
|
||||
workflowSession.ChatHistoryProvider.AddMessages(session, messages);
|
||||
return workflowSession;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
this.RunId = sessionState.RunId;
|
||||
this.LastCheckpoint = sessionState.LastCheckpoint;
|
||||
this.ChatHistoryProvider = new WorkflowChatHistoryProvider(sessionState.ChatHistoryProviderState);
|
||||
this.ChatHistoryProvider = new WorkflowChatHistoryProvider();
|
||||
this.StateBag = sessionState.StateBag;
|
||||
}
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
@@ -81,8 +82,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
SessionState info = new(
|
||||
this.RunId,
|
||||
this.LastCheckpoint,
|
||||
this.ChatHistoryProvider.ExportStoreState(),
|
||||
this._inMemoryCheckpointManager);
|
||||
this._inMemoryCheckpointManager,
|
||||
this.StateBag);
|
||||
|
||||
return marshaller.Marshal(info);
|
||||
}
|
||||
@@ -100,7 +101,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
RawRepresentation = raw
|
||||
};
|
||||
|
||||
this.ChatHistoryProvider.AddMessages(update.ToChatMessage());
|
||||
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
|
||||
|
||||
return update;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
RawRepresentation = raw
|
||||
};
|
||||
|
||||
this.ChatHistoryProvider.AddMessages(update.ToChatMessage());
|
||||
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
|
||||
|
||||
return update;
|
||||
}
|
||||
@@ -156,7 +157,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
try
|
||||
{
|
||||
this.LastResponseId = Guid.NewGuid().ToString("N");
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark().ToList();
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
|
||||
|
||||
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
|
||||
await using Checkpointed<StreamingRun> checkpointed =
|
||||
@@ -240,7 +241,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
finally
|
||||
{
|
||||
// Do we want to try to undo the step, and not update the bookmark?
|
||||
this.ChatHistoryProvider.UpdateBookmark();
|
||||
this.ChatHistoryProvider.UpdateBookmark(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,12 +255,12 @@ internal sealed class WorkflowSession : AgentSession
|
||||
internal sealed class SessionState(
|
||||
string runId,
|
||||
CheckpointInfo? lastCheckpoint,
|
||||
WorkflowChatHistoryProvider.StoreState chatHistoryProviderState,
|
||||
InMemoryCheckpointManager? checkpointManager = null)
|
||||
InMemoryCheckpointManager? checkpointManager = null,
|
||||
AgentSessionStateBag? stateBag = null)
|
||||
{
|
||||
public string RunId { get; } = runId;
|
||||
public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint;
|
||||
public WorkflowChatHistoryProvider.StoreState ChatHistoryProviderState { get; } = chatHistoryProviderState;
|
||||
public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager;
|
||||
public AgentSessionStateBag StateBag { get; } = stateBag ?? new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ internal static partial class AgentJsonUtilities
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
|
||||
// Agent abstraction types
|
||||
[JsonSerializable(typeof(ChatClientAgentSession.SessionState))]
|
||||
[JsonSerializable(typeof(ChatClientAgentSession))]
|
||||
[JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))]
|
||||
[JsonSerializable(typeof(ChatHistoryMemoryProvider.ChatHistoryMemoryProviderState))]
|
||||
[JsonSerializable(typeof(ChatHistoryMemoryProvider.State))]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -105,6 +105,11 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// If the user has not opted out of using our default decorators, we wrap the chat client.
|
||||
this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.WithDefaultAgentMiddleware(options, services);
|
||||
|
||||
// Use the ChatHistoryProvider from options if provided.
|
||||
// If one was not provided, and we later find out that the underlying service does not manage chat history server-side,
|
||||
// we will use the default InMemoryChatHistoryProvider at that time.
|
||||
this.ChatHistoryProvider = options?.ChatHistoryProvider;
|
||||
|
||||
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
|
||||
}
|
||||
|
||||
@@ -120,6 +125,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// </remarks>
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="ChatHistoryProvider"/> used by this agent, to support cases where the chat history is not stored by the agent service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property may be null in case the agent stores messages in the underlying agent service.
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string? IdCore => this._agentOptions?.Id;
|
||||
|
||||
@@ -282,7 +295,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
// We can derive the type of supported session from whether we have a conversation id,
|
||||
// so let's update it and set the conversation id for the service session case.
|
||||
await this.UpdateSessionWithTypeAndConversationIdAsync(safeSession, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false);
|
||||
this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
|
||||
|
||||
// To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request.
|
||||
await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForProviders, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -298,24 +311,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
: serviceType == typeof(IChatClient) ? this.ChatClient
|
||||
: serviceType == typeof(ChatOptions) ? this._agentOptions?.ChatOptions
|
||||
: serviceType == typeof(ChatClientAgentOptions) ? this._agentOptions
|
||||
: this.ChatClient.GetService(serviceType, serviceKey));
|
||||
: this._agentOptions?.AIContextProvider?.GetService(serviceType, serviceKey)
|
||||
?? this.ChatHistoryProvider?.GetService(serviceType, serviceKey)
|
||||
?? this.ChatClient.GetService(serviceType, serviceKey));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this._agentOptions?.ChatHistoryProviderFactory is not null
|
||||
? await this._agentOptions.ChatHistoryProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
|
||||
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
return new ChatClientAgentSession
|
||||
{
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
AIContextProvider = contextProvider
|
||||
};
|
||||
return new(new ChatClientAgentSession());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -336,52 +339,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// instances that support server-side conversation storage through their underlying <see cref="IChatClient"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
|
||||
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
return new ChatClientAgentSession()
|
||||
return new(new ChatClientAgentSession()
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
AIContextProvider = contextProvider
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new agent session instance using an existing <see cref="ChatHistoryProvider"/> to continue a conversation.
|
||||
/// </summary>
|
||||
/// <param name="chatHistoryProvider">The <see cref="ChatHistoryProvider"/> instance to use for managing the conversation's message history.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance configured to work with the provided <paramref name="chatHistoryProvider"/>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method creates threads that do not support server-side conversation storage.
|
||||
/// Some AI services require server-side conversation storage to function properly, and creating a session
|
||||
/// with a <see cref="ChatHistoryProvider"/> may not be compatible with these services.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Where a service requires server-side conversation storage, use <see cref="CreateSessionAsync(string, CancellationToken)"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the agent detects, during the first run, that the underlying AI service requires server-side conversation storage,
|
||||
/// the session will throw an exception to indicate that it cannot continue using the provided <see cref="ChatHistoryProvider"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<AgentSession> CreateSessionAsync(ChatHistoryProvider chatHistoryProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
|
||||
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
return new ChatClientAgentSession()
|
||||
{
|
||||
ChatHistoryProvider = Throw.IfNull(chatHistoryProvider),
|
||||
AIContextProvider = contextProvider
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -398,22 +361,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatHistoryProvider>>? chatHistoryProviderFactory = this._agentOptions?.ChatHistoryProviderFactory is null ?
|
||||
null :
|
||||
(jse, jso, ct) => this._agentOptions.ChatHistoryProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }, ct);
|
||||
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<AIContextProvider>>? aiContextProviderFactory = this._agentOptions?.AIContextProviderFactory is null ?
|
||||
null :
|
||||
(jse, jso, ct) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }, ct);
|
||||
|
||||
return await ChatClientAgentSession.DeserializeAsync(
|
||||
serializedState,
|
||||
jsonSerializerOptions,
|
||||
chatHistoryProviderFactory,
|
||||
aiContextProviderFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new(ChatClientAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
#region Private
|
||||
@@ -462,7 +412,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
// We can derive the type of supported session from whether we have a conversation id,
|
||||
// so let's update it and set the conversation id for the service session case.
|
||||
await this.UpdateSessionWithTypeAndConversationIdAsync(safeSession, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false);
|
||||
this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken);
|
||||
|
||||
// Ensure that the author name is set for each message in the response.
|
||||
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
|
||||
@@ -492,9 +442,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
IEnumerable<ChatMessage> responseMessages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.AIContextProvider is not null)
|
||||
if (this._agentOptions?.AIContextProvider is { } contextProvider)
|
||||
{
|
||||
await session.AIContextProvider.InvokedAsync(new(this, session, inputMessages) { ResponseMessages = responseMessages },
|
||||
await contextProvider.InvokedAsync(new(this, session, inputMessages) { ResponseMessages = responseMessages },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -508,9 +458,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.AIContextProvider is not null)
|
||||
if (this._agentOptions?.AIContextProvider is { } contextProvider)
|
||||
{
|
||||
await session.AIContextProvider.InvokedAsync(new(this, session, inputMessages) { InvokeException = ex },
|
||||
await contextProvider.InvokedAsync(new(this, session, inputMessages) { InvokeException = ex },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -715,7 +665,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// Populate the session messages only if we are not continuing an existing response as it's not allowed
|
||||
if (chatOptions?.ContinuationToken is null)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = ResolveChatHistoryProvider(typedSession, chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, typedSession);
|
||||
|
||||
// Add any existing messages from the session to the messages to be sent to the chat client.
|
||||
if (chatHistoryProvider is not null)
|
||||
@@ -731,10 +681,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
// If we have an AIContextProvider, we should get context from it, and update our
|
||||
// messages and options with the additional context.
|
||||
if (typedSession.AIContextProvider is not null)
|
||||
if (this._agentOptions?.AIContextProvider is { } aiContextProvider)
|
||||
{
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, inputMessages);
|
||||
var aiContext = await typedSession.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
var aiContext = await aiContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
if (aiContext.Messages is { Count: > 0 })
|
||||
{
|
||||
inputMessagesForProviders.AddRange(aiContext.Messages);
|
||||
@@ -780,7 +730,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
return (typedSession, chatOptions, inputMessagesForProviders, inputMessagesForChatClient, continuationToken);
|
||||
}
|
||||
|
||||
private async Task UpdateSessionWithTypeAndConversationIdAsync(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
|
||||
private void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(session.ConversationId))
|
||||
{
|
||||
@@ -791,6 +741,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseConversationId))
|
||||
{
|
||||
if (this.ChatHistoryProvider is not null)
|
||||
{
|
||||
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
|
||||
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
|
||||
}
|
||||
|
||||
// If we got a conversation id back from the chat client, it means that the service supports server side session storage
|
||||
// so we should update the session with the new id.
|
||||
session.ConversationId = responseConversationId;
|
||||
@@ -798,11 +756,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
else
|
||||
{
|
||||
// If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and
|
||||
// the session has no ChatHistoryProvider yet, we should update the session with the custom ChatHistoryProvider or
|
||||
// default InMemoryChatHistoryProvider so that it has somewhere to store the chat history.
|
||||
session.ChatHistoryProvider ??= this._agentOptions?.ChatHistoryProviderFactory is not null
|
||||
? await this._agentOptions.ChatHistoryProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
: new InMemoryChatHistoryProvider();
|
||||
// the agent has no ChatHistoryProvider yet, we should use the default InMemoryChatHistoryProvider so that
|
||||
// we have somewhere to store the chat history.
|
||||
this.ChatHistoryProvider ??= new InMemoryChatHistoryProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +769,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? provider = ResolveChatHistoryProvider(session, chatOptions);
|
||||
ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
|
||||
|
||||
// Only notify the provider if we have one.
|
||||
// If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
|
||||
@@ -837,7 +793,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? provider = ResolveChatHistoryProvider(session, chatOptions);
|
||||
ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session);
|
||||
|
||||
// Only notify the provider if we have one.
|
||||
// If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
|
||||
@@ -853,13 +809,25 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static ChatHistoryProvider? ResolveChatHistoryProvider(ChatClientAgentSession session, ChatOptions? chatOptions)
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
|
||||
{
|
||||
ChatHistoryProvider? provider = session.ChatHistoryProvider;
|
||||
ChatHistoryProvider? provider = this.ChatHistoryProvider;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead of the one on the session.
|
||||
if (session.ConversationId is not null && provider is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
|
||||
}
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (session.ConversationId is not null && overrideProvider is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
}
|
||||
|
||||
provider = overrideProvider;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -39,17 +35,14 @@ public sealed class ChatClientAgentOptions
|
||||
public ChatOptions? ChatOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a factory function to create an instance of <see cref="ChatHistoryProvider"/>
|
||||
/// which will be used to provide chat history for this agent.
|
||||
/// Gets or sets the <see cref="ChatHistoryProvider"/> instance to use for providing chat history for this agent.
|
||||
/// </summary>
|
||||
public Func<ChatHistoryProviderFactoryContext, CancellationToken, ValueTask<ChatHistoryProvider>>? ChatHistoryProviderFactory { get; set; }
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a factory function to create an instance of <see cref="AIContextProvider"/>
|
||||
/// which will be used to create a context provider for each new thread, and can then
|
||||
/// provide additional context for each agent run.
|
||||
/// Gets or sets the <see cref="AIContextProvider"/> instance to use for providing additional context for each agent run.
|
||||
/// </summary>
|
||||
public Func<AIContextProviderFactoryContext, CancellationToken, ValueTask<AIContextProvider>>? AIContextProviderFactory { get; set; }
|
||||
public AIContextProvider? AIContextProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
|
||||
@@ -75,41 +68,7 @@ public sealed class ChatClientAgentOptions
|
||||
Name = this.Name,
|
||||
Description = this.Description,
|
||||
ChatOptions = this.ChatOptions?.Clone(),
|
||||
ChatHistoryProviderFactory = this.ChatHistoryProviderFactory,
|
||||
AIContextProviderFactory = this.AIContextProviderFactory,
|
||||
ChatHistoryProvider = this.ChatHistoryProvider,
|
||||
AIContextProvider = this.AIContextProvider,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Context object passed to the <see cref="AIContextProviderFactory"/> to create a new instance of <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class AIContextProviderFactoryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized state of the <see cref="AIContextProvider"/>, if any.
|
||||
/// </summary>
|
||||
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="AIContextProvider"/> is first created.</value>
|
||||
public JsonElement SerializedState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
|
||||
/// </summary>
|
||||
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context object passed to the <see cref="ChatHistoryProviderFactory"/> to create a new instance of <see cref="ChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryProviderFactoryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized state of the <see cref="ChatHistoryProvider"/>, if any.
|
||||
/// </summary>
|
||||
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="ChatHistoryProvider"/> is first created.</value>
|
||||
public JsonElement SerializedState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
|
||||
/// </summary>
|
||||
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -15,8 +14,6 @@ namespace Microsoft.Agents.AI;
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class ChatClientAgentSession : AgentSession
|
||||
{
|
||||
private ChatHistoryProvider? _chatHistoryProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatClientAgentSession"/> class.
|
||||
/// </summary>
|
||||
@@ -24,29 +21,30 @@ public sealed class ChatClientAgentSession : AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
internal ChatClientAgentSession(string? conversationId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
{
|
||||
this.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service.
|
||||
/// Gets or sets the ID of the underlying service chat history to support cases where the chat history is stored by the agent service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Note that either <see cref="ConversationId"/> or <see cref="ChatHistoryProvider "/> may be set, but not both.
|
||||
/// If <see cref="ChatHistoryProvider "/> is not null, setting <see cref="ConversationId"/> will throw an
|
||||
/// <see cref="InvalidOperationException "/> exception.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property may be null in the following cases:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The thread stores messages via the <see cref="AI.ChatHistoryProvider"/> and not in the agent service.</description></item>
|
||||
/// <item><description>This thread object is new and a server managed thread has not yet been created in the agent service.</description></item>
|
||||
/// <item><description>The agent stores messages via a <see cref="ChatHistoryProvider"/> and not in the agent service.</description></item>
|
||||
/// <item><description>This session object is new and server managed chat history has not yet been created in the agent service.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The id may also change over time where the id is pointing at a
|
||||
/// agent service managed thread, and the default behavior of a service is
|
||||
/// to fork the thread with each iteration.
|
||||
/// The id may also change over time where the id is pointing at
|
||||
/// agent service managed chat history, and the default behavior of a service is
|
||||
/// to fork the chat history with each iteration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">Attempted to set a conversation ID but a <see cref="ChatHistoryProvider"/> is already set.</exception>
|
||||
[JsonPropertyName("conversationId")]
|
||||
public string? ConversationId
|
||||
{
|
||||
get;
|
||||
@@ -57,149 +55,37 @@ public sealed class ChatClientAgentSession : AgentSession
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._chatHistoryProvider is not null)
|
||||
{
|
||||
// If we have a ChatHistoryProvider already, we shouldn't switch the session to use a conversation id
|
||||
// since it means that the session contents will essentially be deleted, and the session will not work
|
||||
// with the original agent anymore.
|
||||
throw new InvalidOperationException("Only the ConversationId or ChatHistoryProvider may be set, but not both and switching from one to another is not supported.");
|
||||
}
|
||||
|
||||
field = Throw.IfNullOrWhitespace(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="AI.ChatHistoryProvider"/> used by this thread, for cases where messages should be stored in a custom location.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Note that either <see cref="ConversationId"/> or <see cref="ChatHistoryProvider "/> may be set, but not both.
|
||||
/// If <see cref="ConversationId"/> is not null, and <see cref="ChatHistoryProvider "/> is set, <see cref="ConversationId"/>
|
||||
/// will be reverted to null, and vice versa.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property may be null in the following cases:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The thread stores messages in the agent service and just has an id to the remove thread, instead of in an <see cref="AI.ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description>This thread object is new it is not yet clear whether it will be backed by a server managed thread or an <see cref="AI.ChatHistoryProvider"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider
|
||||
{
|
||||
get => this._chatHistoryProvider;
|
||||
internal set
|
||||
{
|
||||
if (this._chatHistoryProvider is null && value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this.ConversationId))
|
||||
{
|
||||
// If we have a conversation id already, we shouldn't switch the session to use a ChatHistoryProvider
|
||||
// since it means that the session will not work with the original agent anymore.
|
||||
throw new InvalidOperationException("Only the ConversationId or ChatHistoryProvider may be set, but not both and switching from one to another is not supported.");
|
||||
}
|
||||
|
||||
this._chatHistoryProvider = Throw.IfNull(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="AIContextProvider"/> used by this thread to provide additional context to the AI model before each invocation.
|
||||
/// </summary>
|
||||
public AIContextProvider? AIContextProvider { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="ChatClientAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="chatHistoryProviderFactory">
|
||||
/// An optional factory function to create a custom <see cref="AI.ChatHistoryProvider"/> from its serialized state.
|
||||
/// If not provided, the default <see cref="InMemoryChatHistoryProvider"/> will be used.
|
||||
/// </param>
|
||||
/// <param name="aiContextProviderFactory">
|
||||
/// An optional factory function to create a custom <see cref="AIContextProvider"/> from its serialized state.
|
||||
/// If not provided, no context provider will be configured.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains the deserialized <see cref="ChatClientAgentSession"/>.</returns>
|
||||
internal static async Task<ChatClientAgentSession> DeserializeAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatHistoryProvider>>? chatHistoryProviderFactory = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<AIContextProvider>>? aiContextProviderFactory = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serialization options to use instead of the default options.</param>
|
||||
/// <returns>The deserialized <see cref="ChatClientAgentSession"/>.</returns>
|
||||
internal static ChatClientAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedState.Deserialize(
|
||||
AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(SessionState))) as SessionState;
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
|
||||
session.AIContextProvider = aiContextProviderFactory is not null
|
||||
? await aiContextProviderFactory.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (state?.ConversationId is string sessionId)
|
||||
{
|
||||
session.ConversationId = sessionId;
|
||||
|
||||
// Since we have an ID, we should not have a ChatHistoryProvider and we can return here.
|
||||
return session;
|
||||
}
|
||||
|
||||
session._chatHistoryProvider =
|
||||
chatHistoryProviderFactory is not null
|
||||
? await chatHistoryProviderFactory.Invoke(state?.ChatHistoryProviderState ?? default, jsonSerializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
: new InMemoryChatHistoryProvider(state?.ChatHistoryProviderState ?? default, jsonSerializerOptions); // default to an in-memory ChatHistoryProvider
|
||||
|
||||
return session;
|
||||
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
return serializedState.Deserialize(jso.GetTypeInfo(typeof(ChatClientAgentSession))) as ChatClientAgentSession
|
||||
?? new ChatClientAgentSession();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonElement? chatHistoryProviderState = this._chatHistoryProvider?.Serialize(jsonSerializerOptions);
|
||||
|
||||
JsonElement? aiContextProviderState = this.AIContextProvider?.Serialize(jsonSerializerOptions);
|
||||
|
||||
var state = new SessionState
|
||||
{
|
||||
ConversationId = this.ConversationId,
|
||||
ChatHistoryProviderState = chatHistoryProviderState is { ValueKind: not JsonValueKind.Undefined } ? chatHistoryProviderState : null,
|
||||
AIContextProviderState = aiContextProviderState is { ValueKind: not JsonValueKind.Undefined } ? aiContextProviderState : null,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(SessionState)));
|
||||
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(ChatClientAgentSession)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
base.GetService(serviceType, serviceKey)
|
||||
?? this.AIContextProvider?.GetService(serviceType, serviceKey)
|
||||
?? this.ChatHistoryProvider?.GetService(serviceType, serviceKey);
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
this.ConversationId is { } conversationId ? $"ConversationId = {conversationId}" :
|
||||
this._chatHistoryProvider is InMemoryChatHistoryProvider inMemoryChatHistoryProvider ? $"Count = {inMemoryChatHistoryProvider.Count}" :
|
||||
this._chatHistoryProvider is { } chatHistoryProvider ? $"ChatHistoryProvider = {chatHistoryProvider.GetType().Name}" :
|
||||
"Count = 0";
|
||||
|
||||
internal sealed class SessionState
|
||||
{
|
||||
public string? ConversationId { get; set; }
|
||||
|
||||
public JsonElement? ChatHistoryProviderState { get; set; }
|
||||
|
||||
public JsonElement? AIContextProviderState { get; set; }
|
||||
}
|
||||
this.ConversationId is { } conversationId ? $"ConversationId = {conversationId}, StateBag Count = {this.StateBag.Count}" :
|
||||
$"StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -41,18 +40,21 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
private const int DefaultMaxResults = 3;
|
||||
private const string DefaultFunctionToolName = "Search";
|
||||
private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question.";
|
||||
private const string DefaultStateBagKey = "ChatHistoryMemoryProvider.State";
|
||||
|
||||
#pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal
|
||||
private readonly VectorStore _vectorStore;
|
||||
#pragma warning restore CA2213
|
||||
private readonly VectorStoreCollection<object, Dictionary<string, object?>> _collection;
|
||||
private readonly int _maxResults;
|
||||
private readonly string _contextPrompt;
|
||||
private readonly bool _enableSensitiveTelemetryData;
|
||||
private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string _toolName;
|
||||
private readonly string _toolDescription;
|
||||
private readonly ILogger<ChatHistoryMemoryProvider>? _logger;
|
||||
|
||||
private readonly ChatHistoryMemoryProviderScope _storageScope;
|
||||
private readonly ChatHistoryMemoryProviderScope _searchScope;
|
||||
private readonly string _stateKey;
|
||||
private readonly Func<AgentSession?, State> _stateInitializer;
|
||||
|
||||
private bool _collectionInitialized;
|
||||
private readonly SemaphoreSlim _initializationLock = new(1, 1);
|
||||
@@ -64,93 +66,30 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="storageScope">Optional values to scope the chat history storage with.</param>
|
||||
/// <param name="searchScope">Optional values to scope the chat history search with. Where values are null, no filtering is done using those values. Defaults to <paramref name="storageScope"/> if not provided.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the storage and search scopes.</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="vectorStore"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="vectorStore"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
|
||||
public ChatHistoryMemoryProvider(
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
ChatHistoryMemoryProviderScope storageScope,
|
||||
ChatHistoryMemoryProviderScope? searchScope = null,
|
||||
Func<AgentSession?, State> stateInitializer,
|
||||
ChatHistoryMemoryProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
vectorStore,
|
||||
collectionName,
|
||||
vectorDimensions,
|
||||
new ChatHistoryMemoryProviderState
|
||||
{
|
||||
StorageScope = new(Throw.IfNull(storageScope)),
|
||||
SearchScope = searchScope ?? new(storageScope),
|
||||
},
|
||||
options,
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
this._vectorStore = Throw.IfNull(vectorStore);
|
||||
this._stateInitializer = Throw.IfNull(stateInitializer);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryMemoryProvider"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the provider.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public ChatHistoryMemoryProvider(
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
ChatHistoryMemoryProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
vectorStore,
|
||||
collectionName,
|
||||
vectorDimensions,
|
||||
DeserializeState(serializedState, jsonSerializerOptions),
|
||||
options,
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private ChatHistoryMemoryProvider(
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
ChatHistoryMemoryProviderState? state = null,
|
||||
ChatHistoryMemoryProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore));
|
||||
options ??= new ChatHistoryMemoryProviderOptions();
|
||||
this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults;
|
||||
this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt;
|
||||
this._enableSensitiveTelemetryData = options.EnableSensitiveTelemetryData;
|
||||
this._searchTime = options.SearchTime;
|
||||
this._stateKey = options.StateKey ?? DefaultStateBagKey;
|
||||
this._logger = loggerFactory?.CreateLogger<ChatHistoryMemoryProvider>();
|
||||
|
||||
if (state == null || state.StorageScope == null || state.SearchScope == null)
|
||||
{
|
||||
throw new InvalidOperationException($"The {nameof(ChatHistoryMemoryProvider)} state did not contain the required scope properties.");
|
||||
}
|
||||
|
||||
this._storageScope = state.StorageScope;
|
||||
this._searchScope = state.SearchScope;
|
||||
|
||||
// Create on-demand search tool (only used when behavior is OnDemandFunctionCalling)
|
||||
this._tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(Func<string, CancellationToken, Task<string>>)this.SearchTextAsync,
|
||||
name: options.FunctionToolName ?? DefaultFunctionToolName,
|
||||
description: options.FunctionToolDescription ?? DefaultFunctionToolDescription)
|
||||
];
|
||||
this._toolName = options.FunctionToolName ?? DefaultFunctionToolName;
|
||||
this._toolDescription = options.FunctionToolDescription ?? DefaultFunctionToolDescription;
|
||||
|
||||
// Create a definition so that we can use the dimensions provided at runtime.
|
||||
var definition = new VectorStoreCollectionDefinition
|
||||
@@ -174,15 +113,51 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
this._collection = this._vectorStore.GetDynamicCollection(Throw.IfNullOrWhitespace(collectionName), definition);
|
||||
}
|
||||
|
||||
/// <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, AgentJsonUtilities.DefaultOptions) is true && state is not null)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
state = this._stateInitializer(session);
|
||||
if (state is not null && session is not null)
|
||||
{
|
||||
session.StateBag.SetValue(this._stateKey, state, AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var searchScope = state?.SearchScope ?? new ChatHistoryMemoryProviderScope();
|
||||
|
||||
if (this._searchTime == ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling)
|
||||
{
|
||||
Task<string> InlineSearchAsync(string userQuestion, CancellationToken ct)
|
||||
=> this.SearchTextAsync(userQuestion, searchScope, ct);
|
||||
|
||||
// Create on-demand search tool (only used when behavior is OnDemandFunctionCalling)
|
||||
AITool[] tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
InlineSearchAsync,
|
||||
name: this._toolName,
|
||||
description: this._toolDescription)
|
||||
];
|
||||
|
||||
// Expose search tool for on-demand invocation by the model
|
||||
return new AIContext { Tools = this._tools };
|
||||
return new AIContext { Tools = tools };
|
||||
}
|
||||
|
||||
try
|
||||
@@ -199,7 +174,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
}
|
||||
|
||||
// Search for relevant chat history
|
||||
var contextText = await this.SearchTextAsync(requestText, cancellationToken).ConfigureAwait(false);
|
||||
var contextText = await this.SearchTextAsync(requestText, searchScope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contextText))
|
||||
{
|
||||
@@ -218,10 +193,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"ChatHistoryMemoryProvider: Failed to search for chat history due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', SessionId: '{SessionId}', UserId: '{UserId}'.",
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.SessionId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.SessionId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
}
|
||||
|
||||
return new AIContext();
|
||||
@@ -239,6 +214,9 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var state = this.GetOrInitializeState(context.Session);
|
||||
var storageScope = state?.StorageScope ?? new ChatHistoryMemoryProviderScope();
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure the collection is initialized
|
||||
@@ -253,10 +231,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
["Role"] = message.Role.ToString(),
|
||||
["MessageId"] = message.MessageId,
|
||||
["AuthorName"] = message.AuthorName,
|
||||
["ApplicationId"] = this._storageScope?.ApplicationId,
|
||||
["AgentId"] = this._storageScope?.AgentId,
|
||||
["UserId"] = this._storageScope?.UserId,
|
||||
["SessionId"] = this._storageScope?.SessionId,
|
||||
["ApplicationId"] = storageScope.ApplicationId,
|
||||
["AgentId"] = storageScope.AgentId,
|
||||
["UserId"] = storageScope.UserId,
|
||||
["SessionId"] = storageScope.SessionId,
|
||||
["Content"] = message.Text,
|
||||
["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
|
||||
["ContentEmbedding"] = message.Text,
|
||||
@@ -275,10 +253,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
this._logger.LogError(
|
||||
ex,
|
||||
"ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', SessionId: '{SessionId}', UserId: '{UserId}'.",
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.SessionId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
storageScope.ApplicationId,
|
||||
storageScope.AgentId,
|
||||
storageScope.SessionId,
|
||||
this.SanitizeLogData(storageScope.UserId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,16 +265,17 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
/// Function callable by the AI model (when enabled) to perform an ad-hoc chat history search.
|
||||
/// </summary>
|
||||
/// <param name="userQuestion">The query text.</param>
|
||||
/// <param name="searchScope">The scope to filter search results with.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Formatted search results (may be empty).</returns>
|
||||
internal async Task<string> SearchTextAsync(string userQuestion, CancellationToken cancellationToken = default)
|
||||
private async Task<string> SearchTextAsync(string userQuestion, ChatHistoryMemoryProviderScope searchScope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userQuestion))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var results = await this.SearchChatHistoryAsync(userQuestion, this._maxResults, cancellationToken).ConfigureAwait(false);
|
||||
var results = await this.SearchChatHistoryAsync(userQuestion, searchScope, this._maxResults, cancellationToken).ConfigureAwait(false);
|
||||
if (!results.Any())
|
||||
{
|
||||
return string.Empty;
|
||||
@@ -317,10 +296,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
"ChatHistoryMemoryProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\n ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', SessionId: '{SessionId}', UserId: '{UserId}'.",
|
||||
this.SanitizeLogData(userQuestion),
|
||||
this.SanitizeLogData(formatted),
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.SessionId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.SessionId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
}
|
||||
|
||||
return formatted;
|
||||
@@ -330,11 +309,13 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
/// Searches for relevant chat history items based on the provided query text.
|
||||
/// </summary>
|
||||
/// <param name="queryText">The text to search for.</param>
|
||||
/// <param name="searchScope">The scope to filter search results with.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of relevant chat history items.</returns>
|
||||
private async Task<IEnumerable<Dictionary<string, object?>>> SearchChatHistoryAsync(
|
||||
string queryText,
|
||||
ChatHistoryMemoryProviderScope searchScope,
|
||||
int top,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -345,10 +326,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
|
||||
var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? applicationId = this._searchScope.ApplicationId;
|
||||
string? agentId = this._searchScope.AgentId;
|
||||
string? userId = this._searchScope.UserId;
|
||||
string? sessionId = this._searchScope.SessionId;
|
||||
string? applicationId = searchScope.ApplicationId;
|
||||
string? agentId = searchScope.AgentId;
|
||||
string? userId = searchScope.UserId;
|
||||
string? sessionId = searchScope.SessionId;
|
||||
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
|
||||
if (applicationId != null)
|
||||
@@ -401,10 +382,10 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
this._logger.LogInformation(
|
||||
"ChatHistoryMemoryProvider: Retrieved {Count} search results. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', SessionId: '{SessionId}', UserId: '{UserId}'.",
|
||||
results.Count,
|
||||
this._searchScope.ApplicationId,
|
||||
this._searchScope.AgentId,
|
||||
this._searchScope.SessionId,
|
||||
this.SanitizeLogData(this._searchScope.UserId));
|
||||
searchScope.ApplicationId,
|
||||
searchScope.AgentId,
|
||||
searchScope.SessionId,
|
||||
this.SanitizeLogData(searchScope.UserId));
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -465,39 +446,32 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current provider state to a <see cref="JsonElement"/> including storage and search scopes.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options.</param>
|
||||
/// <returns>Serialized provider state.</returns>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var state = new ChatHistoryMemoryProviderState
|
||||
{
|
||||
StorageScope = this._storageScope,
|
||||
SearchScope = this._searchScope,
|
||||
};
|
||||
|
||||
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState)));
|
||||
}
|
||||
|
||||
private static ChatHistoryMemoryProviderState? DeserializeState(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
return serializedState.Deserialize(jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))) as ChatHistoryMemoryProviderState;
|
||||
}
|
||||
|
||||
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
|
||||
|
||||
internal sealed class ChatHistoryMemoryProviderState
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="ChatHistoryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
public ChatHistoryMemoryProviderScope? StorageScope { get; set; }
|
||||
public ChatHistoryMemoryProviderScope? SearchScope { get; set; }
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="State"/> class with the specified storage and search scopes.
|
||||
/// </summary>
|
||||
/// <param name="storageScope">The scope to use when storing chat history messages.</param>
|
||||
/// <param name="searchScope">The scope to use when searching for relevant chat history messages. If null, the storage scope will be used for searching as well.</param>
|
||||
public State(ChatHistoryMemoryProviderScope storageScope, ChatHistoryMemoryProviderScope? searchScope = null)
|
||||
{
|
||||
this.StorageScope = Throw.IfNull(storageScope);
|
||||
this.SearchScope = searchScope ?? storageScope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope used when storing chat history messages.
|
||||
/// </summary>
|
||||
public ChatHistoryMemoryProviderScope StorageScope { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope used when searching chat history messages.
|
||||
/// </summary>
|
||||
public ChatHistoryMemoryProviderScope SearchScope { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ public sealed class ChatHistoryMemoryProviderOptions
|
||||
/// <value>Defaults to <see langword="false"/>.</value>
|
||||
public bool EnableSensitiveTelemetryData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key used to store provider state in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to "ChatHistoryMemoryProvider.State". Override this if you need multiple
|
||||
/// <see cref="ChatHistoryMemoryProvider"/> instances with separate state in the same session.
|
||||
/// </value>
|
||||
public string? StateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Behavior choices for the provider.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -39,31 +38,28 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
private const string DefaultPluginSearchFunctionDescription = "Allows searching for additional information to help answer the user question.";
|
||||
private const string DefaultContextPrompt = "## Additional Context\nConsider the following information from source documents when responding to the user:";
|
||||
private const string DefaultCitationsPrompt = "Include citations to the source document with document name and link if document name and link is available.";
|
||||
private const string DefaultStateBagKey = "TextSearchProvider.RecentMessagesText";
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> _searchAsync;
|
||||
private readonly ILogger<TextSearchProvider>? _logger;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly Queue<string> _recentMessagesText;
|
||||
private readonly List<ChatRole> _recentMessageRolesIncluded;
|
||||
private readonly int _recentMessageMemoryLimit;
|
||||
private readonly TextSearchProviderOptions.TextSearchBehavior _searchTime;
|
||||
private readonly string _contextPrompt;
|
||||
private readonly string _citationsPrompt;
|
||||
private readonly string _stateKey;
|
||||
private readonly Func<IList<TextSearchResult>, string>? _contextFormatter;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="searchAsync">Delegate that executes the search logic. Must not be <see langword="null"/>.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized provider state.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options (unused - source generated context is used).</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
|
||||
public TextSearchProvider(
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync,
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
TextSearchProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
@@ -75,27 +71,9 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
this._searchTime = options?.SearchTime ?? TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke;
|
||||
this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt;
|
||||
this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt;
|
||||
this._stateKey = options?.StateKey ?? DefaultStateBagKey;
|
||||
this._contextFormatter = options?.ContextFormatter;
|
||||
|
||||
// Restore recent messages from serialized state if provided
|
||||
List<string>? restoredMessages = null;
|
||||
if (serializedState.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
this._recentMessagesText = new();
|
||||
}
|
||||
else
|
||||
{
|
||||
var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(TextSearchProviderState))) as TextSearchProviderState;
|
||||
if (state?.RecentMessagesText is { Count: > 0 })
|
||||
{
|
||||
restoredMessages = state.RecentMessagesText;
|
||||
}
|
||||
|
||||
// Restore recent messages respecting the limit (may truncate if limit changed afterwards).
|
||||
this._recentMessagesText = restoredMessages is null ? new() : new(restoredMessages.Take(this._recentMessageMemoryLimit));
|
||||
}
|
||||
|
||||
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
|
||||
this._tools =
|
||||
[
|
||||
@@ -115,12 +93,16 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
return new AIContext { Tools = this._tools }; // No automatic message injection.
|
||||
}
|
||||
|
||||
// Retrieve recent messages from the session state bag.
|
||||
var recentMessagesText = context.Session?.StateBag.GetValue<TextSearchProviderState>(this._stateKey, AgentJsonUtilities.DefaultOptions)?.RecentMessagesText
|
||||
?? [];
|
||||
|
||||
// Aggregate text from memory + current request messages.
|
||||
var sbInput = new StringBuilder();
|
||||
var requestMessagesText = context.RequestMessages
|
||||
.Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text);
|
||||
foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText))
|
||||
foreach (var messageText in recentMessagesText.Concat(requestMessagesText))
|
||||
{
|
||||
if (sbInput.Length > 0)
|
||||
{
|
||||
@@ -176,12 +158,21 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
return default; // Memory disabled.
|
||||
}
|
||||
|
||||
if (context.Session is null)
|
||||
{
|
||||
return default; // No session to store state in.
|
||||
}
|
||||
|
||||
if (context.InvokeException is not null)
|
||||
{
|
||||
return default; // Do not update memory on failed invocations.
|
||||
}
|
||||
|
||||
var messagesText = context.RequestMessages
|
||||
// Retrieve existing recent messages from the session state bag.
|
||||
var recentMessagesText = context.Session.StateBag.GetValue<TextSearchProviderState>(this._stateKey, AgentJsonUtilities.DefaultOptions)?.RecentMessagesText
|
||||
?? [];
|
||||
|
||||
var newMessagesText = context.RequestMessages
|
||||
.Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External)
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Where(m =>
|
||||
@@ -190,44 +181,23 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
// Filter out any messages that were added by this class in InvokingAsync, since we don't want
|
||||
// a feedback loop where previous search results are used to find new search results.
|
||||
(m.AdditionalProperties == null || m.AdditionalProperties.TryGetValue("IsTextSearchProviderOutput", out bool isTextSearchProviderOutput) == false || !isTextSearchProviderOutput))
|
||||
.Select(m => m.Text)
|
||||
.ToList();
|
||||
if (messagesText.Count > limit)
|
||||
{
|
||||
// If the current request/response exceeds the limit, only keep the most recent messages from it.
|
||||
messagesText = messagesText.Skip(messagesText.Count - limit).ToList();
|
||||
}
|
||||
.Select(m => m.Text);
|
||||
|
||||
foreach (var message in messagesText)
|
||||
{
|
||||
this._recentMessagesText.Enqueue(message);
|
||||
}
|
||||
// Combine existing messages with new messages, then take the most recent up to the limit.
|
||||
var allMessages = recentMessagesText.Concat(newMessagesText).ToList();
|
||||
var updatedMessages = allMessages.Count > limit
|
||||
? allMessages.Skip(allMessages.Count - limit).ToList()
|
||||
: allMessages;
|
||||
|
||||
while (this._recentMessagesText.Count > limit)
|
||||
{
|
||||
this._recentMessagesText.Dequeue();
|
||||
}
|
||||
// Store updated state back to the session state bag.
|
||||
context.Session.StateBag.SetValue(
|
||||
this._stateKey,
|
||||
new TextSearchProviderState { RecentMessagesText = updatedMessages },
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current provider state to a <see cref="JsonElement"/> containing any overridden prompts or descriptions.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options (ignored, source generated context is used).</param>
|
||||
/// <returns>A <see cref="JsonElement"/> with overridden values, or default if nothing was overridden.</returns>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
// Only persist values that differ from defaults plus recent memory configuration & messages.
|
||||
TextSearchProviderState state = new();
|
||||
if (this._recentMessageMemoryLimit > 0 && this._recentMessagesText.Count > 0)
|
||||
{
|
||||
state.RecentMessagesText = this._recentMessagesText.Take(this._recentMessageMemoryLimit).ToList();
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(TextSearchProviderState)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function callable by the AI model (when enabled) to perform an ad-hoc search.
|
||||
/// </summary>
|
||||
|
||||
@@ -59,6 +59,15 @@ public sealed class TextSearchProviderOptions
|
||||
/// </value>
|
||||
public int RecentMessageMemoryLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key used to store provider state in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to "TextSearchProvider.RecentMessagesText". Override this if you need multiple
|
||||
/// <see cref="TextSearchProvider"/> instances with separate state in the same session.
|
||||
/// </value>
|
||||
public string? StateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of <see cref="ChatRole"/> types to filter recent messages to
|
||||
/// when deciding which recent messages to include when constructing the search input.
|
||||
|
||||
Reference in New Issue
Block a user