Forge
csharp3407750f
1using System.Reflection;
2using System.Runtime.Loader;
3using AgentForge.Abstractions;
4using AgentForge.Configuration;
5using AgentForge.Options;
6using Tomlyn;
7
8namespace AgentForge.Plugins;
9
10public sealed class ForgePluginRegistry
11{
12 public string ActiveBundle { get; init; } = "standard";
13
14 public ForgePluginDiscovery Discovery { get; init; } = ForgePluginDiscovery.Manual;
15
16 public IReadOnlyList<IForgePlugin> Plugins { get; init; } = [];
17
18 public IReadOnlyDictionary<string, ForgePluginEntry> PluginEntries { get; init; }
19 = new Dictionary<string, ForgePluginEntry>(StringComparer.OrdinalIgnoreCase);
20
21 public IReadOnlyList<string> Ids => Plugins.Select(p => p.Id).ToList();
22}
23
24public static class ForgePluginLoader
25{
26 private static readonly object ViewShellPinGate = new();
27 private static readonly object GitHubImportCorePinGate = new();
28 private static bool _viewShellPinned;
29 private static bool _gitHubImportCorePinned;
30 private static bool _pluginSdkPinned;
31 public static ForgePluginRegistry Load(WebApplicationBuilder builder)
32 {
33 var runtime = ForgePluginRuntime.Load(builder);
34 return runtime.Registry;
35 }
36
37 public static void ConfigureServices(IReadOnlyList<IForgePlugin> plugins, IServiceCollection services, IConfiguration configuration)
38 {
39 foreach (var plugin in plugins)
40 plugin.ConfigureServices(services, configuration);
41 }
42
43 public static void MapEndpoints(IReadOnlyList<IForgePlugin> plugins, WebApplication app)
44 {
45 foreach (var plugin in plugins)
46 plugin.MapEndpoints(app);
47 }
48
49 public static void ApplyMigrations(IReadOnlyList<IForgePlugin> plugins, IForgeMigrationContext context)
50 {
51 foreach (var plugin in plugins)
52 plugin.ApplyMigrations(context);
53 }
54
55 internal static ForgePluginManifest LoadManifest(string contentRoot, IConfiguration configuration)
56 {
57 var path = configuration["Forge:PluginsManifestPath"]
58 ?? Environment.GetEnvironmentVariable("FORGE_PLUGINS_MANIFEST");
59
60 if (string.IsNullOrWhiteSpace(path))
61 path = Path.Combine(contentRoot, "forge.plugins.toml");
62
63 if (!Path.IsPathRooted(path) && !File.Exists(path))
64 path = Path.Combine(contentRoot, path);
65
66 if (!File.Exists(path))
67 {
68 var embeddedOnly = ForgePluginManifestLoader.LoadEmbedded();
69 ApplyPluginDiscoveryEnvironmentOverride(embeddedOnly);
70 return embeddedOnly;
71 }
72
73 var toml = File.ReadAllText(path);
74 var model = Toml.ToModel(toml);
75 var embedded = ForgePluginManifestLoader.LoadEmbedded();
76 var manifest = ForgePluginManifestLoader.LoadTomlText(toml);
77 manifest.Import = model.TryGetValue("import", out _)
78 ? ForgePluginManifestLoader.MergeImportSettings(embedded.Import, manifest.Import)
79 : embedded.Import;
80 manifest.Host = model.TryGetValue("plugins", out _)
81 ? ForgePluginManifestLoader.MergeHostSettings(embedded.Host, manifest.Host)
82 : embedded.Host;
83 ApplyPluginDiscoveryEnvironmentOverride(manifest);
84 return manifest;
85 }
86
87 internal static void ApplyPluginDiscoveryEnvironmentOverride(ForgePluginManifest manifest)
88 {
89 var discovery = Environment.GetEnvironmentVariable("FORGE_PLUGIN_DISCOVERY");
90 if (string.IsNullOrWhiteSpace(discovery))
91 return;
92
93 manifest.Host.Discovery = discovery.Trim().ToLowerInvariant() switch
94 {
95 "auto" => ForgePluginDiscovery.Auto,
96 _ => ForgePluginDiscovery.Manual,
97 };
98 }
99
100 internal static ForgePluginDiscovery ResolveDiscovery(ForgePluginManifest manifest) =>
101 manifest.Host.Discovery ?? ForgePluginDiscovery.Manual;
102
103 internal static (IForgePlugin Plugin, ForgePluginLoadContext? Context) LoadPluginWithContext(
104 string id,
105 ForgePluginEntry entry,
106 string? pluginsPath)
107 {
108 var assemblyName = string.IsNullOrWhiteSpace(entry.Assembly)
109 ? $"AgentForge.Plugin.{id}.dll"
110 : entry.Assembly;
111
112 var assemblyPath = ResolveAssemblyPath(assemblyName, pluginsPath)
113 ?? throw new InvalidOperationException($"Could not load plugin assembly '{assemblyName}' for plugin '{id}'.");
114
115 ForgePluginLoadContext? context = null;
116 Assembly assembly;
117 if (ShouldUseCollectibleContext(id, assemblyPath))
118 {
119 EnsureViewShellInDefault(pluginsPath);
120 EnsurePluginSdkInDefault(pluginsPath);
121 EnsureGitHubImportCoreInDefault(pluginsPath);
122 context = new ForgePluginLoadContext(id, assemblyPath);
123 assembly = context.LoadFromAssemblyPath(assemblyPath);
124 }
125 else
126 {
127 PreloadDefaultSiblingAssembly(Path.GetDirectoryName(assemblyPath)!, "Markdig");
128 assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
129 }
130
131 var pluginType = ResolvePluginType(assembly, entry.Type)
132 ?? throw new InvalidOperationException(
133 $"No {nameof(IForgePlugin)} implementation found in '{assemblyName}' for plugin '{id}'.");
134
135 if (Activator.CreateInstance(pluginType) is not IForgePlugin plugin)
136 throw new InvalidOperationException($"Failed to activate plugin type '{pluginType.FullName}'.");
137
138 return (plugin, context);
139 }
140
141 internal static string? ResolveAssemblyPath(string assemblyFileName, string? pluginsPath)
142 {
143 var baseDir = AppContext.BaseDirectory;
144 var roots = new List<string>();
145 if (!string.IsNullOrWhiteSpace(pluginsPath))
146 {
147 roots.Add(Path.IsPathRooted(pluginsPath)
148 ? pluginsPath
149 : Path.GetFullPath(Path.Combine(baseDir, pluginsPath)));
150 }
151
152 roots.Add(Path.Combine(baseDir, "plugins"));
153
154 foreach (var root in roots.Distinct(StringComparer.OrdinalIgnoreCase))
155 {
156 var flat = Path.Combine(root, assemblyFileName);
157 if (File.Exists(flat))
158 return flat;
159
160 if (!Directory.Exists(root))
161 continue;
162
163 var nested = Directory.EnumerateFiles(root, assemblyFileName, SearchOption.AllDirectories).FirstOrDefault();
164 if (nested is not null)
165 return nested;
166 }
167
168 var legacy = Path.Combine(baseDir, assemblyFileName);
169 return File.Exists(legacy) ? legacy : null;
170 }
171
172 private static Type? ResolvePluginType(Assembly assembly, string? typeName)
173 {
174 if (!string.IsNullOrWhiteSpace(typeName))
175 {
176 var explicitType = assembly.GetType(typeName, throwOnError: false, ignoreCase: false);
177 if (explicitType is not null && typeof(IForgePlugin).IsAssignableFrom(explicitType))
178 return explicitType;
179 }
180
181 return assembly.GetTypes()
182 .FirstOrDefault(t => t is { IsAbstract: false, IsInterface: false } && typeof(IForgePlugin).IsAssignableFrom(t));
183 }
184
185 private static bool ShouldUseCollectibleContext(string id, string assemblyPath) =>
186 Path.GetFileName(assemblyPath).StartsWith("AgentForge.Plugin.", StringComparison.OrdinalIgnoreCase)
187 && !string.Equals(id, "view-shell", StringComparison.OrdinalIgnoreCase);
188
189 private static void EnsureViewShellInDefault(string? pluginsPath)
190 {
191 lock (ViewShellPinGate)
192 {
193 if (_viewShellPinned)
194 return;
195
196 const string viewShellFile = "AgentForge.Plugin.ViewShell.dll";
197 if (AssemblyLoadContext.Default.Assemblies.Any(a =>
198 string.Equals(a.GetName().Name, "AgentForge.Plugin.ViewShell", StringComparison.Ordinal)))
199 {
200 _viewShellPinned = true;
201 return;
202 }
203
204 var path = ResolveAssemblyPath(viewShellFile, pluginsPath);
205 if (path is not null)
206 {
207 var pluginDir = Path.GetDirectoryName(path)!;
208 PreloadDefaultSiblingAssembly(pluginDir, "Markdig");
209 AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
210 }
211
212 _viewShellPinned = true;
213 }
214 }
215
216 private static void EnsureGitHubImportCoreInDefault(string? pluginsPath)
217 {
218 lock (GitHubImportCorePinGate)
219 {
220 if (_gitHubImportCorePinned)
221 return;
222
223 EnsurePluginSdkInDefault(pluginsPath);
224
225 const string coreFile = "AgentForge.Plugin.RepositoryImport.GitHub.Core.dll";
226 if (AssemblyLoadContext.Default.Assemblies.Any(a =>
227 string.Equals(a.GetName().Name, "AgentForge.Plugin.RepositoryImport.GitHub.Core", StringComparison.Ordinal)))
228 {
229 _gitHubImportCorePinned = true;
230 return;
231 }
232
233 var path = ResolveAssemblyPath(coreFile, pluginsPath);
234 if (path is not null)
235 AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
236
237 _gitHubImportCorePinned = true;
238 }
239 }
240
241 private static void EnsurePluginSdkInDefault(string? pluginsPath)
242 {
243 if (_pluginSdkPinned)
244 return;
245
246 const string sdkFile = "AgentForge.Plugin.Sdk.dll";
247 if (AssemblyLoadContext.Default.Assemblies.Any(a =>
248 string.Equals(a.GetName().Name, "AgentForge.Plugin.Sdk", StringComparison.Ordinal)))
249 {
250 _pluginSdkPinned = true;
251 return;
252 }
253
254 var path = ResolveAssemblyPath(sdkFile, pluginsPath)
255 ?? Path.Combine(AppContext.BaseDirectory, sdkFile);
256 if (File.Exists(path))
257 AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
258
259 _pluginSdkPinned = true;
260 }
261
262 private static void PreloadDefaultSiblingAssembly(string pluginDir, string assemblyName)
263 {
264 if (AssemblyLoadContext.Default.Assemblies.Any(a =>
265 string.Equals(a.GetName().Name, assemblyName, StringComparison.Ordinal)))
266 return;
267
268 var path = Path.Combine(pluginDir, $"{assemblyName}.dll");
269 if (File.Exists(path))
270 AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
271 }
272
273 internal sealed record ForgeDiscoveredPlugin(
274 string Id,
275 ForgePluginEntry Entry,
276 IForgePlugin Plugin,
277 ForgePluginLoadContext? Context);
278
279 internal static List<ForgeDiscoveredPlugin> DiscoverPlugins(ForgePluginManifest manifest, string? pluginsPath)
280 {
281 var results = new List<ForgeDiscoveredPlugin>();
282 var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
283 foreach (var assemblyPath in EnumeratePluginAssemblyPaths(pluginsPath))
284 {
285 var fileName = Path.GetFileName(assemblyPath);
286 var catalogEntry = manifest.Plugins.Values.FirstOrDefault(entry =>
287 string.Equals(entry.Assembly, fileName, StringComparison.OrdinalIgnoreCase));
288 var catalogId = manifest.Plugins.FirstOrDefault(pair =>
289 string.Equals(pair.Value.Assembly, fileName, StringComparison.OrdinalIgnoreCase)).Key;
290 var loadId = catalogId ?? Path.GetFileNameWithoutExtension(fileName);
291 var entry = catalogEntry ?? new ForgePluginEntry { Assembly = fileName };
292
293 try
294 {
295 var (plugin, context) = LoadPluginWithContext(loadId, entry, pluginsPath);
296 if (seen.Contains(plugin.Id))
297 continue;
298
299 if (manifest.Host.Auto.Exclude.Any(id =>
300 string.Equals(id, plugin.Id, StringComparison.OrdinalIgnoreCase)))
301 continue;
302
303 if (manifest.Host.Auto.RequireManifestEntry && !manifest.Plugins.ContainsKey(plugin.Id))
304 continue;
305
306 if (manifest.Plugins.TryGetValue(plugin.Id, out var manifestEntry))
307 entry = manifestEntry;
308
309 seen.Add(plugin.Id);
310 results.Add(new ForgeDiscoveredPlugin(plugin.Id, entry, plugin, context));
311 }
312 catch (InvalidOperationException)
313 {
314 // Shared libs and non-plugin assemblies are skipped.
315 }
316 }
317
318 return results.OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase).ToList();
319 }
320
321 private static IEnumerable<string> EnumeratePluginAssemblyPaths(string? pluginsPath)
322 {
323 var baseDir = AppContext.BaseDirectory;
324 var roots = new List<string>();
325 if (!string.IsNullOrWhiteSpace(pluginsPath))
326 {
327 roots.Add(Path.IsPathRooted(pluginsPath)
328 ? pluginsPath
329 : Path.GetFullPath(Path.Combine(baseDir, pluginsPath)));
330 }
331
332 roots.Add(Path.Combine(baseDir, "plugins"));
333
334 return roots
335 .Distinct(StringComparer.OrdinalIgnoreCase)
336 .Where(Directory.Exists)
337 .SelectMany(root => Directory.EnumerateFiles(root, "AgentForge.Plugin.*.dll", SearchOption.TopDirectoryOnly))
338 .Distinct(StringComparer.OrdinalIgnoreCase)
339 .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase);
340 }
341}
342
View only · write via MCP/CIDE