Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd33922869 | |||
| 4321a230c5 | |||
| 403f6e9fc7 | |||
| 87acc91630 |
@@ -241,6 +241,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/03-workflows/Declarative/">
|
||||
<File Path="samples/03-workflows/Declarative/README.md" />
|
||||
<Project Path="samples/03-workflows/Declarative/AotCheckpointing/AotCheckpointing.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj" />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Simulate an AOT / trim-aggressive deployment by disabling System.Text.Json's
|
||||
implicit reflection fallback. When this flag is false, any JSON operation in
|
||||
the process must resolve type info via a source-generated JsonSerializerContext
|
||||
or fail at runtime (the checkpoint pipeline surfaces this as
|
||||
InvalidOperationException: "No JSON type info is available for type 'X'").
|
||||
This is the same constraint that 'dotnet publish -p:PublishAot=true' imposes,
|
||||
observed without requiring a full AOT publish.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="AotCheckpointing.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
#
|
||||
# This workflow has three discrete actions so each runs in a distinct
|
||||
# superstep, making the checkpoint progression easy to follow.
|
||||
#
|
||||
# 1. SetVariable: capture the user input
|
||||
# 2. InvokeAzureAgent: greet the user via an Azure agent (autoSend on)
|
||||
# 3. SendActivity: emit a closing message
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_aot_demo
|
||||
actions:
|
||||
|
||||
# Capture the user's input
|
||||
- kind: SetVariable
|
||||
id: set_input
|
||||
variable: Local.UserInput
|
||||
value: =System.LastMessage.Text
|
||||
|
||||
# Invoke the greeting agent and stream the response to the user
|
||||
- kind: InvokeAzureAgent
|
||||
id: invoke_greeter
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GreeterAgent
|
||||
input:
|
||||
messages: =Local.UserInput
|
||||
output:
|
||||
messages: Local.GreeterResponse
|
||||
autoSend: true
|
||||
|
||||
# Emit a closing activity so there is a clear final superstep
|
||||
- kind: SendActivity
|
||||
id: send_summary
|
||||
activity: |-
|
||||
[Sample] Workflow completed. Checkpoints persisted to disk.
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
namespace Demo.Workflows.Declarative.AotCheckpointing;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates JSON checkpointing of a declarative workflow under reflection-disabled
|
||||
/// <see cref="System.Text.Json.JsonSerializer"/> (the AOT / trim-aggressive constraint set
|
||||
/// via <c>JsonSerializerIsReflectionEnabledByDefault=false</c> in the csproj).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The key call is <see cref="CheckpointManager.CreateJson(ICheckpointStore{System.Text.Json.JsonElement}, System.Text.Json.JsonSerializerOptions?)"/>
|
||||
/// with <see cref="DeclarativeWorkflowJsonOptions.Default"/>. Drop the options argument to observe the AOT failure. See README.
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
|
||||
await CreateGreeterAgentAsync(foundryEndpoint, configuration);
|
||||
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
Workflow CreateWorkflow()
|
||||
{
|
||||
AzureAgentProvider agentProvider = new(foundryEndpoint, new AzureCliCredential());
|
||||
DeclarativeWorkflowOptions options = new(agentProvider) { Configuration = configuration };
|
||||
string workflowPath = Path.Combine(AppContext.BaseDirectory, "AotCheckpointing.yaml");
|
||||
return DeclarativeWorkflowBuilder.Build<string>(workflowPath, options);
|
||||
}
|
||||
|
||||
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-HHmmss-ff}"));
|
||||
try
|
||||
{
|
||||
using FileSystemJsonCheckpointStore store = new(checkpointFolder);
|
||||
|
||||
// KEY LINE: AOT-safe checkpoint manager. Drop the options argument to see the failure.
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default);
|
||||
|
||||
Console.WriteLine($"\nCheckpoint folder: {checkpointFolder.FullName}");
|
||||
|
||||
// Phase 1: run + drain. Every [checkpoint x<n>] line is a successful JSON WRITE.
|
||||
List<CheckpointInfo> checkpoints = await RunAndStreamAsync(CreateWorkflow(), workflowInput, checkpointManager).ConfigureAwait(false);
|
||||
|
||||
// Phase 2: prove the JSON READ path. ResumeStreamingAsync deserializes the checkpoint
|
||||
// inside the call; a clean return is the proof. We do not drain the resumed run because
|
||||
// it parks in WaitForInputAsync without a pending external request.
|
||||
if (checkpoints.Count > 0)
|
||||
{
|
||||
CheckpointInfo resumeFromCheckpoint = checkpoints[0];
|
||||
Console.WriteLine($"\nWORKFLOW: Verifying read path by resuming from checkpoint {resumeFromCheckpoint.CheckpointId}");
|
||||
StreamingRun resumed = await InProcessExecution.ResumeStreamingAsync(CreateWorkflow(), resumeFromCheckpoint, checkpointManager).ConfigureAwait(false);
|
||||
await resumed.DisposeAsync().ConfigureAwait(false);
|
||||
Console.WriteLine("WORKFLOW: Checkpoint deserialized successfully");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nWORKFLOW: Done!\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(checkpointFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<CheckpointInfo>> RunAndStreamAsync(Workflow workflow, string input, CheckpointManager checkpointManager)
|
||||
{
|
||||
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpointManager).ConfigureAwait(false);
|
||||
return await DrainAsync(run).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<List<CheckpointInfo>> DrainAsync(StreamingRun run)
|
||||
{
|
||||
#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
|
||||
await using IAsyncDisposable disposeRun = run;
|
||||
#pragma warning restore CA2007
|
||||
|
||||
List<CheckpointInfo> checkpoints = [];
|
||||
string? streamingMessageId = null;
|
||||
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
switch (workflowEvent)
|
||||
{
|
||||
case WorkflowErrorEvent workflowError:
|
||||
throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected workflow failure.");
|
||||
|
||||
case SuperStepCompletedEvent superStepCompleted:
|
||||
CheckpointInfo? checkpoint = superStepCompleted.CompletionInfo?.Checkpoint;
|
||||
if (checkpoint is not null)
|
||||
{
|
||||
checkpoints.Add(checkpoint);
|
||||
}
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine($"\n[checkpoint x{superStepCompleted.StepNumber}: {checkpoint?.CheckpointId ?? "(none)"}]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case MessageActivityEvent activityEvent:
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\nACTIVITY: {activityEvent.Message.Trim()}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case AgentResponseUpdateEvent streamEvent:
|
||||
if (!string.Equals(streamingMessageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
streamingMessageId = streamEvent.Update.MessageId;
|
||||
string agentName = streamEvent.Update.AuthorName ?? streamEvent.Update.AgentId ?? nameof(ChatRole.Assistant);
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write($"\n{agentName.ToUpperInvariant()}: ");
|
||||
Console.ResetColor();
|
||||
}
|
||||
Console.Write(streamEvent.Update.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return checkpoints;
|
||||
}
|
||||
|
||||
private static async Task CreateGreeterAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
|
||||
|
||||
DeclarativeAgentDefinition definition =
|
||||
new(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a warm and concise greeter. Reply to the user's message in
|
||||
one or two short sentences. Always include the user's name if they
|
||||
provided one, and end with a friendly question.
|
||||
"""
|
||||
};
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "GreeterAgent",
|
||||
agentDefinition: definition,
|
||||
agentDescription: "Greeter agent for the AotCheckpointing sample.");
|
||||
}
|
||||
|
||||
private static void TryDelete(DirectoryInfo directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
directory.Refresh();
|
||||
if (directory.Exists)
|
||||
{
|
||||
directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($"\n(could not clean up '{directory.FullName}': {ex.Message})");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# AotCheckpointing sample
|
||||
|
||||
Demonstrates JSON checkpointing of a declarative workflow under
|
||||
reflection-disabled `System.Text.Json` -- the same constraint imposed by
|
||||
**AOT / trim-aggressive deployments**.
|
||||
|
||||
## What it shows
|
||||
|
||||
- A 3-action declarative workflow
|
||||
(`SetVariable` -> `InvokeAzureAgent` -> `SendActivity`) checkpointed
|
||||
after every superstep.
|
||||
- The csproj sets
|
||||
`<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>`.
|
||||
Every JSON operation must resolve type info via a source-gen
|
||||
`JsonSerializerContext` or fail with
|
||||
`InvalidOperationException: No JSON type info is available for type 'X'`.
|
||||
- The experimental
|
||||
[`DeclarativeWorkflowJsonOptions.Default`](../../../../src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowJsonOptions.cs)
|
||||
`JsonSerializerOptions` instance covers every declarative-package
|
||||
type that flows through the checkpoint pipeline. Pass it to
|
||||
`CheckpointManager.CreateJson`.
|
||||
- JSON round-trip is verified in two phases:
|
||||
1. Run + drain -- every `[checkpoint x<n>]` line is a successful JSON **write**.
|
||||
2. `ResumeStreamingAsync` on a fresh workflow instance -- a clean
|
||||
return is the proof JSON **reads** round-trip too. The resumed run
|
||||
is disposed immediately; without a pending external request it
|
||||
would park in `WaitForInputAsync` indefinitely.
|
||||
|
||||
`DeclarativeWorkflowJsonOptions` is marked
|
||||
`[Experimental("MAAI001")]`. Suppress that diagnostic in your csproj to
|
||||
use it.
|
||||
|
||||
### Registering user-defined types
|
||||
|
||||
For workflows whose inputs or custom `ActionExecutorResult.Result`
|
||||
payloads are user-defined, clone `Default` and append your own resolver:
|
||||
|
||||
```csharp
|
||||
JsonSerializerOptions options = new(DeclarativeWorkflowJsonOptions.Default);
|
||||
options.TypeInfoResolverChain.Add(MyAppJsonContext.Default);
|
||||
options.MakeReadOnly();
|
||||
CheckpointManager manager = CheckpointManager.CreateJson(store, options);
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Azure Foundry project with a deployed model.
|
||||
- `az login`.
|
||||
- Configuration (user secrets or env):
|
||||
|
||||
| Setting | Description |
|
||||
| --- | --- |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project endpoint URL. |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name. |
|
||||
|
||||
See the [parent README](../README.md) for the full walkthrough.
|
||||
|
||||
```sh
|
||||
cd dotnet/samples/03-workflows/Declarative/AotCheckpointing
|
||||
dotnet run "Hello, my name is Ada."
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
1. `[checkpoint x<n>]` lines after each superstep.
|
||||
2. The agent's streamed response.
|
||||
3. `ACTIVITY: [Sample] Workflow completed. ...`
|
||||
4. `WORKFLOW: Verifying read path by resuming from checkpoint <id>`
|
||||
5. `WORKFLOW: Checkpoint deserialized successfully`
|
||||
6. `WORKFLOW: Done!`
|
||||
|
||||
The `chk-*/` checkpoint folder is deleted at the end.
|
||||
|
||||
## Observe the failure mode
|
||||
|
||||
Drop the options argument:
|
||||
|
||||
```csharp
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default);
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```csharp
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store);
|
||||
```
|
||||
|
||||
Clean rebuild, then re-run. Expected on the first checkpoint commit:
|
||||
|
||||
```
|
||||
System.InvalidOperationException: No JSON type info is available for type
|
||||
'Microsoft.Agents.AI.Workflows.Declarative.Kit.ActionExecutorResult'.
|
||||
```
|
||||
|
||||
This is what `dotnet publish -p:PublishAot=true` would surface at runtime.
|
||||
|
||||
## Notes
|
||||
|
||||
- `PublishAot=true` is **not** set. The
|
||||
`JsonSerializerIsReflectionEnabledByDefault=false` flag is the
|
||||
minimum constraint that reproduces the AOT failure for JSON
|
||||
checkpointing.
|
||||
- JSON code paths inside transitive dependencies (e.g. Foundry SDK)
|
||||
that rely on reflection would also fail under this flag; those are
|
||||
outside the workflow framework's responsibility.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON options for checkpointing declarative workflows under
|
||||
/// AOT / trim-aggressive deployments where reflection-based serialization is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Pass <see cref="Default"/> to <see cref="CheckpointManager.CreateJson(ICheckpointStore{JsonElement}, JsonSerializerOptions?)"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// User-defined types (workflow inputs, custom <see cref="ActionExecutorResult.Result"/> payloads,
|
||||
/// non-primitive approval-request arguments) must be registered in user-supplied options. Compose by
|
||||
/// cloning <see cref="Default"/> and appending your own resolver, for example:
|
||||
/// <code>
|
||||
/// JsonSerializerOptions options = new(DeclarativeWorkflowJsonOptions.Default);
|
||||
/// options.TypeInfoResolverChain.Add(MyAppJsonContext.Default);
|
||||
/// options.MakeReadOnly();
|
||||
/// CheckpointManager manager = CheckpointManager.CreateJson(store, options);
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static partial class DeclarativeWorkflowJsonOptions
|
||||
{
|
||||
/// <summary>Gets the source-gen <see cref="JsonSerializerOptions"/> covering declarative-package checkpoint types.</summary>
|
||||
public static JsonSerializerOptions Default { get; } = CreateDefaultOptions();
|
||||
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Source-gen context.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Source-gen context.")]
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new(DeclarativeJsonContext.Default.Options);
|
||||
|
||||
// Declarative source-gen first, then agent abstractions for ChatMessage/AgentResponse/etc.
|
||||
// Framework types resolve via JsonMarshaller's internal options, so we do not chain WorkflowsJsonUtilities here.
|
||||
options.TypeInfoResolverChain.Clear();
|
||||
options.TypeInfoResolverChain.Add(DeclarativeJsonContext.Default);
|
||||
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(ActionExecutorResult))]
|
||||
[JsonSerializable(typeof(ExternalInputRequest))]
|
||||
[JsonSerializable(typeof(ExternalInputResponse))]
|
||||
[JsonSerializable(typeof(UnassignedValue))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
// TypeInfoPropertyName disambiguates the two ApprovalSnapshot records that share a simple name.
|
||||
[JsonSerializable(typeof(Dictionary<string, InvokeFunctionToolExecutor.ApprovalSnapshot>),
|
||||
TypeInfoPropertyName = "DictionaryStringFunctionApprovalSnapshot")]
|
||||
[JsonSerializable(typeof(InvokeFunctionToolExecutor.ApprovalSnapshot),
|
||||
TypeInfoPropertyName = "FunctionApprovalSnapshot")]
|
||||
[JsonSerializable(typeof(Dictionary<string, InvokeMcpToolExecutor.ApprovalSnapshot>),
|
||||
TypeInfoPropertyName = "DictionaryStringMcpApprovalSnapshot")]
|
||||
[JsonSerializable(typeof(InvokeMcpToolExecutor.ApprovalSnapshot),
|
||||
TypeInfoPropertyName = "McpApprovalSnapshot")]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class DeclarativeJsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
@@ -19,6 +20,7 @@ public sealed record class ActionExecutorResult
|
||||
/// </summary>
|
||||
public object? Result { get; }
|
||||
|
||||
[JsonConstructor]
|
||||
internal ActionExecutorResult(string executorId, object? result = null)
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
|
||||
+2
@@ -7,8 +7,10 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
@@ -70,6 +70,8 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(Checkpoint))]
|
||||
[JsonSerializable(typeof(CheckpointInfo))]
|
||||
[JsonSerializable(typeof(PortableValue))]
|
||||
[JsonSerializable(typeof(PortableValue[]))]
|
||||
[JsonSerializable(typeof(Dictionary<string, PortableValue>))]
|
||||
[JsonSerializable(typeof(PortableMessageEnvelope))]
|
||||
[JsonSerializable(typeof(InMemoryCheckpointManager))]
|
||||
[JsonSerializable(typeof(CheckpointFileIndexEntry))]
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
|
||||
|
||||
public sealed partial class DeclarativeWorkflowJsonOptionsTests(ITestOutputHelper output)
|
||||
: WorkflowTest(output)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(ActionExecutorResult))]
|
||||
[InlineData(typeof(ExternalInputRequest))]
|
||||
[InlineData(typeof(ExternalInputResponse))]
|
||||
[InlineData(typeof(UnassignedValue))]
|
||||
[InlineData(typeof(List<string>))]
|
||||
// Internal approval-snapshot types: catches source-gen regressions from rename/move of the nested types.
|
||||
[InlineData(typeof(InvokeFunctionToolExecutor.ApprovalSnapshot))]
|
||||
[InlineData(typeof(Dictionary<string, InvokeFunctionToolExecutor.ApprovalSnapshot>))]
|
||||
[InlineData(typeof(InvokeMcpToolExecutor.ApprovalSnapshot))]
|
||||
[InlineData(typeof(Dictionary<string, InvokeMcpToolExecutor.ApprovalSnapshot>))]
|
||||
public void DefaultOptions_HasTypeInfoForRegisteredType(Type type)
|
||||
{
|
||||
Assert.True(
|
||||
DeclarativeWorkflowJsonOptions.Default.TryGetTypeInfo(type, out _),
|
||||
$"Default should resolve JsonTypeInfo for {type.FullName}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionExecutorResult_RoundTrip_NullResult()
|
||||
{
|
||||
ActionExecutorResult copy = RoundTrip(new ActionExecutorResult("exec-1"));
|
||||
|
||||
Assert.Equal("exec-1", copy.ExecutorId);
|
||||
Assert.Null(copy.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalInputRequest_RoundTrip_WithApprovalAndFunctionCallContent()
|
||||
{
|
||||
ChatMessage requestMessage = new(
|
||||
ChatRole.Assistant,
|
||||
[
|
||||
new ToolApprovalRequestContent("call1", new FunctionCallContent("call1", "do-something")),
|
||||
new FunctionCallContent("call2", "do-other"),
|
||||
]);
|
||||
ExternalInputRequest copy = RoundTrip(new ExternalInputRequest(new AgentResponse(requestMessage)));
|
||||
|
||||
ChatMessage messageCopy = Assert.Single(copy.AgentResponse.Messages);
|
||||
Assert.Equal(2, messageCopy.Contents.Count);
|
||||
Assert.Contains(messageCopy.Contents, c => c is ToolApprovalRequestContent);
|
||||
Assert.Contains(messageCopy.Contents, c => c is FunctionCallContent);
|
||||
|
||||
// GetInnerRequestContent prefers ToolApprovalRequestContent over FunctionCallContent.
|
||||
AIContent? inner = ((IExternalRequestEnvelope)copy).GetInnerRequestContent();
|
||||
Assert.IsType<ToolApprovalRequestContent>(inner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalInputResponse_RoundTrip()
|
||||
{
|
||||
ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok")));
|
||||
|
||||
ChatMessage messageCopy = Assert.Single(copy.Messages);
|
||||
Assert.Equal(ChatRole.User, messageCopy.Role);
|
||||
Assert.Equal("ok", messageCopy.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnassignedValue_RoundTrip()
|
||||
{
|
||||
Assert.NotNull(RoundTrip(UnassignedValue.Instance));
|
||||
}
|
||||
|
||||
// Proves declarative types fail under a source-gen-only resolver that lacks the declarative
|
||||
// context. Mirrors AOT runtime behavior in a non-AOT test project.
|
||||
[Fact]
|
||||
public void Serialization_WithoutDeclarativeChain_FailsOnDeclarativeType()
|
||||
{
|
||||
ActionExecutorResult source = new("exec-1");
|
||||
JsonSerializerOptions bareOptions = new() { TypeInfoResolver = EmptyJsonContext.Default };
|
||||
|
||||
Assert.False(
|
||||
bareOptions.TryGetTypeInfo(typeof(ActionExecutorResult), out _),
|
||||
$"Test is meaningless if the bare resolver already covers {nameof(ActionExecutorResult)}.");
|
||||
|
||||
NotSupportedException ex = Assert.Throws<NotSupportedException>(
|
||||
() => JsonSerializer.Serialize(source, bareOptions));
|
||||
|
||||
Assert.Contains(nameof(ActionExecutorResult), ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_WithDeclarativeChain_SucceedsOnDeclarativeType()
|
||||
{
|
||||
string text = JsonSerializer.Serialize(new ActionExecutorResult("exec-1"), DeclarativeWorkflowJsonOptions.Default);
|
||||
|
||||
Assert.Contains("exec-1", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Mirrors the documented public usage: CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default).
|
||||
[Fact]
|
||||
public void CheckpointManager_CreateJson_WithDefault_SmokeTest()
|
||||
{
|
||||
InMemoryJsonStore store = new();
|
||||
|
||||
CheckpointManager manager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default);
|
||||
|
||||
Assert.NotNull(manager);
|
||||
}
|
||||
|
||||
// Empty context for the negative test; `string` is harmless filler required for a valid context.
|
||||
[JsonSerializable(typeof(string))]
|
||||
internal sealed partial class EmptyJsonContext : JsonSerializerContext;
|
||||
|
||||
private sealed class InMemoryJsonStore : JsonCheckpointStore
|
||||
{
|
||||
private readonly Dictionary<CheckpointInfo, JsonElement> _store = [];
|
||||
|
||||
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(
|
||||
string sessionId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
CheckpointInfo key = new(sessionId, Guid.NewGuid().ToString("N"));
|
||||
this._store[key] = value;
|
||||
return new(key);
|
||||
}
|
||||
|
||||
public override ValueTask<JsonElement> RetrieveCheckpointAsync(
|
||||
string sessionId, CheckpointInfo key)
|
||||
=> new(this._store[key]);
|
||||
|
||||
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(
|
||||
string sessionId, CheckpointInfo? withParent = null)
|
||||
{
|
||||
List<CheckpointInfo> matches = [];
|
||||
foreach (CheckpointInfo k in this._store.Keys)
|
||||
{
|
||||
if (k.SessionId == sessionId)
|
||||
{
|
||||
matches.Add(k);
|
||||
}
|
||||
}
|
||||
return new(matches);
|
||||
}
|
||||
}
|
||||
|
||||
private static T RoundTrip<T>(T source) where T : notnull
|
||||
{
|
||||
JsonSerializerOptions options = DeclarativeWorkflowJsonOptions.Default;
|
||||
string text = JsonSerializer.Serialize(source, options);
|
||||
T? copy = JsonSerializer.Deserialize<T>(text, options);
|
||||
Assert.NotNull(copy);
|
||||
return copy!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user