diff --git a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs index d8d88aefcbe..652a6a2f65a 100644 --- a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs +++ b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/Program.cs @@ -81,7 +81,12 @@ private static async Task Main() } Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}"); - // Rehydrate a new workflow instance from a saved checkpoint and continue execution + // + // A rehydrated workflow must preserve the topology and executor identities of the workflow that + // created the checkpoint. This executor-only workflow rebuilds identically because its executors + // use fixed ids. Agent-based workflows must recreate each local agent with the same + // ChatClientAgentOptions.Id (and, if set, the same Name), otherwise the executor ids no longer + // match the checkpoint and resume fails. var newWorkflow = WorkflowFactory.BuildWorkflow(); const int CheckpointIndex = 5; Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint."); @@ -89,6 +94,7 @@ private static async Task Main() await using StreamingRun newCheckpointedRun = await InProcessExecution.ResumeStreamingAsync(newWorkflow, savedCheckpoint, checkpointManager); + // await foreach (WorkflowEvent evt in newCheckpointedRun.WatchStreamAsync()) { diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs index 3a21dd8d288..2bb1e0acd02 100644 --- a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs @@ -10,50 +10,81 @@ /// The to use as the agent backend. internal sealed class AgentRegistry(IChatClient chatClient) { + // + // Give each agent a stable, unique Id so its workflow executor identity stays the same when the + // workflow is reconstructed (for example per request or dependency-injection scope), which keeps + // checkpoints resumable. If an agent also has a Name, keep that stable too, since the executor + // identity includes it. Use a fixed logical role here, not a conversation, request, or user id. internal const string IntakeAgentName = "Assistant"; - public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent( - instructions: - """ + public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Id = "intake-agent", + Name = IntakeAgentName, + ChatOptions = new() + { + Instructions = + """ You receive a user request and are responsible for routing to the correct initial expert agent. """, - IntakeAgentName - ); + }, + }); + // internal const string LiquidityAnalysisAgentName = "Liquidity Analysis"; - public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent( - instructions: - """ + public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Id = "liquidity-analysis-agent", + Name = LiquidityAnalysisAgentName, + ChatOptions = new() + { + Instructions = + """ You are responsible for Liquidity Analysis. """, - LiquidityAnalysisAgentName - ); + }, + }); internal const string TaxAnalysisAgentName = "Tax Analysis"; - public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent( - instructions: - """ - You are responsible for Tax Analysis. + public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Id = "tax-analysis-agent", + Name = TaxAnalysisAgentName, + ChatOptions = new() + { + Instructions = + """ + You are responsible for Tax Analysis. """, - TaxAnalysisAgentName - ); + }, + }); internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis"; - public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent( - instructions: - """ - You are responsible for Foreign Exchange Analysis. + public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Id = "foreign-exchange-agent", + Name = ForeignExchangeAgentName, + ChatOptions = new() + { + Instructions = + """ + You are responsible for Foreign Exchange Analysis. """, - ForeignExchangeAgentName - ); + }, + }); internal const string EquityAgentName = "Equity Analysis"; - public AIAgent EquityAgent { get; } = chatClient.AsAIAgent( - instructions: - """ - You are responsible for Equity Analysis. + public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions + { + Id = "equity-analysis-agent", + Name = EquityAgentName, + ChatOptions = new() + { + Instructions = + """ + You are responsible for Equity Analysis. """, - EquityAgentName - ); + }, + }); public IEnumerable Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs new file mode 100644 index 00000000000..92cbbbeb333 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowAgentCheckpointIdentityTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Verifies that a workflow hosted as an resumes a serialized session after its inner +/// agents are reconstructed only when each inner agent keeps a stable executor identity. A stable +/// is sufficient; if an agent also sets a +/// , that name must stay stable because the executor id includes it. +/// +public class WorkflowAgentCheckpointIdentityTests +{ + private const string TriageName = "triage_agent"; + private const string SpecialistName = "specialist_agent"; + private const string SpecialistReply = "SPECIALIST_REPLY"; + + // Held constant across reconstruction so resume differences come only from the inner agent identities. + private const string OuterWorkflowAgentId = "workflow-agent"; + + [Fact] + public async Task WorkflowAgentSession_WithStableInnerAgentIds_ResumesAcrossReconstructionAsync() + { + // Arrange: build a first-generation workflow agent whose inner agents have stable, explicit ids. + AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true); + AgentSession session = await firstGeneration.CreateSessionAsync(); + + // Act: complete a first turn (triage hands off to the specialist), then serialize the session. + AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session); + firstResponse.Text.Should().Be( + $"{SpecialistReply}:turn:1", + "the first turn should route triage -> specialist, and the specialist observes a single user turn"); + + JsonElement serialized = await firstGeneration.SerializeSessionAsync(session); + + // Reconstruct a completely fresh object graph (new clients, agents, and workflow) using the same stable ids, + // modeling a second dependency-injection scope. + AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true); + AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized); + + AgentResponse secondResponse = await secondGeneration.RunAsync("Anything else?", resumedSession); + + // Assert: the specialist observes both user turns, which is only possible if the checkpointed conversation was + // restored. A fresh (non-resumed) session would restart the count at turn:1, so this distinguishes a genuine + // resume from a compatible-but-empty restart. + secondResponse.Text.Should().Be( + $"{SpecialistReply}:turn:2", + "stable inner agent ids keep the executor identities compatible and the reconstructed workflow resumes from the checkpoint"); + } + + [Fact] + public async Task WorkflowAgentSession_WithoutStableInnerAgentIds_FailsAcrossReconstructionAsync() + { + // Arrange: build a first-generation workflow agent whose inner agents receive random ids (no explicit id). + AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: false); + AgentSession session = await firstGeneration.CreateSessionAsync(); + + // Complete a first turn and serialize the session; a completed handoff turn captures a checkpoint. + AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session); + firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist"); + + JsonElement serialized = await firstGeneration.SerializeSessionAsync(session); + + // Reconstruct with new random inner ids but the SAME outer workflow-agent id, proving that a stable outer id + // alone does not stabilize the inner executor identities. + AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: false); + + // Act: deserialization itself succeeds; the incompatibility surfaces only when the resuming run validates the + // checkpoint against the reconstructed workflow. + AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized); + + Func resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession); + + // Assert: the second run throws because the reconstructed executor ids no longer match the checkpoint. + await resumeAndRun.Should().ThrowAsync() + .WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner."); + } + + [Fact] + public async Task WorkflowAgentSession_WithStableIdsButChangedInnerNames_FailsAcrossReconstructionAsync() + { + // Arrange: first generation uses stable ids and the default inner names. + AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true); + AgentSession session = await firstGeneration.CreateSessionAsync(); + + AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session); + firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist"); + + JsonElement serialized = await firstGeneration.SerializeSessionAsync(session); + + // Reconstruct with the SAME stable ids but different inner names. Because the executor id is derived from + // both the name and the id, changing only the name still breaks checkpoint compatibility. + AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true, nameSuffix: "-renamed"); + + // Act: deserialization succeeds; the incompatibility surfaces on the resuming run. + AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized); + + Func resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession); + + // Assert: changing a set name invalidates the executor identity even though the id is stable. + await resumeAndRun.Should().ThrowAsync() + .WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner."); + } + + /// + /// Builds a fresh Handoff workflow-as-agent object graph. Every call constructs new chat clients, agents, and a + /// new workflow, modeling reconstruction across dependency-injection scopes. + /// + /// + /// When , each inner agent is assigned a deterministic . + /// When , the id is left unset so each agent receives a random per-instance id. + /// + /// + /// Optional suffix appended to each inner agent's . Used to simulate a + /// reconstruction that keeps ids stable but changes names. + /// + private static AIAgent BuildWorkflowAgent(bool useStableInnerIds, string nameSuffix = "") + { + AIAgent triage = CreateTriageClient().AsAIAgent(new ChatClientAgentOptions + { + Id = useStableInnerIds ? "triage-agent" : null, + Name = TriageName + nameSuffix, + Description = "Routes the request to a specialist.", + ChatOptions = new() { Instructions = "Always hand off to the specialist." }, + }); + + AIAgent specialist = CreateSpecialistClient().AsAIAgent(new ChatClientAgentOptions + { + Id = useStableInnerIds ? "specialist-agent" : null, + Name = SpecialistName + nameSuffix, + Description = "Handles the request once triage hands off.", + ChatOptions = new() { Instructions = "Answer the request." }, + }); + + Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triage) + .WithHandoff(triage, specialist) + .Build(); + + return workflow.AsAIAgent(id: OuterWorkflowAgentId, name: OuterWorkflowAgentId); + } + + // Triage hands off to the specialist by calling the handoff tool present on the request. + private static StatelessMockChatClient CreateTriageClient() => new((messages, options) => + { + string handoffTool = options?.Tools? + .FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name + ?? throw new InvalidOperationException("Expected a handoff tool to be available to the triage agent."); + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool)])); + }); + + // The specialist echoes how many user turns it has observed. Because a genuine resume restores the prior turn + // from the checkpoint, the count advances across turns, distinguishing a real resume from a fresh restart. + private static StatelessMockChatClient CreateSpecialistClient() => new((messages, _) => + { + int observedUserTurns = messages.Count(m => m.Role == ChatRole.User); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, $"{SpecialistReply}:turn:{observedUserTurns}")); + }); + + /// + /// A minimal whose response is a pure function of the request, so reconstructing it + /// preserves behavior. + /// + private sealed class StatelessMockChatClient(Func, ChatOptions?, ChatResponse> responseFactory) : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + Task.FromResult(responseFactory(messages, options)); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +}