From 96229c3a9d85531fbb5772ab05464dfb3f986711 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 29 Sep 2026 13:12:02 +0000 Subject: [PATCH 1/2] Allow a custom ChatHistoryProvider for workflows hosted as agents --- .../DeclarativeWorkflowBuilder.cs | 2 +- .../DeclarativeWorkflowExecutor.cs | 2 +- .../Microsoft.Agents.AI.Workflows/Futures.cs | 2 +- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 19 ++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 19 ++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 19 ++ .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 19 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 19 ++ .../WorkflowAgentOptions.cs | 66 ++++++ .../WorkflowHostAgent.cs | 134 ++++++++---- .../WorkflowHostingExtensions.cs | 29 ++- .../WorkflowSession.cs | 3 +- ...rkflowHostAgentChatHistoryProviderTests.cs | 190 ++++++++++++++++++ 13 files changed, 472 insertions(+), 51 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index e12370a53ca..519161104f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -61,7 +61,7 @@ public static Workflow Build( /// , of /// , , and . This /// makes the workflow usable both for direct invocation and for hosting via - /// . + /// . /// public static Workflow Build( TextReader yamlReader, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 1126b43d8ff..12c06cfca5c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter; /// [], and so that the workflow /// satisfies . This makes the workflow /// usable both for direct Run.SendMessageAsync(input) invocations and for hosting -/// via . +/// via . /// /// /// Each non- input drives the declarative graph forward diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs index b2c83f112ad..80ca689c0eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs @@ -26,7 +26,7 @@ public static class Futures /// [Obsolete] in v2.0.0 when the new behavior becomes default, and removed in v3.0.0. /// /// - /// Interaction with . When this flag + /// Interaction with . When this flag /// is , joins /// in being forwarded out of the agent surface /// unconditionally — neither honors the host's includeWorkflowOutputsInResponse diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..0274576241b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ #nullable enable +*REMOVED*static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) -> Microsoft.Agents.AI.AIAgent! +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.get -> Microsoft.Agents.AI.ChatHistoryProvider? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.get -> Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.WorkflowAgentOptions() -> void +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, Microsoft.Agents.AI.Workflows.WorkflowAgentOptions! options) -> Microsoft.Agents.AI.AIAgent! +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, Microsoft.Agents.AI.ChatHistoryProvider? chatHistoryProvider = null) -> Microsoft.Agents.AI.AIAgent! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..0274576241b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ #nullable enable +*REMOVED*static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) -> Microsoft.Agents.AI.AIAgent! +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.get -> Microsoft.Agents.AI.ChatHistoryProvider? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.get -> Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.WorkflowAgentOptions() -> void +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, Microsoft.Agents.AI.Workflows.WorkflowAgentOptions! options) -> Microsoft.Agents.AI.AIAgent! +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, Microsoft.Agents.AI.ChatHistoryProvider? chatHistoryProvider = null) -> Microsoft.Agents.AI.AIAgent! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..0274576241b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ #nullable enable +*REMOVED*static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) -> Microsoft.Agents.AI.AIAgent! +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.get -> Microsoft.Agents.AI.ChatHistoryProvider? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.get -> Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.WorkflowAgentOptions() -> void +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, Microsoft.Agents.AI.Workflows.WorkflowAgentOptions! options) -> Microsoft.Agents.AI.AIAgent! +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, Microsoft.Agents.AI.ChatHistoryProvider? chatHistoryProvider = null) -> Microsoft.Agents.AI.AIAgent! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..0274576241b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ #nullable enable +*REMOVED*static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) -> Microsoft.Agents.AI.AIAgent! +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.get -> Microsoft.Agents.AI.ChatHistoryProvider? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.get -> Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.WorkflowAgentOptions() -> void +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, Microsoft.Agents.AI.Workflows.WorkflowAgentOptions! options) -> Microsoft.Agents.AI.AIAgent! +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, Microsoft.Agents.AI.ChatHistoryProvider? chatHistoryProvider = null) -> Microsoft.Agents.AI.AIAgent! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..0274576241b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ #nullable enable +*REMOVED*static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) -> Microsoft.Agents.AI.AIAgent! +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.get -> Microsoft.Agents.AI.ChatHistoryProvider? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ChatHistoryProvider.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Description.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.get -> Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.ExecutionEnvironment.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Id.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeExceptionDetails.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.get -> bool +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.IncludeWorkflowOutputsInResponse.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.get -> string? +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.Name.set -> void +Microsoft.Agents.AI.Workflows.WorkflowAgentOptions.WorkflowAgentOptions() -> void +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, Microsoft.Agents.AI.Workflows.WorkflowAgentOptions! options) -> Microsoft.Agents.AI.AIAgent! +static Microsoft.Agents.AI.Workflows.WorkflowHostingExtensions.AsAIAgent(this Microsoft.Agents.AI.Workflows.Workflow! workflow, string? id = null, string? name = null, string? description = null, Microsoft.Agents.AI.Workflows.IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, Microsoft.Agents.AI.ChatHistoryProvider? chatHistoryProvider = null) -> Microsoft.Agents.AI.AIAgent! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentOptions.cs new file mode 100644 index 00000000000..c1b44d8a0d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentOptions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Configuration options for hosting a as an . +/// +public sealed class WorkflowAgentOptions +{ + /// + /// Gets or sets a unique id for the hosting . + /// + public string? Id { get; set; } + + /// + /// Gets or sets a name for the hosting . + /// + public string? Name { get; set; } + + /// + /// Gets or sets a description for the hosting . + /// + public string? Description { get; set; } + + /// + /// Gets or sets the execution environment to use when running the workflow. See + /// , and + /// for the in-process environments. + /// + public IWorkflowExecutionEnvironment? ExecutionEnvironment { get; set; } + + /// + /// Gets or sets a value indicating whether should be included + /// in the representing the workflow error. + /// + public bool IncludeExceptionDetails { get; set; } + + /// + /// Gets or sets a value indicating whether outgoing workflow outputs should be transformed + /// into content in s or the as appropriate. + /// + public bool IncludeWorkflowOutputsInResponse { get; set; } + + /// + /// Gets or sets the used to store the conversation history of the hosting . + /// + /// + /// + /// When set, the provider is notified via at the end of each run + /// with the request messages and the response messages, and it can be retrieved from the agent via + /// . + /// + /// + /// The workflow maintains its own conversation state, so only the new request messages are passed to the workflow on each run, + /// and the provider is not asked to supply prior chat history via . + /// + /// + /// When , a default in-memory provider that stores the history in the session is used. + /// + /// + public ChatHistoryProvider? ChatHistoryProvider { get; set; } + + internal WorkflowAgentOptions Clone() => (WorkflowAgentOptions)this.MemberwiseClone(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index d498d13b651..c77645489e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -16,21 +17,22 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowHostAgent : AIAgent { private readonly Workflow _workflow; - private readonly string? _id; + private readonly WorkflowAgentOptions _options; + private readonly ChatHistoryProvider? _chatHistoryProvider; private readonly IWorkflowExecutionEnvironment _executionEnvironment; - private readonly bool _includeExceptionDetails; - private readonly bool _includeWorkflowOutputsInResponse; private readonly Task _describeTask; private readonly ConcurrentDictionary _assignedSessionIds = []; - public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) + public WorkflowHostAgent(Workflow workflow, WorkflowAgentOptions? options = null) { this._workflow = Throw.IfNull(workflow); + this._options = options?.Clone() ?? new(); + this._chatHistoryProvider = this._options.ChatHistoryProvider; - this._executionEnvironment = executionEnvironment ?? (workflow.AllowConcurrent - ? InProcessExecution.Concurrent - : InProcessExecution.OffThread); + this._executionEnvironment = this._options.ExecutionEnvironment ?? (workflow.AllowConcurrent + ? InProcessExecution.Concurrent + : InProcessExecution.OffThread); if (!this._executionEnvironment.IsCheckpointingEnabled && this._executionEnvironment is not InProcessExecutionEnvironment) @@ -40,20 +42,13 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu throw new InvalidOperationException("Cannot use a non-checkpointed execution environment. Implicit checkpointing is supported only for InProcess."); } - this._includeExceptionDetails = includeExceptionDetails; - this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; - - this._id = id; - this.Name = name; - this.Description = description; - // Kick off the typecheck right away by starting the DescribeProtocol task. this._describeTask = this._workflow.DescribeProtocolAsync().AsTask(); } - protected override string? IdCore => this._id; - public override string? Name { get; } - public override string? Description { get; } + protected override string? IdCore => this._options.Id; + public override string? Name => this._options.Name; + public override string? Description => this._options.Description; /// /// Reports whether this agent was built with an execution environment that already names a @@ -69,7 +64,8 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu return base.GetService(serviceType, serviceKey) ?? (serviceKey is null && serviceType == typeof(WorkflowAgentMetadata) ? this._metadata ??= new WorkflowAgentMetadata(this.UsesOwnCheckpointStorage) - : null); + : null) + ?? this._chatHistoryProvider?.GetService(serviceType, serviceKey); } private WorkflowAgentMetadata? _metadata; @@ -87,14 +83,10 @@ internal AIAgent WithCheckpointing(CheckpointManager checkpointManager) return this; } - return new WorkflowHostAgent( - this._workflow, - this._id, - this.Name, - this.Description, - inProcEnvironment.WithCheckpointing(checkpointManager), - this._includeExceptionDetails, - this._includeWorkflowOutputsInResponse); + WorkflowAgentOptions options = this._options.Clone(); + options.ExecutionEnvironment = inProcEnvironment.WithCheckpointing(checkpointManager); + + return new WorkflowHostAgent(this._workflow, options); } private string GenerateNewId() @@ -116,7 +108,7 @@ private async ValueTask ValidateWorkflowAsync() } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - => new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse)); + => new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._options.IncludeExceptionDetails, this._options.IncludeWorkflowOutputsInResponse)); protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { @@ -131,9 +123,17 @@ protected override ValueTask SerializeSessionCoreAsync(AgentSession } protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions)); + => new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._options.IncludeExceptionDetails, this._options.IncludeWorkflowOutputsInResponse, jsonSerializerOptions)); - private async ValueTask UpdateSessionAsync(IEnumerable messages, AgentSession? session = null, CancellationToken cancellationToken = default) + /// + /// Resolves the for this run and computes the messages to send to the workflow. + /// + /// + /// The session, and the input messages for this run: the new messages when a custom + /// is configured, otherwise the messages after the bookmark of the default provider. The input messages are sent + /// to the workflow and then passed to the custom provider as request messages when the run completes or fails. + /// + private async ValueTask<(WorkflowSession Session, List InputMessages)> UpdateSessionAsync(IEnumerable messages, AgentSession? session = null, CancellationToken cancellationToken = default) { session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -142,10 +142,60 @@ private async ValueTask UpdateSessionAsync(IEnumerable inputMessages, AgentResponse response, CancellationToken cancellationToken) + { + if (this._chatHistoryProvider is not null) + { + ChatHistoryProvider.InvokedContext context = new(this, workflowSession, inputMessages, response.Messages); + await this._chatHistoryProvider.InvokedAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + } + + private async IAsyncEnumerable InvokeStageAsync( + WorkflowSession workflowSession, + List inputMessages, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + IAsyncEnumerator enumerator = workflowSession.InvokeStageAsync(inputMessages, cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + } + catch (Exception ex) when (this._chatHistoryProvider is not null) + { + ChatHistoryProvider.InvokedContext context = new(this, workflowSession, inputMessages, ex); + await this._chatHistoryProvider.InvokedAsync(context, cancellationToken).ConfigureAwait(false); + throw; + } + + yield return enumerator.Current; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } } protected override async @@ -157,19 +207,17 @@ Task RunCoreAsync( { await this.ValidateWorkflowAsync().ConfigureAwait(false); - WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + (WorkflowSession workflowSession, List inputMessages) = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); ResponseMergeState mergeState = new(); - await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + await foreach (AgentResponseUpdate update in this.InvokeStageAsync(workflowSession, inputMessages, cancellationToken) + .ConfigureAwait(false)) { mergeState.AddUpdate(update, this.IsTerminalWorkflowOutputUpdate(update)); } AgentResponse response = mergeState.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); - workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); - workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + await this.StoreChatHistoryAsync(workflowSession, inputMessages, response, cancellationToken).ConfigureAwait(false); return response; } @@ -183,20 +231,18 @@ IAsyncEnumerable RunCoreStreamingAsync( { await this.ValidateWorkflowAsync().ConfigureAwait(false); - WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + (WorkflowSession workflowSession, List inputMessages) = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); ResponseMergeState mergeState = new(); - await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + await foreach (AgentResponseUpdate update in this.InvokeStageAsync(workflowSession, inputMessages, cancellationToken) + .ConfigureAwait(false)) { mergeState.AddUpdate(update, this.IsTerminalWorkflowOutputUpdate(update)); yield return update; } AgentResponse response = mergeState.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); - workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); - workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + await this.StoreChatHistoryAsync(workflowSession, inputMessages, response, cancellationToken).ConfigureAwait(false); } private sealed class ResponseMergeState diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs index 0a0237cc66f..9eca180deb1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs @@ -25,6 +25,8 @@ public static class WorkflowHostingExtensions /// in the representing the workflow error. /// If , will transform outgoing workflow outputs /// into content in s or the as appropriate. + /// An optional used to store the conversation history + /// of the hosting . See for details. /// public static AIAgent AsAIAgent( this Workflow workflow, @@ -33,9 +35,32 @@ public static AIAgent AsAIAgent( string? description = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, - bool includeWorkflowOutputsInResponse = false) + bool includeWorkflowOutputsInResponse = false, + ChatHistoryProvider? chatHistoryProvider = null) { - return new WorkflowHostAgent(workflow, id, name, description, executionEnvironment, includeExceptionDetails, includeWorkflowOutputsInResponse); + return workflow.AsAIAgent(new WorkflowAgentOptions + { + Id = id, + Name = name, + Description = description, + ExecutionEnvironment = executionEnvironment, + IncludeExceptionDetails = includeExceptionDetails, + IncludeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse, + ChatHistoryProvider = chatHistoryProvider, + }); + } + + /// + /// Convert a workflow with the appropriate primary input type to an . + /// + /// The workflow to be hosted by the resulting + /// The options used to configure the hosting . + /// An that hosts the workflow. + public static AIAgent AsAIAgent(this Workflow workflow, WorkflowAgentOptions options) + { + Throw.IfNull(options); + + return new WorkflowHostAgent(workflow, options); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 97134a94073..57462ca253b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; @@ -469,10 +468,10 @@ private void RemovePendingRequest(string requestId) => internal async IAsyncEnumerable InvokeStageAsync( + List messages, [EnumeratorCancellation] CancellationToken cancellationToken = default) { this.LastResponseId = Guid.NewGuid().ToString("N"); - List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); ResumeRunResult resumeResult = await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs new file mode 100644 index 00000000000..35d585de87d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class WorkflowHostAgentChatHistoryProviderTests +{ + private static Workflow CreateWorkflow() => AgentWorkflowBuilder.BuildSequential(new TestEchoAgent("echo", "Echo")); + + private static Task RunAgentAsync(AIAgent agent, string input, AgentSession session, bool streaming) + => streaming + ? agent.RunStreamingAsync(input, session).ToAgentResponseAsync() + : agent.RunAsync(input, session); + + [Fact] + public void AsAIAgent_WithNullOptions_Throws() + { + // Arrange + Workflow workflow = CreateWorkflow(); + + // Act & Assert + Assert.Throws("options", () => workflow.AsAIAgent(options: null!)); + } + + [Fact] + public void AsAIAgent_WithOptions_AppliesAgentMetadata() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + + // Act + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions + { + Id = "workflow-agent", + Name = "WorkflowAgent", + Description = "A workflow agent", + ChatHistoryProvider = provider, + }); + + // Assert + Assert.Equal("workflow-agent", agent.Id); + Assert.Equal("WorkflowAgent", agent.Name); + Assert.Equal("A workflow agent", agent.Description); + Assert.Same(provider, agent.GetService()); + Assert.Same(provider, agent.GetService()); + } + + [Fact] + public void AsAIAgent_WithOptions_IsNotAffectedByLaterOptionChanges() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + WorkflowAgentOptions options = new() { Name = "Original", ChatHistoryProvider = provider }; + AIAgent agent = CreateWorkflow().AsAIAgent(options); + + // Act + options.Name = "Changed"; + options.ChatHistoryProvider = null; + + // Assert + Assert.Equal("Original", agent.Name); + Assert.Same(provider, agent.GetService()); + } + + [Fact] + public async Task AsAIAgent_WithChatHistoryProviderParameter_UsesProviderAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AIAgent agent = CreateWorkflow().AsAIAgent("workflow-agent", "WorkflowAgent", chatHistoryProvider: provider); + AgentSession session = await agent.CreateSessionAsync(); + + // Act + await agent.RunAsync("Hello", session); + + // Assert + Assert.Equal("workflow-agent", agent.Id); + Assert.Equal("WorkflowAgent", agent.Name); + Assert.Same(provider, agent.GetService()); + (ChatRole, string)[] expected = [(ChatRole.User, "Hello"), (ChatRole.Assistant, "Hello")]; + Assert.Equal(expected, provider.GetMessages(session).Select(m => (m.Role, m.Text))); + } + + [Fact] + public void AsAIAgent_WithoutCustomProvider_DoesNotExposeChatHistoryProvider() + { + // Arrange & Act + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions()); + + // Assert + Assert.Null(agent.GetService()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_WithCustomProvider_StoresRequestAndResponseMessagesAsync(bool streaming) + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions { ChatHistoryProvider = provider }); + AgentSession session = await agent.CreateSessionAsync(); + + // Act + await RunAgentAsync(agent, "Hello", session, streaming); + await RunAgentAsync(agent, "World", session, streaming); + + // Assert + (ChatRole, string)[] expected = + [ + (ChatRole.User, "Hello"), + (ChatRole.Assistant, "Hello"), + (ChatRole.User, "World"), + (ChatRole.Assistant, "World"), + ]; + Assert.Equal(expected, provider.GetMessages(session).Select(m => (m.Role, m.Text))); + + // The default workflow provider should not be used when a custom provider is configured. + WorkflowSession workflowSession = Assert.IsType(session); + Assert.Empty(workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession)); + } + + [Fact] + public async Task RunAsync_WithCustomProvider_AppliesProviderOptionsAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions + { + StorageInputResponseMessageFilter = _ => [], + }); + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions { ChatHistoryProvider = provider }); + AgentSession session = await agent.CreateSessionAsync(); + + // Act + await agent.RunAsync("Hello", session); + + // Assert + ChatMessage message = Assert.Single(provider.GetMessages(session)); + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal("Hello", message.Text); + } + + [Fact] + public async Task SerializeSession_WithCustomProvider_RoundTripsHistoryAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions { ChatHistoryProvider = provider }); + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + // Act + JsonElement serialized = await agent.SerializeSessionAsync(session); + AgentSession restored = await agent.DeserializeSessionAsync(serialized); + await agent.RunAsync("World", restored); + + // Assert + (ChatRole, string)[] expected = + [ + (ChatRole.User, "Hello"), + (ChatRole.Assistant, "Hello"), + (ChatRole.User, "World"), + (ChatRole.Assistant, "World"), + ]; + Assert.Equal(expected, provider.GetMessages(restored).Select(m => (m.Role, m.Text))); + } + + [Fact] + public async Task WithCheckpointing_PreservesCustomProviderAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions { ChatHistoryProvider = provider }); + + // Act + AIAgent redirected = agent.WithCheckpointing(CheckpointManager.CreateInMemory()); + AgentSession session = await redirected.CreateSessionAsync(); + await redirected.RunAsync("Hello", session); + + // Assert + Assert.NotSame(agent, redirected); + Assert.Same(provider, redirected.GetService()); + Assert.Contains(provider.GetMessages(session), m => m.Role == ChatRole.User && m.Text == "Hello"); + } +} From c85c3a56799431597dc2f6de1f76efc556802543 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 29 Sep 2026 13:43:22 +0000 Subject: [PATCH 2/2] Add tests for ChatHistoryProvider failure notification in workflow agents --- ...rkflowHostAgentChatHistoryProviderTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs index 35d585de87d..ce8de22562b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostAgentChatHistoryProviderTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -187,4 +189,43 @@ public async Task WithCheckpointing_PreservesCustomProviderAsync() Assert.Same(provider, redirected.GetService()); Assert.Contains(provider.GetMessages(session), m => m.Role == ChatRole.User && m.Text == "Hello"); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_WhenWorkflowRunFails_NotifiesProviderWithExceptionAsync(bool streaming) + { + // Arrange + RecordingChatHistoryProvider provider = new(); + AIAgent agent = CreateWorkflow().AsAIAgent(new WorkflowAgentOptions { ChatHistoryProvider = provider }); + AgentSession session = await agent.CreateSessionAsync(); + + // Resuming from a checkpoint that does not exist makes the workflow run throw. + WorkflowSessionCheckpointRecovery recovery = session.GetService()!; + Assert.True(recovery.TryPrepare("missing-checkpoint")); + + // Act + Exception exception = await Assert.ThrowsAnyAsync(() => RunAgentAsync(agent, "Hello", session, streaming)); + + // Assert + ChatHistoryProvider.InvokedContext context = Assert.Single(provider.InvokedContexts); + Assert.Same(exception, context.InvokeException); + Assert.Same(agent, context.Agent); + Assert.Same(session, context.Session); + Assert.Null(context.ResponseMessages); + ChatMessage requestMessage = Assert.Single(context.RequestMessages); + Assert.Equal(ChatRole.User, requestMessage.Role); + Assert.Equal("Hello", requestMessage.Text); + } + + private sealed class RecordingChatHistoryProvider : ChatHistoryProvider + { + public List InvokedContexts { get; } = []; + + protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + this.InvokedContexts.Add(context); + return default; + } + } }