mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET Workflows - Update State Serialization for JSON Checkpointing (#1388)
* Checkpoint * Checkpoint * Namespace * All ready * Namespace * Clean * Fix
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Uncomment this to enable JSON checkpointing to the local file system.
|
||||
#define CHECKPOINT_JSON
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -57,8 +61,15 @@ internal sealed class Program
|
||||
// Run the workflow, just like any other workflow
|
||||
string input = this.GetWorkflowInput();
|
||||
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
#if CHECKPOINT_JSON
|
||||
// Use a file-system based JSON checkpoint store to persist checkpoints to disk.
|
||||
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:YYmmdd-hhMMss-ff}"));
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
|
||||
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager);
|
||||
#else
|
||||
// Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process.
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
|
||||
#endif
|
||||
|
||||
bool isComplete = false;
|
||||
InputResponse? response = null;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
|
||||
/// <summary>
|
||||
@@ -12,6 +14,7 @@ public sealed class InputRequest
|
||||
/// </summary>
|
||||
public string Prompt { get; }
|
||||
|
||||
[JsonConstructor]
|
||||
internal InputRequest(string prompt)
|
||||
{
|
||||
this.Prompt = prompt;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,6 +18,7 @@ public sealed class InputResponse
|
||||
/// Initializes a new instance of the <see cref="InputResponse"/> class.
|
||||
/// </summary>
|
||||
/// <param name="value">The response value.</param>
|
||||
[JsonConstructor]
|
||||
public InputResponse(string value)
|
||||
{
|
||||
this.Value = value;
|
||||
|
||||
@@ -148,8 +148,6 @@ internal static class DataValueExtensions
|
||||
|
||||
IEnumerable<KeyValuePair<string, DataValue>> GetFields()
|
||||
{
|
||||
yield return new KeyValuePair<string, DataValue>(TypeSchema.Discriminator, nameof(ExpandoObject).ToDataValue());
|
||||
|
||||
foreach (string key in value.Keys)
|
||||
{
|
||||
yield return new KeyValuePair<string, DataValue>(key, value[key].ToDataValue());
|
||||
@@ -252,7 +250,6 @@ internal static class DataValueExtensions
|
||||
private static Dictionary<string, object?> ToDictionary(this RecordDataValue record)
|
||||
{
|
||||
Dictionary<string, object?> result = [];
|
||||
result[TypeSchema.Discriminator] = nameof(ExpandoObject);
|
||||
foreach (KeyValuePair<string, DataValue> property in record.Properties)
|
||||
{
|
||||
result[property.Key] = property.Value.ToObject();
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
|
||||
internal static class ExpandoObjectExtensions
|
||||
{
|
||||
public static RecordType ToRecordType(this ExpandoObject value)
|
||||
{
|
||||
RecordType recordType = RecordType.Empty();
|
||||
|
||||
foreach (KeyValuePair<string, object?> property in value)
|
||||
{
|
||||
recordType.Add(property.Key, property.Value.GetFormulaType());
|
||||
}
|
||||
|
||||
return recordType;
|
||||
}
|
||||
|
||||
public static RecordValue ToRecord(this ExpandoObject value) =>
|
||||
FormulaValue.NewRecordFromFields(
|
||||
value.Select(
|
||||
property => new NamedValue(property.Key, property.Value.ToFormula())));
|
||||
}
|
||||
+6
-15
@@ -123,6 +123,8 @@ internal static class FormulaValueExtensions
|
||||
_ => DataType.Unspecified,
|
||||
};
|
||||
|
||||
public static object AsPortable(this FormulaValue? value) => (value?.ToObject()).AsPortable();
|
||||
|
||||
public static string Format(this FormulaValue value) =>
|
||||
value switch
|
||||
{
|
||||
@@ -161,6 +163,10 @@ internal static class FormulaValueExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
public static RecordValue ToRecord(this Dictionary<string, PortableValue> value) =>
|
||||
FormulaValue.NewRecordFromFields(
|
||||
value.Select(
|
||||
property => new NamedValue(property.Key, property.Value.ToFormula())));
|
||||
|
||||
private static RecordDataType ToDataType(this RecordType record)
|
||||
{
|
||||
@@ -182,21 +188,6 @@ internal static class FormulaValueExtensions
|
||||
return tableType;
|
||||
}
|
||||
|
||||
private static RecordType ToRecordType(this ExpandoObject value)
|
||||
{
|
||||
RecordType recordType = RecordType.Empty();
|
||||
foreach (KeyValuePair<string, object?> property in value)
|
||||
{
|
||||
recordType.Add(property.Key, property.Value.GetFormulaType());
|
||||
}
|
||||
return recordType;
|
||||
}
|
||||
|
||||
private static RecordValue ToRecord(this ExpandoObject value) =>
|
||||
FormulaValue.NewRecordFromFields(
|
||||
value.Select(
|
||||
property => new NamedValue(property.Key, property.Value.ToFormula())));
|
||||
|
||||
private static TableType ToTableType(this IEnumerable value)
|
||||
{
|
||||
foreach (object? element in value)
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Bot.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
@@ -46,6 +47,56 @@ internal static class ObjectExtensions
|
||||
}
|
||||
}
|
||||
|
||||
public static object AsPortable(this object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => UnassignedValue.Instance,
|
||||
string or
|
||||
bool or
|
||||
int or
|
||||
float or
|
||||
long or
|
||||
decimal or
|
||||
double or
|
||||
DateTime or
|
||||
TimeSpan =>
|
||||
value,
|
||||
ChatMessage messageValue => messageValue.ToRecord().AsPortable(),
|
||||
IDictionary<string, object?> objectValue => objectValue.AsPortable(),
|
||||
IDictionary recordValue => recordValue.AsPortable(),
|
||||
IEnumerable tableValue => tableValue.AsPortable(),
|
||||
_ => throw new DeclarativeModelException($"Unsupported data type: {value.GetType().Name}"),
|
||||
};
|
||||
|
||||
public static object AsPortable(this IDictionary<string, object?> value) => value.ToDictionary(kvp => kvp.Key, kvp => new PortableValue(kvp.Value.AsPortable()));
|
||||
|
||||
public static object AsPortable(this IDictionary value)
|
||||
{
|
||||
return GetEntries().ToDictionary(kvp => kvp.Key, kvp => new PortableValue(kvp.Value.AsPortable()));
|
||||
|
||||
IEnumerable<KeyValuePair<string, object?>> GetEntries()
|
||||
{
|
||||
foreach (string key in value.Keys)
|
||||
{
|
||||
yield return new KeyValuePair<string, object?>(key, value[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static object AsPortable(this IEnumerable value)
|
||||
{
|
||||
return GetValues().ToArray();
|
||||
|
||||
IEnumerable<PortableValue> GetValues()
|
||||
{
|
||||
IEnumerator enumerator = value.GetEnumerator();
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
yield return new PortableValue(enumerator.Current.AsPortable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static object? ConvertType(this object? sourceValue, VariableType targetType)
|
||||
{
|
||||
if (!targetType.IsValid())
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
|
||||
internal static class PortableValueExtensions
|
||||
{
|
||||
public static FormulaValue ToFormula(this PortableValue value) =>
|
||||
value.TypeId switch
|
||||
{
|
||||
null => FormulaValue.NewBlank(),
|
||||
_ when value.TypeId.IsMatch<UnassignedValue>() => FormulaValue.NewBlank(),
|
||||
_ when value.IsType(out string? stringValue) => FormulaValue.New(stringValue),
|
||||
_ when value.IsSystemType(out bool? boolValue) => FormulaValue.New(boolValue.Value),
|
||||
_ when value.IsSystemType(out int? intValue) => FormulaValue.New(intValue.Value),
|
||||
_ when value.IsSystemType(out long? longValue) => FormulaValue.New(longValue.Value),
|
||||
_ when value.IsSystemType(out decimal? decimalValue) => FormulaValue.New(decimalValue.Value),
|
||||
_ when value.IsSystemType(out float? floatValue) => FormulaValue.New(floatValue.Value),
|
||||
_ when value.IsSystemType(out double? doubleValue) => FormulaValue.New(doubleValue.Value),
|
||||
_ when value.IsParentType(out Dictionary<string, PortableValue>? recordValue) => recordValue.ToRecord(),
|
||||
_ when value.IsParentType(out IDictionary? recordValue) => recordValue.ToRecord(),
|
||||
_ when value.IsType(out PortableValue[]? tableValue) => tableValue.ToTable(),
|
||||
_ when value.IsType(out ChatMessage? messageValue) => messageValue.ToRecord(),
|
||||
_ when value.IsType(out DateTime dateValue) =>
|
||||
dateValue.TimeOfDay == TimeSpan.Zero ?
|
||||
FormulaValue.NewDateOnly(dateValue.Date) :
|
||||
FormulaValue.New(dateValue),
|
||||
_ when value.IsType(out TimeSpan timeValue) => FormulaValue.New(timeValue),
|
||||
_ => throw new DeclarativeModelException($"Unsupported portable type: {value.TypeId.TypeName}"),
|
||||
};
|
||||
|
||||
private static TableValue ToTable(this PortableValue[] values)
|
||||
{
|
||||
FormulaValue[] formulaValues = values.Select(value => value.ToFormula()).ToArray();
|
||||
if (formulaValues[0] is RecordValue recordValue)
|
||||
{
|
||||
return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType<RecordValue>());
|
||||
}
|
||||
|
||||
return
|
||||
formulaValues[0] switch
|
||||
{
|
||||
PrimitiveValue<bool> => NewSingleColumnTable<bool>(),
|
||||
PrimitiveValue<string> => NewSingleColumnTable<string>(),
|
||||
PrimitiveValue<int> => NewSingleColumnTable<int>(),
|
||||
PrimitiveValue<long> => NewSingleColumnTable<long>(),
|
||||
PrimitiveValue<float> => NewSingleColumnTable<float>(),
|
||||
PrimitiveValue<decimal> => NewSingleColumnTable<decimal>(),
|
||||
PrimitiveValue<double> => NewSingleColumnTable<double>(),
|
||||
PrimitiveValue<TimeSpan> => NewSingleColumnTable<TimeSpan>(),
|
||||
PrimitiveValue<DateTime> => NewSingleColumnTable<DateTime>(),
|
||||
_ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"),
|
||||
};
|
||||
|
||||
TableValue NewSingleColumnTable<TValue>() =>
|
||||
FormulaValue.NewSingleColumnTable(formulaValues.OfType<PrimitiveValue<TValue>>());
|
||||
}
|
||||
|
||||
private static RecordType ParseRecordType(this RecordValue record)
|
||||
{
|
||||
RecordType recordType = RecordType.Empty();
|
||||
foreach (NamedValue property in record.Fields)
|
||||
{
|
||||
recordType = recordType.Add(property.Name, property.Value.Type);
|
||||
}
|
||||
return recordType;
|
||||
}
|
||||
|
||||
private static bool IsParentType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
|
||||
{
|
||||
if (value.TypeId.IsMatchPolymorphic(typeof(TValue)))
|
||||
{
|
||||
return value.Is(out typedValue);
|
||||
}
|
||||
|
||||
typedValue = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSystemType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct
|
||||
{
|
||||
if (value.TypeId.IsMatch<TValue>() || value.TypeId.IsMatch(typeof(TValue).UnderlyingSystemType))
|
||||
{
|
||||
return value.Is(out typedValue);
|
||||
}
|
||||
|
||||
typedValue = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
|
||||
{
|
||||
if (value.TypeId.IsMatch<TValue>())
|
||||
{
|
||||
return value.Is(out typedValue);
|
||||
}
|
||||
|
||||
typedValue = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -142,27 +142,32 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
|
||||
{
|
||||
this.State.Set(key, formulaValue, scopeName);
|
||||
}
|
||||
return this.Source.QueueStateUpdateAsync(key, formulaValue.ToObject(), scopeName, cancellationToken);
|
||||
|
||||
return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
|
||||
}
|
||||
|
||||
ValueTask QueueDataValueStateAsync(DataValue dataValue)
|
||||
{
|
||||
FormulaValue formulaValue = dataValue.ToFormula();
|
||||
|
||||
if (isManagedScope)
|
||||
{
|
||||
FormulaValue formulaValue = dataValue.ToFormula();
|
||||
this.State.Set(key, formulaValue, scopeName);
|
||||
}
|
||||
return this.Source.QueueStateUpdateAsync(key, dataValue.ToObject(), scopeName, cancellationToken);
|
||||
|
||||
return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
|
||||
}
|
||||
|
||||
ValueTask QueueNativeStateAsync(object? rawValue)
|
||||
ValueTask QueueNativeStateAsync(object rawValue)
|
||||
{
|
||||
FormulaValue formulaValue = rawValue.ToFormula();
|
||||
|
||||
if (isManagedScope)
|
||||
{
|
||||
FormulaValue formulaValue = rawValue.ToFormula();
|
||||
this.State.Set(key, formulaValue, scopeName);
|
||||
}
|
||||
return this.Source.QueueStateUpdateAsync(key, rawValue, scopeName, cancellationToken);
|
||||
|
||||
return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -2,11 +2,11 @@
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Bot.ObjectModel;
|
||||
using Microsoft.PowerFx;
|
||||
using Microsoft.PowerFx.Types;
|
||||
@@ -68,20 +68,25 @@ internal sealed class WorkflowFormulaState
|
||||
return;
|
||||
}
|
||||
|
||||
Stopwatch timer = Stopwatch.StartNew();
|
||||
Debug.WriteLine("RESTORE CHECKPOINT - BEGIN");
|
||||
await Task.WhenAll(RestorableScopes.Select(scopeName => ReadScopeAsync(scopeName))).ConfigureAwait(false);
|
||||
Debug.WriteLine($"RESTORE CHECKPOINT - COMPLETE [{timer.Elapsed}]");
|
||||
|
||||
async Task ReadScopeAsync(string scopeName)
|
||||
{
|
||||
HashSet<string> keys = await context.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
|
||||
foreach (string key in keys)
|
||||
{
|
||||
object? value = await context.ReadStateAsync<object>(key, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
if (value is null or UnassignedValue)
|
||||
PortableValue? value = await context.ReadStateAsync<PortableValue>(key, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
if (value is null)
|
||||
{
|
||||
value = FormulaValue.NewBlank();
|
||||
this.Set(key, FormulaValue.NewBlank(), scopeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.Set(key, value.ToFormula(), scopeName);
|
||||
FormulaValue formulaValue = value.ToFormula();
|
||||
this.Set(key, formulaValue, scopeName);
|
||||
Debug.WriteLine($"RESTORED: {scopeName}.{key} => {formulaValue.Type}");
|
||||
}
|
||||
|
||||
this.Bind(scopeName);
|
||||
|
||||
+8
-3
@@ -56,7 +56,12 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
// read the lines of indexfile and parse them as CheckpointInfos
|
||||
this.CheckpointIndex = [];
|
||||
using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: -1, leaveOpen: true);
|
||||
#if NET
|
||||
const int BufferSize = -1;
|
||||
#else
|
||||
const int BufferSize = 1024;
|
||||
#endif
|
||||
using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true);
|
||||
while (reader.ReadLine() is string line)
|
||||
{
|
||||
if (JsonSerializer.Deserialize(line, KeyTypeInfo) is { } info)
|
||||
@@ -65,9 +70,9 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not load store at '{directory.FullName}'. Index corrupted.");
|
||||
throw new InvalidOperationException($"Could not load store at '{directory.FullName}'. Index corrupted.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public sealed class PortableValue
|
||||
{
|
||||
internal PortableValue(object value)
|
||||
/// <summary>
|
||||
/// Initializes a new instance <see cref="PortableValue"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The represented value.</param>
|
||||
public PortableValue(object value)
|
||||
{
|
||||
this._value = value;
|
||||
this.TypeId = new(value.GetType());
|
||||
|
||||
+7
-4
@@ -29,11 +29,14 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT
|
||||
[InlineData("Marketing.yaml", "Marketing.json", true)]
|
||||
[InlineData("MathChat.yaml", "MathChat.json", true)]
|
||||
[InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")]
|
||||
[InlineData("HumanInLoop.yaml", "HumanInLoop.json", Skip = "Needs template support")]
|
||||
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
|
||||
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
|
||||
|
||||
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input)
|
||||
[Fact(Skip = "Needs template support")]
|
||||
public Task ValidateMultiTurnAsync() =>
|
||||
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true);
|
||||
|
||||
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint)
|
||||
{
|
||||
const string WorkflowNamespace = "Test.WorkflowProviders";
|
||||
const string WorkflowPrefix = "Test";
|
||||
@@ -49,13 +52,13 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT
|
||||
workflowOptions,
|
||||
input);
|
||||
|
||||
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input).ConfigureAwait(false);
|
||||
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false);
|
||||
|
||||
// Verify no action events are present
|
||||
Assert.Empty(workflowEvents.ActionInvokeEvents);
|
||||
Assert.Empty(workflowEvents.ActionCompleteEvents);
|
||||
// Verify the associated conversations
|
||||
AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase);
|
||||
AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase);
|
||||
// Verify executor events
|
||||
AssertWorkflow.EventCounts(workflowEvents.ExecutorInvokeEvents.Count - 2, testcase);
|
||||
AssertWorkflow.EventCounts(workflowEvents.ExecutorCompleteEvents.Count - 2, testcase);
|
||||
|
||||
+7
-4
@@ -29,22 +29,25 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
[InlineData("Marketing.yaml", "Marketing.json", true)]
|
||||
[InlineData("MathChat.yaml", "MathChat.json", true)]
|
||||
[InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")]
|
||||
[InlineData("HumanInLoop.yaml", "HumanInLoop.json")]
|
||||
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
|
||||
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
|
||||
|
||||
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input)
|
||||
[Fact]
|
||||
public Task ValidateMultiTurnAsync() =>
|
||||
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true);
|
||||
|
||||
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint)
|
||||
{
|
||||
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
|
||||
|
||||
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
|
||||
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input).ConfigureAwait(false);
|
||||
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false);
|
||||
|
||||
// Verify executor events are present
|
||||
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
|
||||
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
|
||||
// Verify the associated conversations
|
||||
AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase);
|
||||
AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase);
|
||||
// Verify the agent responses
|
||||
AssertWorkflow.Responses(workflowEvents.AgentResponseEvents, testcase);
|
||||
// Verify the messages on the workflow conversation
|
||||
|
||||
+35
-14
@@ -2,9 +2,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Shared.Code;
|
||||
using Xunit.Sdk;
|
||||
@@ -13,12 +15,12 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
|
||||
|
||||
internal sealed class WorkflowHarness(Workflow workflow, string runId)
|
||||
{
|
||||
private readonly CheckpointManager _checkpointManager = CheckpointManager.CreateInMemory();
|
||||
private CheckpointManager? _checkpointManager;
|
||||
private CheckpointInfo? LastCheckpoint { get; set; }
|
||||
|
||||
public async Task<WorkflowEvents> RunTestcaseAsync<TInput>(Testcase testcase, TInput input) where TInput : notnull
|
||||
public async Task<WorkflowEvents> RunTestcaseAsync<TInput>(Testcase testcase, TInput input, bool useJson = false) where TInput : notnull
|
||||
{
|
||||
WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input);
|
||||
WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input, useJson);
|
||||
int requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
|
||||
int responseCount = 0;
|
||||
while (requestCount > responseCount)
|
||||
@@ -37,24 +39,15 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
|
||||
return workflowEvents;
|
||||
}
|
||||
|
||||
public async Task<WorkflowEvents> RunWorkflowAsync<TInput>(TInput input) where TInput : notnull
|
||||
public async Task<WorkflowEvents> RunWorkflowAsync<TInput>(TInput input, bool useJson = false) where TInput : notnull
|
||||
{
|
||||
Console.WriteLine("RUNNING WORKFLOW...");
|
||||
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, this._checkpointManager, runId);
|
||||
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, this.GetCheckpointManager(useJson), runId);
|
||||
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run).ToArrayAsync();
|
||||
this.LastCheckpoint = workflowEvents.OfType<SuperStepCompletedEvent>().LastOrDefault()?.CompletionInfo?.Checkpoint;
|
||||
return new WorkflowEvents(workflowEvents);
|
||||
}
|
||||
|
||||
private async Task<WorkflowEvents> ResumeAsync(InputResponse response)
|
||||
{
|
||||
Console.WriteLine("RESUMING WORKFLOW...");
|
||||
Assert.NotNull(this.LastCheckpoint);
|
||||
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this._checkpointManager, runId);
|
||||
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
|
||||
return new WorkflowEvents(workflowEvents);
|
||||
}
|
||||
|
||||
public static async Task<WorkflowHarness> GenerateCodeAsync<TInput>(
|
||||
string runId,
|
||||
string workflowProviderCode,
|
||||
@@ -76,6 +69,30 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
|
||||
return new WorkflowHarness(workflow, runId);
|
||||
}
|
||||
|
||||
private CheckpointManager GetCheckpointManager(bool useJson = false)
|
||||
{
|
||||
if (useJson && this._checkpointManager is null)
|
||||
{
|
||||
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:YYmmdd-hhMMss-ff}"));
|
||||
this._checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
|
||||
}
|
||||
else
|
||||
{
|
||||
this._checkpointManager ??= CheckpointManager.CreateInMemory();
|
||||
}
|
||||
|
||||
return this._checkpointManager;
|
||||
}
|
||||
|
||||
private async Task<WorkflowEvents> ResumeAsync(InputResponse response)
|
||||
{
|
||||
Console.WriteLine("RESUMING WORKFLOW...");
|
||||
Assert.NotNull(this.LastCheckpoint);
|
||||
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId);
|
||||
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
|
||||
return new WorkflowEvents(workflowEvents);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, InputResponse? response = null)
|
||||
{
|
||||
await using IAsyncDisposable disposeRun = run;
|
||||
@@ -100,6 +117,10 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
|
||||
}
|
||||
break;
|
||||
|
||||
case ConversationUpdateEvent conversationEvent:
|
||||
Console.WriteLine($"CONVERSATION: {conversationEvent.ConversationId}");
|
||||
break;
|
||||
|
||||
case ExecutorFailedEvent failureEvent:
|
||||
Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}");
|
||||
break;
|
||||
|
||||
+17
-25
@@ -22,12 +22,14 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
|
||||
Testcase testcase,
|
||||
string workflowPath,
|
||||
DeclarativeWorkflowOptions workflowOptions,
|
||||
TInput input) where TInput : notnull;
|
||||
TInput input,
|
||||
bool useJsonCheckpoint) where TInput : notnull;
|
||||
|
||||
protected Task RunWorkflowAsync(
|
||||
string workflowPath,
|
||||
string testcaseFileName,
|
||||
bool externalConversation = false)
|
||||
bool externalConversation = false,
|
||||
bool useJsonCheckpoint = false)
|
||||
{
|
||||
this.Output.WriteLine($"WORKFLOW: {workflowPath}");
|
||||
this.Output.WriteLine($"TESTCASE: {testcaseFileName}");
|
||||
@@ -39,24 +41,21 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
|
||||
return
|
||||
testcase.Setup.Input.Type switch
|
||||
{
|
||||
nameof(ChatMessage) => this.TestWorkflowAsync<ChatMessage>(testcase, workflowPath),
|
||||
nameof(String) => this.TestWorkflowAsync<string>(testcase, workflowPath),
|
||||
nameof(ChatMessage) => TestWorkflowAsync<ChatMessage>(),
|
||||
nameof(String) => TestWorkflowAsync<string>(),
|
||||
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
|
||||
};
|
||||
}
|
||||
|
||||
protected async Task TestWorkflowAsync<TInput>(
|
||||
Testcase testcase,
|
||||
string workflowPath,
|
||||
bool externalConversation = false) where TInput : notnull
|
||||
{
|
||||
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
|
||||
async Task TestWorkflowAsync<TInput>() where TInput : notnull
|
||||
{
|
||||
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
|
||||
|
||||
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false);
|
||||
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false);
|
||||
|
||||
TInput input = (TInput)GetInput<TInput>(testcase);
|
||||
TInput input = (TInput)GetInput<TInput>(testcase);
|
||||
|
||||
await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input);
|
||||
await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input, useJsonCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
protected static string? GetConversationId(string? conversationId, IReadOnlyList<ConversationUpdateEvent> conversationEvents)
|
||||
@@ -76,8 +75,8 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
|
||||
|
||||
protected static Testcase ReadTestcase(string testcaseFileName)
|
||||
{
|
||||
using Stream testcaseStream = File.Open(Path.Combine("Testcases", testcaseFileName), FileMode.Open);
|
||||
Testcase? testcase = JsonSerializer.Deserialize<Testcase>(testcaseStream, s_jsonSerializerOptions);
|
||||
string testcaseJson = File.ReadAllText(Path.Combine("Testcases", testcaseFileName));
|
||||
Testcase? testcase = JsonSerializer.Deserialize<Testcase>(testcaseJson, s_jsonSerializerOptions);
|
||||
Assert.NotNull(testcase);
|
||||
return testcase;
|
||||
}
|
||||
@@ -109,16 +108,9 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
|
||||
|
||||
protected static class AssertWorkflow
|
||||
{
|
||||
public static void Conversation(string? conversationId, IReadOnlyList<ConversationUpdateEvent> conversationEvents, Testcase testcase)
|
||||
public static void Conversation(IReadOnlyList<ConversationUpdateEvent> conversationEvents, Testcase testcase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(conversationId))
|
||||
{
|
||||
Assert.Equal(testcase.Validation.ConversationCount, conversationEvents.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(testcase.Validation.ConversationCount - 1, conversationEvents.Count);
|
||||
}
|
||||
Assert.Equal(testcase.Validation.ConversationCount, conversationEvents.Count);
|
||||
}
|
||||
|
||||
// "isCompletion" adjusts validation logic to account for when condition completion is not experienced due to goto. Remove this test logic once addressed.
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
|
||||
|
||||
public sealed class PortableValueExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvalidType() => TestInvalidType(IPAddress.Loopback);
|
||||
|
||||
[Fact]
|
||||
public void NullType() => TestValidType<object>(null, FormulaType.Blank);
|
||||
|
||||
[Fact]
|
||||
public void BooleanType() => TestValidType(true, FormulaType.Boolean);
|
||||
|
||||
[Fact]
|
||||
public void StringType() => TestValidType("Hello, World!", FormulaType.String);
|
||||
|
||||
[Fact]
|
||||
public void IntType() => TestValidType(int.MinValue, FormulaType.Decimal);
|
||||
|
||||
[Fact]
|
||||
public void LongType() => TestValidType(long.MaxValue, FormulaType.Decimal);
|
||||
|
||||
[Fact]
|
||||
public void DecimalType() => TestValidType(decimal.MaxValue, FormulaType.Decimal);
|
||||
|
||||
[Fact]
|
||||
public void FloatType() => TestValidType(float.MaxValue, FormulaType.Number);
|
||||
|
||||
[Fact]
|
||||
public void DoubleType() => TestValidType(double.MinValue, FormulaType.Number);
|
||||
|
||||
[Fact]
|
||||
public void DateType() => TestValidType(DateTime.UtcNow.Date, FormulaType.Date);
|
||||
|
||||
[Fact]
|
||||
public void DateTimeType() => TestValidType(DateTime.UtcNow, FormulaType.DateTime);
|
||||
|
||||
[Fact]
|
||||
public void TimeSpanType() => TestValidType(DateTime.UtcNow.TimeOfDay, FormulaType.Time);
|
||||
|
||||
[Fact]
|
||||
public void ChatMessageType() => TestValidType(new ChatMessage(ChatRole.User, "input"), RecordType.Empty());
|
||||
|
||||
[Fact]
|
||||
public void ListSimpleType()
|
||||
{
|
||||
TableValue convertedValue = (TableValue)TestValidType(new List<int> { 1, 2, 3 }, TableType.Empty());
|
||||
Assert.Equal(3, convertedValue.Count());
|
||||
RecordValue firstElement = convertedValue.Rows.First().Value;
|
||||
NamedValue recordElement = Assert.Single(firstElement.Fields);
|
||||
Assert.Equal("Value", recordElement.Name);
|
||||
DecimalValue recordValue = Assert.IsType<DecimalValue>(recordElement.Value);
|
||||
Assert.Equal(1, recordValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListComplexType()
|
||||
{
|
||||
TableValue convertedValue = (TableValue)TestValidType(new List<ChatMessage> { new(ChatRole.User, "input"), new(ChatRole.Assistant, "output") }, TableType.Empty());
|
||||
Assert.Equal(2, convertedValue.Count());
|
||||
RecordValue firstElement = convertedValue.Rows.First().Value;
|
||||
StringValue typeValue = Assert.IsType<StringValue>(firstElement.GetField(TypeSchema.Discriminator));
|
||||
Assert.Equal(nameof(ChatMessage), typeValue.Value);
|
||||
StringValue textValue = Assert.IsType<StringValue>(firstElement.GetField(TypeSchema.Message.Fields.Text));
|
||||
Assert.Equal("input", textValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DictionaryType()
|
||||
{
|
||||
RecordValue convertedValue = (RecordValue)TestValidType(new Dictionary<string, int> { { "A", 1 }, { "B", 2 } }, RecordType.Empty());
|
||||
Assert.Equal(2, convertedValue.Fields.Count());
|
||||
NamedValue firstElement = convertedValue.Fields.First();
|
||||
Assert.Equal("A", firstElement.Name);
|
||||
DecimalValue firstElementValue = Assert.IsType<DecimalValue>(firstElement.Value);
|
||||
Assert.Equal(1, firstElementValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectType()
|
||||
{
|
||||
RecordValue convertedValue = (RecordValue)TestValidType(FormulaValue.NewRecordFromFields(new NamedValue("key", FormulaValue.New(3))).ToDataValue().ToObject(), RecordType.Empty());
|
||||
Assert.Single(convertedValue.Fields);
|
||||
NamedValue firstElement = convertedValue.Fields.First();
|
||||
Assert.Equal("key", firstElement.Name);
|
||||
DecimalValue firstElementValue = Assert.IsType<DecimalValue>(firstElement.Value);
|
||||
Assert.Equal(3, firstElementValue.Value);
|
||||
}
|
||||
|
||||
private static void TestInvalidType(object? sourceValue)
|
||||
{
|
||||
Assert.Throws<DeclarativeModelException>(() => sourceValue.AsPortable());
|
||||
|
||||
PortableValue portableValue = new(sourceValue ?? UnassignedValue.Instance);
|
||||
Assert.Throws<DeclarativeModelException>(() => portableValue.ToFormula());
|
||||
}
|
||||
|
||||
private static FormulaValue TestValidType<TValue>(TValue? sourceValue, FormulaType expectedType) where TValue : notnull
|
||||
{
|
||||
object portableObject = sourceValue.AsPortable();
|
||||
Assert.IsNotType<PortableValue>(portableObject);
|
||||
PortableValue portableValue = new(portableObject);
|
||||
FormulaValue formulaValue = portableValue.ToFormula();
|
||||
Assert.NotNull(formulaValue);
|
||||
Assert.Equal(expectedType.GetType(), formulaValue.Type.GetType());
|
||||
return formulaValue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user