Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -44,4 +46,65 @@ public sealed class AIAgentHostOptions
/// by the agent during its turn.
/// </summary>
public bool ForwardIncomingMessages { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the complete agent response should be forwarded as a workflow message.
/// </summary>
/// <remarks>
/// When enabled, downstream executors that handle <see cref="AIAgentHostResponse"/> can inspect response-level
/// metadata such as <see cref="AgentResponse.FinishReason"/>, <see cref="AgentResponse.Usage"/>, and
/// <see cref="AgentResponse.ResponseId"/>. Existing chat-message forwarding is unchanged.
/// </remarks>
public bool ForwardAgentResponse { get; set; }
}

/// <summary>
/// Represents the complete response produced by an <see cref="AIAgent"/> hosted in a workflow.
/// </summary>
/// <remarks>
/// <see cref="AIAgentHostResponse"/> is sent as a workflow message when
/// <see cref="AIAgentHostOptions.ForwardAgentResponse"/> 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.
/// </remarks>
public sealed class AIAgentHostResponse
Comment thread
baywet marked this conversation as resolved.
{
/// <summary>
/// Initializes a new instance of the <see cref="AIAgentHostResponse"/> class.
/// </summary>
/// <param name="executorId">The ID of the executor that produced the response.</param>
/// <param name="agentResponse">The complete response returned by the hosted agent.</param>
/// <param name="currentTurnMessages">The portable messages for the current host turn, including input messages and forwarded response messages.</param>
/// <param name="forwardableMessages">The sanitized response messages forwarded on the chat-message path.</param>
public AIAgentHostResponse(
string executorId,
AgentResponse agentResponse,
IReadOnlyList<ChatMessage> currentTurnMessages,
IReadOnlyList<ChatMessage> forwardableMessages)
{
this.ExecutorId = Throw.IfNull(executorId);
this.AgentResponse = Throw.IfNull(agentResponse);
this.CurrentTurnMessages = new List<ChatMessage>(Throw.IfNull(currentTurnMessages));
this.ForwardableMessages = new List<ChatMessage>(Throw.IfNull(forwardableMessages));
}

/// <summary>
/// Gets the ID of the executor that produced the response.
/// </summary>
public string ExecutorId { get; }

/// <summary>
/// Gets the complete response returned by the hosted agent.
/// </summary>
public AgentResponse AgentResponse { get; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This response is persisted through the new checkpoint contract, but AgentResponse.RawRepresentation is [JsonIgnore], and derived AgentResponse<T> data is serialized through this base-typed property. After checkpoint/restore, a downstream handler can therefore receive a different, incomplete response even though this API promises the complete response. Please define a persistence-safe envelope contract for the supported details, or explicitly narrow the public contract to the portable subset instead of silently dropping response data.


/// <summary>
/// Gets the portable messages for the current host turn, including the input messages and forwarded response messages.
/// </summary>
public IReadOnlyList<ChatMessage> CurrentTurnMessages { get; }

/// <summary>
/// Gets the sanitized response messages forwarded on the chat-message path.
/// </summary>
public IReadOnlyList<ChatMessage> ForwardableMessages { get; }
}
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Extensions.AI.ChatMessage!>! currentTurnMessages, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! forwardableMessages) -> void
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Extensions.AI.ChatMessage!>! currentTurnMessages, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! forwardableMessages) -> void
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Extensions.AI.ChatMessage!>! currentTurnMessages, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! forwardableMessages) -> void
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Extensions.AI.ChatMessage!>! currentTurnMessages, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! forwardableMessages) -> void
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Extensions.AI.ChatMessage!>! currentTurnMessages, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! forwardableMessages) -> void
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ExecutorId.get -> string!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.ForwardableMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Microsoft.Agents.AI.Workflows.AIAgentHostResponse.CurrentTurnMessages.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>!
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResetChatSignal>(this.ResetChat));
protocolBuilder = this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));

return this._options.ForwardAgentResponse
? protocolBuilder.SendsMessage<AIAgentHostResponse>()
: protocolBuilder;
}

internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
Expand Down Expand Up @@ -201,6 +205,17 @@ await context.SendMessageAsync(forwardableMessages, cancellationToken)
.ConfigureAwait(false);
}

if (this._options.ForwardAgentResponse)
{
await context.SendMessageAsync(
new AIAgentHostResponse(
Comment thread
baywet marked this conversation as resolved.
this.Id,
response,
[.. messages, .. forwardableMessages],
Comment thread
baywet marked this conversation as resolved.
forwardableMessages),
cancellationToken).ConfigureAwait(false);
}

// If we have no outstanding requests, we can yield a turn token back to the workflow.
if (!this.HasOutstandingRequests)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,109 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> 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<object> sentMessages = testContext.QueuedMessages[executor.Id].Select(envelope => envelope.Message);
AIAgentHostResponse hostResponse = Assert.Single(sentMessages.OfType<AIAgentHostResponse>());

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<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new FixedResponseSession());

protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new FixedResponseSession());

protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;

protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(response);

protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> 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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AIAgentHostResponse>();

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()
{
Expand Down
Loading