mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
275 lines
9.5 KiB
C#
275 lines
9.5 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
|
using Microsoft.Shared.Diagnostics;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.Execution;
|
|
|
|
internal sealed class StateManager
|
|
{
|
|
private readonly Dictionary<ScopeId, StateScope> _scopes = [];
|
|
private readonly Dictionary<UpdateKey, StateUpdate> _queuedUpdates = [];
|
|
|
|
private StateScope GetOrCreateScope(ScopeId scopeId)
|
|
{
|
|
Throw.IfNull(scopeId);
|
|
|
|
if (!this._scopes.TryGetValue(scopeId, out StateScope? scope))
|
|
{
|
|
scope = new StateScope(scopeId);
|
|
this._scopes[scopeId] = scope;
|
|
}
|
|
|
|
return scope;
|
|
}
|
|
|
|
private IEnumerable<UpdateKey> GetUpdatesForScopeStrict(ScopeId scopeId)
|
|
{
|
|
Throw.IfNull(scopeId);
|
|
|
|
return this._queuedUpdates.Keys.Where(key => key.IsMatchingScope(scopeId, strict: true));
|
|
}
|
|
|
|
public ValueTask ClearStateAsync(string executorId, string? scopeName)
|
|
=> this.ClearStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName));
|
|
|
|
public async ValueTask ClearStateAsync(ScopeId scopeId)
|
|
{
|
|
Throw.IfNull(scopeId);
|
|
|
|
if (this._scopes.TryGetValue(scopeId, out StateScope? scope))
|
|
{
|
|
HashSet<string> keysToDelete = await scope.ReadKeysAsync().ConfigureAwait(false);
|
|
|
|
foreach (UpdateKey updateKey in this.GetUpdatesForScopeStrict(scopeId))
|
|
{
|
|
StateUpdate update = this._queuedUpdates[updateKey];
|
|
if (!update.IsDelete)
|
|
{
|
|
this._queuedUpdates[updateKey] = StateUpdate.Delete(update.Key);
|
|
}
|
|
|
|
keysToDelete.Remove(update.Key);
|
|
}
|
|
|
|
foreach (string key in keysToDelete)
|
|
{
|
|
UpdateKey updateKey = new(scopeId, key);
|
|
this._queuedUpdates[updateKey] = StateUpdate.Delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private HashSet<string> ApplyUnpublishedUpdates(ScopeId scopeId, HashSet<string> keys)
|
|
{
|
|
// Apply any queued updates for this scope
|
|
foreach (UpdateKey key in this.GetUpdatesForScopeStrict(scopeId))
|
|
{
|
|
StateUpdate update = this._queuedUpdates[key];
|
|
if (update.IsDelete)
|
|
{
|
|
keys.Remove(update.Key);
|
|
}
|
|
else
|
|
{
|
|
// Add is idempotent on Sets
|
|
keys.Add(update.Key);
|
|
}
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
public ValueTask<HashSet<string>> ReadKeysAsync(string executorId, string? scopeName = null)
|
|
=> this.ReadKeysAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName));
|
|
|
|
public async ValueTask<HashSet<string>> ReadKeysAsync(ScopeId scopeId)
|
|
{
|
|
StateScope scope = this.GetOrCreateScope(scopeId);
|
|
HashSet<string> keys = await scope.ReadKeysAsync().ConfigureAwait(false);
|
|
return this.ApplyUnpublishedUpdates(scopeId, keys);
|
|
}
|
|
|
|
public ValueTask<T?> ReadStateAsync<T>(string executorId, string? scopeName, string key)
|
|
=> this.ReadStateAsync<T>(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key);
|
|
|
|
public ValueTask<T> ReadOrInitStateAsync<T>(string executorId, string? scopeName, string key, Func<T> initialStateFactory)
|
|
=> this.ReadOrInitStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, initialStateFactory);
|
|
|
|
private async ValueTask<T?> ReadValueOrDefaultAsync<T>(ScopeId scopeId, string key, Func<T>? defaultValueFactory = default, bool initOnDefault = false)
|
|
{
|
|
if (typeof(T) == typeof(object))
|
|
{
|
|
// Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc.
|
|
throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants.");
|
|
}
|
|
|
|
Throw.IfNullOrEmpty(key);
|
|
|
|
UpdateKey stateKey = new(scopeId, key);
|
|
|
|
T? result = defaultValueFactory != null ? defaultValueFactory() : default;
|
|
bool needsInit = false;
|
|
|
|
// If there is executor-local state (from a queued update), read it first
|
|
if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? update))
|
|
{
|
|
// What's the right thing to do when we have a state object, but it is the wrong type?
|
|
if (update.IsDelete || update.Value is null)
|
|
{
|
|
needsInit = initOnDefault;
|
|
}
|
|
else if (update.Value is T typed)
|
|
{
|
|
result = typed;
|
|
}
|
|
else if (typeof(T) == typeof(PortableValue) && update.Value != null)
|
|
{
|
|
result = (T)(object)new PortableValue(update.Value);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"State for key '{key}' in scope '{scopeId}' is not of type '{typeof(T).Name}'.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
StateScope scope = this.GetOrCreateScope(scopeId);
|
|
if (scope.ContainsKey(key))
|
|
{
|
|
result = await scope.ReadStateAsync<T>(key).ConfigureAwait(false);
|
|
}
|
|
else if (initOnDefault)
|
|
{
|
|
needsInit = true;
|
|
}
|
|
}
|
|
|
|
if (needsInit)
|
|
{
|
|
if (defaultValueFactory is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(defaultValueFactory), "Default value must be provided when initializing state.");
|
|
}
|
|
|
|
Debug.Assert(initOnDefault);
|
|
|
|
await this.WriteStateAsync(scopeId, key, defaultValueFactory()).ConfigureAwait(false);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public ValueTask<T?> ReadStateAsync<T>(ScopeId scopeId, string key)
|
|
=> this.ReadValueOrDefaultAsync<T>(scopeId, key);
|
|
|
|
public async ValueTask<T> ReadOrInitStateAsync<T>(ScopeId scopeId, string key, Func<T> initialStateFactory)
|
|
{
|
|
return (await this.ReadValueOrDefaultAsync(scopeId, key, initialStateFactory, initOnDefault: true)
|
|
.ConfigureAwait(false))!;
|
|
}
|
|
|
|
public ValueTask WriteStateAsync<T>(string executorId, string? scopeName, string key, T value)
|
|
=> this.WriteStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, value);
|
|
|
|
public ValueTask WriteStateAsync<T>(ScopeId scopeId, string key, T value)
|
|
{
|
|
Throw.IfNullOrEmpty(key);
|
|
|
|
UpdateKey stateKey = new(scopeId, key);
|
|
this._queuedUpdates[stateKey] = StateUpdate.Update(key, value);
|
|
|
|
return default;
|
|
}
|
|
|
|
public ValueTask ClearStateAsync(string executorId, string? scopeName, string key)
|
|
=> this.ClearStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key);
|
|
|
|
public ValueTask ClearStateAsync(ScopeId scopeId, string key)
|
|
{
|
|
Throw.IfNullOrEmpty(key);
|
|
UpdateKey stateKey = new(scopeId, key);
|
|
this._queuedUpdates[stateKey] = StateUpdate.Delete(key);
|
|
return default;
|
|
}
|
|
|
|
public async ValueTask PublishUpdatesAsync(IStepTracer? tracer)
|
|
{
|
|
Dictionary<ScopeId, Dictionary<string, List<StateUpdate>>> updatesByScope = [];
|
|
|
|
// Aggregate the updates for each scope
|
|
foreach (UpdateKey key in this._queuedUpdates.Keys)
|
|
{
|
|
if (!updatesByScope.TryGetValue(key.ScopeId, out Dictionary<string, List<StateUpdate>>? scopeUpdates))
|
|
{
|
|
updatesByScope[key.ScopeId] = scopeUpdates = [];
|
|
}
|
|
|
|
if (!scopeUpdates.TryGetValue(key.Key, out List<StateUpdate>? stateUpdates))
|
|
{
|
|
scopeUpdates[key.Key] = stateUpdates = [];
|
|
}
|
|
|
|
stateUpdates.Add(this._queuedUpdates[key]);
|
|
}
|
|
|
|
if (tracer is not null && (updatesByScope.Count > 0))
|
|
{
|
|
tracer.TraceStatePublished();
|
|
}
|
|
|
|
foreach (ScopeId scope in updatesByScope.Keys)
|
|
{
|
|
StateScope stateScope = this.GetOrCreateScope(scope);
|
|
await stateScope.WriteStateAsync(updatesByScope[scope]).ConfigureAwait(false);
|
|
}
|
|
|
|
this._queuedUpdates.Clear();
|
|
}
|
|
|
|
private static IEnumerable<KeyValuePair<ScopeKey, PortableValue>> ExportScope(StateScope scope)
|
|
{
|
|
foreach (KeyValuePair<string, PortableValue> state in scope.ExportStates())
|
|
{
|
|
yield return new(new ScopeKey(scope.ScopeId, state.Key), state.Value);
|
|
}
|
|
}
|
|
|
|
internal async ValueTask<Dictionary<ScopeKey, PortableValue>> ExportStateAsync()
|
|
{
|
|
if (this._queuedUpdates.Count != 0)
|
|
{
|
|
throw new InvalidOperationException("Cannot export state while there are queued updates. Call PublishUpdatesAsync() first.");
|
|
}
|
|
|
|
return this._scopes.Values.SelectMany(ExportScope).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
|
}
|
|
|
|
internal ValueTask ImportStateAsync(Checkpoint checkpoint)
|
|
{
|
|
// TODO: Should this be a warning instead?
|
|
if (this._queuedUpdates.Count != 0)
|
|
{
|
|
throw new InvalidOperationException("Cannot import state while there are queued updates. Call PublishUpdatesAsync() first.");
|
|
}
|
|
|
|
this._queuedUpdates.Clear();
|
|
this._scopes.Clear();
|
|
|
|
Dictionary<ScopeKey, PortableValue> importedState = checkpoint.StateData;
|
|
|
|
foreach (ScopeKey scopeKey in importedState.Keys)
|
|
{
|
|
StateScope scope = this.GetOrCreateScope(scopeKey.ScopeId);
|
|
scope.ImportState(scopeKey.Key, importedState[scopeKey]);
|
|
}
|
|
|
|
return default;
|
|
}
|
|
}
|