diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs index c981b5d8015..0e4d2e0f5b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -44,4 +46,65 @@ public sealed class AIAgentHostOptions /// by the agent during its turn. /// public bool ForwardIncomingMessages { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the complete agent response should be forwarded as a workflow message. + /// + /// + /// When enabled, downstream executors that handle can inspect response-level + /// metadata such as , , and + /// . Existing chat-message forwarding is unchanged. + /// + public bool ForwardAgentResponse { get; set; } +} + +/// +/// Represents the complete response produced by an hosted in a workflow. +/// +/// +/// is sent as a workflow message when +/// is enabled. It allows custom downstream executors to inspect +/// response-level metadata while the existing chat-message path continues to carry portable conversation messages to +/// chat-protocol executors. +/// +public sealed class AIAgentHostResponse +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the executor that produced the response. + /// The complete response returned by the hosted agent. + /// The portable messages for the current host turn, including input messages and forwarded response messages. + /// The sanitized response messages forwarded on the chat-message path. + public AIAgentHostResponse( + string executorId, + AgentResponse agentResponse, + IReadOnlyList currentTurnMessages, + IReadOnlyList forwardableMessages) + { + this.ExecutorId = Throw.IfNull(executorId); + this.AgentResponse = Throw.IfNull(agentResponse); + this.CurrentTurnMessages = new List(Throw.IfNull(currentTurnMessages)); + this.ForwardableMessages = new List(Throw.IfNull(forwardableMessages)); + } + + /// + /// Gets the ID of the executor that produced the response. + /// + public string ExecutorId { get; } + + /// + /// Gets the complete response returned by the hosted agent. + /// + public AgentResponse AgentResponse { get; } + + /// + /// Gets the portable messages for the current host turn, including the input messages and forwarded response messages. + /// + public IReadOnlyList CurrentTurnMessages { get; } + + /// + /// Gets the sanitized response messages forwarded on the chat-message path. + /// + public IReadOnlyList ForwardableMessages { get; } } 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..4dc1b2d2755 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,9 @@ #nullable enable +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.get -> bool +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.set -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AgentResponse.get -> Microsoft.Agents.AI.AgentResponse! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AIAgentHostResponse(string! executorId, Microsoft.Agents.AI.AgentResponse! agentResponse, System.Collections.Generic.IReadOnlyList! currentTurnMessages, System.Collections.Generic.IReadOnlyList! forwardableMessages) -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList! 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..4dc1b2d2755 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,9 @@ #nullable enable +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.get -> bool +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.set -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AgentResponse.get -> Microsoft.Agents.AI.AgentResponse! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AIAgentHostResponse(string! executorId, Microsoft.Agents.AI.AgentResponse! agentResponse, System.Collections.Generic.IReadOnlyList! currentTurnMessages, System.Collections.Generic.IReadOnlyList! forwardableMessages) -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList! 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..4dc1b2d2755 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,9 @@ #nullable enable +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.get -> bool +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.set -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AgentResponse.get -> Microsoft.Agents.AI.AgentResponse! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AIAgentHostResponse(string! executorId, Microsoft.Agents.AI.AgentResponse! agentResponse, System.Collections.Generic.IReadOnlyList! currentTurnMessages, System.Collections.Generic.IReadOnlyList! forwardableMessages) -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList! 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..4dc1b2d2755 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,9 @@ #nullable enable +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.get -> bool +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.set -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AgentResponse.get -> Microsoft.Agents.AI.AgentResponse! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AIAgentHostResponse(string! executorId, Microsoft.Agents.AI.AgentResponse! agentResponse, System.Collections.Generic.IReadOnlyList! currentTurnMessages, System.Collections.Generic.IReadOnlyList! forwardableMessages) -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList! 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..4dc1b2d2755 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,9 @@ #nullable enable +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.get -> bool +Microsoft.Agents.AI.Workflows.AIAgentHostOptions.ForwardAgentResponse.set -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AgentResponse.get -> Microsoft.Agents.AI.AgentResponse! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.AIAgentHostResponse(string! executorId, Microsoft.Agents.AI.AgentResponse! agentResponse, System.Collections.Generic.IReadOnlyList! currentTurnMessages, System.Collections.Generic.IReadOnlyList! forwardableMessages) -> void +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList! diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 85ff6e40528..1a77a2d83d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -69,8 +69,12 @@ private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuild protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder)) - .ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.ResetChat)); + protocolBuilder = this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder)) + .ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.ResetChat)); + + return this._options.ForwardAgentResponse + ? protocolBuilder.SendsMessage() + : protocolBuilder; } internal void ResetChat(ResetChatSignal signal, IWorkflowContext context) @@ -201,6 +205,17 @@ await context.SendMessageAsync(forwardableMessages, cancellationToken) .ConfigureAwait(false); } + if (this._options.ForwardAgentResponse) + { + await context.SendMessageAsync( + new AIAgentHostResponse( + this.Id, + response, + [.. messages, .. forwardableMessages], + forwardableMessages), + cancellationToken).ConfigureAwait(false); + } + // If we have no outstanding requests, we can yield a turn token back to the workflow. if (!this.HasOutstandingRequests) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index a1d24a95222..1c4ca5eed05 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -91,6 +91,7 @@ private static JsonSerializerOptions CreateDefaultOptions() // Message Types [JsonSerializable(typeof(ChatMessage))] + [JsonSerializable(typeof(AIAgentHostResponse))] [JsonSerializable(typeof(ExternalRequest))] [JsonSerializable(typeof(ExternalResponse))] [JsonSerializable(typeof(TurnToken))] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 2818d230326..699a6439832 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -134,6 +134,109 @@ protected override async IAsyncEnumerable RunCoreStreamingA } } + [Fact] + public async Task Test_AgentHostExecutor_ForwardsAgentResponseMessageAsync() + { + // Arrange + ChatMessage userMessage = new(ChatRole.User, "Summarize this.") { AuthorName = "User" }; + ChatMessage responseMessage = new(ChatRole.Assistant, [new TextContent("Partial answer")]) + { + AuthorName = TestAgentName, + MessageId = "message-id", + RawRepresentation = "provider-message", + }; + ChatMessage reasoningMessage = new(ChatRole.Assistant, [new TextReasoningContent("internal reasoning")]) + { + AuthorName = TestAgentName, + MessageId = "reasoning-id", + RawRepresentation = "provider-reasoning", + }; + AgentResponse agentResponse = new([responseMessage, reasoningMessage]) + { + AgentId = TestAgentId, + ResponseId = "response-id", + FinishReason = ChatFinishReason.Length, + Usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 2, TotalTokenCount = 12 }, + AdditionalProperties = new() { ["detail"] = "metadata" }, + }; + AIAgentHostExecutor executor = new(new FixedResponseAgent(agentResponse, TestAgentId, TestAgentName), new() { ForwardAgentResponse = true }); + TestRunContext testContext = new(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.Router.RouteMessageAsync(userMessage, testContext.BindWorkflowContext(executor.Id)); + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.Contains(typeof(AIAgentHostResponse), executor.Protocol.Describe().Sends); + + IEnumerable sentMessages = testContext.QueuedMessages[executor.Id].Select(envelope => envelope.Message); + AIAgentHostResponse hostResponse = Assert.Single(sentMessages.OfType()); + + Assert.Equal(executor.Id, hostResponse.ExecutorId); + Assert.Same(agentResponse, hostResponse.AgentResponse); + Assert.Equal(ChatFinishReason.Length, hostResponse.AgentResponse.FinishReason); + Assert.Equal("response-id", hostResponse.AgentResponse.ResponseId); + Assert.Equal(12, hostResponse.AgentResponse.Usage?.TotalTokenCount); + Assert.Equal("metadata", hostResponse.AgentResponse.AdditionalProperties?["detail"]); + + ChatMessage forwardableMessage = Assert.Single(hostResponse.ForwardableMessages); + Assert.Equal("Partial answer", forwardableMessage.Text); + Assert.Null(forwardableMessage.RawRepresentation); + + Assert.Equal(2, hostResponse.CurrentTurnMessages.Count); + Assert.Equal("Summarize this.", hostResponse.CurrentTurnMessages[0].Text); + Assert.Equal("Partial answer", hostResponse.CurrentTurnMessages[1].Text); + } + + [Fact] + public async Task Test_AgentHostExecutor_DoesNotForwardAgentResponseMessageByDefaultAsync() + { + // Arrange + AgentResponse agentResponse = new(new ChatMessage(ChatRole.Assistant, "Hello")); + AIAgentHostExecutor executor = new(new FixedResponseAgent(agentResponse, TestAgentId, TestAgentName), new()); + TestRunContext testContext = new(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.DoesNotContain(typeof(AIAgentHostResponse), executor.Protocol.Describe().Sends); + Assert.Contains(executor.Id, testContext.QueuedMessages); + Assert.DoesNotContain(testContext.QueuedMessages[executor.Id], envelope => envelope.Message is AIAgentHostResponse); + } + + private sealed class FixedResponseAgent(AgentResponse response, string? id = null, string? name = null) : AIAgent + { + protected override string? IdCore => id; + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new FixedResponseSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new FixedResponseSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(response); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (AgentResponseUpdate update in response.ToAgentResponseUpdates()) + { + yield return update; + } + + await Task.CompletedTask; + } + + private sealed class FixedResponseSession : AgentSession; + } + [Theory] [InlineData(true, true, false, false)] [InlineData(true, true, false, true)] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 580ff8209db..e186bc36f63 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -444,6 +444,37 @@ public void Test_PortableMessageEnvelope_JsonRoundtrip_InternalType() AssertMatches(reconstructedMessage, message.CreateValidatorCheckingText()); } + [Fact] + public void Test_PortableMessageEnvelope_JsonRoundtrip_AIAgentHostResponse() + { + AIAgentHostResponse message = new( + "Source1", + new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello from agent")) + { + FinishReason = ChatFinishReason.Length, + ResponseId = "response-id", + Usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 2, TotalTokenCount = 12 }, + }, + [new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hello from agent")], + [new ChatMessage(ChatRole.Assistant, "Hello from agent")]); + + MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(AIAgentHostResponse)), targetId: "Target1"); + PortableMessageEnvelope value = new(envelope); + PortableMessageEnvelope result = RunJsonRoundtrip(value); + + MessageEnvelope reconstructed = result.ToMessageEnvelope(); + AIAgentHostResponse? reconstructedMessage = ((PortableValue)reconstructed.Message).As(); + + Assert.NotNull(reconstructedMessage); + Assert.Equal(message.ExecutorId, reconstructedMessage.ExecutorId); + Assert.Equal(message.AgentResponse.Text, reconstructedMessage.AgentResponse.Text); + Assert.Equal(message.AgentResponse.FinishReason, reconstructedMessage.AgentResponse.FinishReason); + Assert.Equal(message.AgentResponse.ResponseId, reconstructedMessage.AgentResponse.ResponseId); + Assert.Equal(message.AgentResponse.Usage?.TotalTokenCount, reconstructedMessage.AgentResponse.Usage?.TotalTokenCount); + Assert.Equal(message.CurrentTurnMessages.Select(m => m.Text), reconstructedMessage.CurrentTurnMessages.Select(m => m.Text)); + Assert.Equal(message.ForwardableMessages.Select(m => m.Text), reconstructedMessage.ForwardableMessages.Select(m => m.Text)); + } + [Fact] public void Test_PortableMessageEnvelope_JsonRoundtrip_CustomType() {