Skip to content

Commit 15a25dd

Browse files
committed
feat: Unify non-Handoff Agent Hosting
1 parent 6b3782d commit 15a25dd

7 files changed

Lines changed: 137 additions & 76 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,16 @@ public sealed class AIAgentHostOptions
3232
/// instead of being raised as a request.
3333
/// </summary>
3434
public bool InterceptUnterminatedFunctionCalls { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets a value indicating whether other messages from other agents should be assigned to the
38+
/// <see cref="ChatRole.User"/> role during execution.
39+
/// </summary>
40+
public bool ReassignOtherAgentsAsUsers { get; set; } = true;
41+
42+
/// <summary>
43+
/// Gets or sets a value indicating whether incoming messages are automatically forwarded before new messages generated
44+
/// by the agent during its turn.
45+
/// </summary>
46+
public bool ForwardIncomingMessages { get; set; } = true;
3547
}

dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@ public static ChatMessage ToChatMessage(this AgentRunResponseUpdate update) =>
1919
RawRepresentation = update.RawRepresentation ?? update,
2020
};
2121

22+
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
23+
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
24+
25+
private static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName, out bool changed, bool inplace = true)
26+
{
27+
changed = false;
28+
29+
if (message.Role == ChatRole.Assistant &&
30+
message.AuthorName != agentName &&
31+
message.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
32+
{
33+
if (!inplace)
34+
{
35+
message = message.Clone();
36+
}
37+
38+
message.Role = ChatRole.User;
39+
changed = true;
40+
}
41+
42+
return message;
43+
}
44+
2245
/// <summary>
2346
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
2447
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
@@ -29,11 +52,9 @@ public static ChatMessage ToChatMessage(this AgentRunResponseUpdate update) =>
2952
List<ChatMessage>? roleChanged = null;
3053
foreach (var m in messages)
3154
{
32-
if (m.Role == ChatRole.Assistant &&
33-
m.AuthorName != targetAgentName &&
34-
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
55+
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
56+
if (changed)
3557
{
36-
m.Role = ChatRole.User;
3758
(roleChanged ??= []).Add(m);
3859
}
3960
}

dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
using System;
44
using System.Collections.Generic;
5-
using System.Diagnostics;
65
using System.Linq;
76
using System.Threading.Tasks;
87
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -35,38 +34,28 @@ public static Workflow BuildSequential(string workflowName, params IEnumerable<A
3534

3635
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> agents)
3736
{
38-
Throw.IfNull(agents);
37+
Throw.IfNullOrEmpty(agents);
3938

4039
// Create a builder that chains the agents together in sequence. The workflow simply begins
4140
// with the first agent in the sequence.
42-
WorkflowBuilder? builder = null;
43-
ExecutorBinding? previous = null;
44-
foreach (var agent in agents)
41+
42+
AIAgentHostOptions options = new()
4543
{
46-
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
47-
48-
if (builder is null)
49-
{
50-
builder = new WorkflowBuilder(agentExecutor);
51-
}
52-
else
53-
{
54-
Debug.Assert(previous is not null);
55-
builder.AddEdge(previous, agentExecutor);
56-
}
57-
58-
previous = agentExecutor;
59-
}
44+
ReassignOtherAgentsAsUsers = true,
45+
ForwardIncomingMessages = true,
46+
};
47+
48+
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
6049

61-
if (previous is null)
50+
ExecutorBinding previous = agentExecutors[0];
51+
WorkflowBuilder builder = new(previous);
52+
53+
foreach (ExecutorBinding next in agentExecutors.Skip(1))
6254
{
63-
Throw.ArgumentException(nameof(agents), "At least one agent must be provided to build a sequential workflow.");
55+
builder.AddEdge(previous, next);
56+
previous = next;
6457
}
6558

66-
// Add an ending executor that batches up all messages from the last agent
67-
// so that it's published as a single list result.
68-
Debug.Assert(builder is not null);
69-
7059
OutputMessagesExecutor end = new();
7160
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
7261
if (workflowName is not null)
@@ -125,9 +114,12 @@ private static Workflow BuildConcurrentCore(
125114
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
126115
// accumulator would not be able to determine what came from what agent, as there's currently no
127116
// provenance tracking exposed in the workflow context passed to a handler.
128-
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
117+
118+
ExecutorBinding[] agentExecutors = (from agent in agents
119+
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
129120
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
130121
builder.AddFanOutEdge(start, agentExecutors);
122+
131123
for (int i = 0; i < agentExecutors.Length; i++)
132124
{
133125
builder.AddEdge(agentExecutors[i], accumulators[i]);

dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ public GroupChatWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agen
5050
public Workflow Build()
5151
{
5252
AIAgent[] agents = this._participants.ToArray();
53-
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
53+
54+
AIAgentHostOptions options = new()
55+
{
56+
ReassignOtherAgentsAsUsers = true,
57+
ForwardIncomingMessages = true
58+
};
59+
60+
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
5461

5562
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
5663
(id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,16 @@ protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowC
134134
private async ValueTask ContinueTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken)
135135
{
136136
this._currentTurnEmitEvents = emitEvents;
137-
AgentRunResponse response = await this.InvokeAgentAsync(messages, context, emitEvents, cancellationToken).ConfigureAwait(false);
137+
if (this._options.ForwardIncomingMessages)
138+
{
139+
await context.SendMessageAsync(messages, cancellationToken).ConfigureAwait(false);
140+
}
141+
142+
IEnumerable<ChatMessage> filteredMessages = this._options.ReassignOtherAgentsAsUsers
143+
? messages.Select(m => m.ChatAssistantToUserIfNotFromNamed(this._agent.Name ?? this._agent.Id))
144+
: messages;
145+
146+
AgentRunResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
138147

139148
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
140149
.ConfigureAwait(false);

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs

Lines changed: 0 additions & 44 deletions
This file was deleted.

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,70 @@ public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool ex
118118
}
119119
}
120120

121+
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
122+
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
123+
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };
124+
125+
[Theory]
126+
[InlineData(true, true, false, false)]
127+
[InlineData(true, true, false, true)]
128+
[InlineData(true, true, true, false)]
129+
[InlineData(true, true, true, true)]
130+
[InlineData(true, false, false, false)]
131+
[InlineData(true, false, false, true)]
132+
[InlineData(true, false, true, false)]
133+
[InlineData(true, false, true, true)]
134+
[InlineData(false, true, false, false)]
135+
[InlineData(false, true, false, true)]
136+
[InlineData(false, true, true, false)]
137+
[InlineData(false, true, true, true)]
138+
[InlineData(false, false, false, false)]
139+
[InlineData(false, false, false, true)]
140+
[InlineData(false, false, true, false)]
141+
[InlineData(false, false, true, true)]
142+
public async Task Test_AgentHostExecutor_ReassignsRolesIFFConfiguredAsync(bool executorSetting, bool includeUser, bool includeSelfMessages, bool includeOtherMessages)
143+
{
144+
// Arrange
145+
TestRunContext testContext = new();
146+
RoleCheckAgent agent = new(false, TestAgentId, TestAgentName);
147+
AIAgentHostExecutor executor = new(agent, new() { ReassignOtherAgentsAsUsers = executorSetting });
148+
testContext.ConfigureExecutor(executor);
149+
150+
List<ChatMessage> messages = [];
151+
152+
if (includeUser)
153+
{
154+
messages.Add(UserMessage);
155+
}
156+
157+
if (includeSelfMessages)
158+
{
159+
messages.Add(TestAgentMessage);
160+
}
161+
162+
if (includeOtherMessages)
163+
{
164+
messages.Add(AssistantMessage);
165+
}
166+
167+
// Act
168+
await executor.Router.RouteMessageAsync(messages, testContext.BindWorkflowContext(executor.Id));
169+
170+
Func<Task> act = async () => await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
171+
172+
// Assert
173+
bool shouldThrow = includeOtherMessages && !executorSetting;
174+
175+
if (shouldThrow)
176+
{
177+
await act.Should().ThrowAsync<InvalidOperationException>();
178+
}
179+
else
180+
{
181+
await act.Should().NotThrowAsync();
182+
}
183+
}
184+
121185
[Theory]
122186
[InlineData(true, TestAgentRequestType.FunctionCall)]
123187
[InlineData(false, TestAgentRequestType.FunctionCall)]

0 commit comments

Comments
 (0)