using System.Diagnostics; using System.Text; using System.Text.Json; using MAF1.PluginContract; using MAF1.Utils; namespace MAF1.Plugins; /// /// 启动子进程跑插件:stdin 写 PluginRequest,stdout 读输出 JSON,超时 Kill。 /// 这是「进程外 Agent」的完整示例,对照 FileCityPlugin/Program.cs 一起看。 /// public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials) { public async Task> RunAsync( LoadedPlugin plugin, Dictionary inputs, string? credentialId, CancellationToken cancellationToken) { PluginManifest manifest = plugin.Manifest; ValidateDeclaredInputs(manifest, inputs); Dictionary payload = ResolveCredentials(manifest, credentialId); PluginRequest request = new() { Inputs = inputs, Credentials = payload, }; int timeoutSeconds = manifest.TimeoutSeconds > 0 ? manifest.TimeoutSeconds : options.DefaultTimeoutSeconds; using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); ProcessStartInfo start = CreateStartInfo(plugin, payload); using Process process = new() { StartInfo = start, EnableRaisingEvents = true }; if (!process.Start()) { throw new InvalidOperationException($"无法启动插件 {manifest.Id}:{start.FileName}"); } string json = JsonSerializer.Serialize(request, PluginJson.Options); await process.StandardInput.WriteAsync(json.AsMemory(), timeout.Token); await process.StandardInput.FlushAsync(timeout.Token); process.StandardInput.Close(); Task stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); Task stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); try { await process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { TryKill(process); throw new TimeoutException($"插件 {manifest.Id} 超过 {timeoutSeconds} 秒未结束,已终止。"); } string stdout = await stdoutTask; string stderr = await stderrTask; if (process.ExitCode != 0) { throw new InvalidOperationException( $"插件 {manifest.Id} 退出码 {process.ExitCode}。{FormatStd(stderr, stdout)}"); } Dictionary outputs = ParseOutputs(stdout); ValidateDeclaredOutputs(manifest, outputs); if (!string.IsNullOrWhiteSpace(stderr)) { outputs["_stderr"] = stderr.Trim(); } return outputs; } private Dictionary ResolveCredentials(PluginManifest manifest, string? credentialId) { Dictionary map = new(StringComparer.OrdinalIgnoreCase); foreach (PluginCredentialNeed need in manifest.Credentials) { if (need.Type is "openai-compatible" or "llm" || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase)) { CredentialRecord record = credentials.Resolve(credentialId); if (need.Required && string.IsNullOrWhiteSpace(record.ApiKey)) { throw new InvalidOperationException( $"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); } map[need.Name] = credentials.ToPayload(record); } } if (manifest.Credentials.Count == 0) { map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId)); } return map; } /// 工作目录=插件文件夹;环境变量带上 OPENAI_* 和 MAF1_CONTENT_ROOT。 private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary payload) { string command = plugin.Manifest.Launch.Command; if (!Path.IsPathRooted(command)) { string local = Path.Combine(plugin.FolderPath, command); if (File.Exists(local)) { command = local; } } ProcessStartInfo start = new() { FileName = command, WorkingDirectory = plugin.FolderPath, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardInputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (string arg in plugin.Manifest.Launch.Args) { start.ArgumentList.Add(arg); } start.Environment["MAF1_CONTENT_ROOT"] = AppContext.BaseDirectory; ApplyDotEnv(plugin.FolderPath, start); foreach (KeyValuePair pair in plugin.Manifest.Env) { start.Environment[pair.Key] = pair.Value; } if (payload.TryGetValue("llm", out PluginCredentialPayload? llm) || payload.Count > 0) { llm ??= payload.Values.First(); if (!string.IsNullOrWhiteSpace(llm.ApiKey)) { start.Environment["OPENAI_API_KEY"] = llm.ApiKey; } if (!string.IsNullOrWhiteSpace(llm.Endpoint)) { start.Environment["OPENAI_ENDPOINT"] = llm.Endpoint; } if (!string.IsNullOrWhiteSpace(llm.Model)) { start.Environment["OPENAI_CHAT_MODEL"] = llm.Model; } } return start; } private static void ApplyDotEnv(string pluginDir, ProcessStartInfo start) { string path = Path.Combine(pluginDir, ".env"); if (!File.Exists(path)) { return; } foreach (string raw in File.ReadAllLines(path)) { string line = raw.Trim(); if (line.Length == 0 || line.StartsWith('#') || !line.Contains('=')) { continue; } int split = line.IndexOf('='); string key = line[..split].Trim(); string value = line[(split + 1)..].Trim().Trim('"'); if (!string.IsNullOrWhiteSpace(key)) { start.Environment[key] = value; } } } private static void ValidateDeclaredInputs(PluginManifest manifest, Dictionary inputs) { foreach (PluginPort port in manifest.Inputs.Where(p => p.Required)) { if (!inputs.TryGetValue(port.Name, out object? value) || value is null || value.ToString() == "") { throw new InvalidOperationException( $"插件 {manifest.Id} 的必填输入 `{port.Name}` 为空。请在节点配置里填写,或从上一节点连到这个端口。字段名必须和 plugin.json / 插件代码一致。"); } } } private static void ValidateDeclaredOutputs(PluginManifest manifest, Dictionary outputs) { List missing = manifest.Outputs .Select(p => p.Name) .Where(name => !outputs.Keys.Any(k => k.Equals(name, StringComparison.OrdinalIgnoreCase))) .ToList(); if (missing.Count > 0) { throw new InvalidOperationException( $"插件 {manifest.Id} 的输出字段与 plugin.json 不一致,缺少:{string.Join(", ", missing)}。请让插件 stdout 的 JSON 字段和清单 outputs 完全对应。"); } } private static Dictionary ParseOutputs(string stdout) { string json = JsonText.UnwrapObject(stdout); using JsonDocument doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Object) { throw new InvalidOperationException("插件 stdout 必须是一个 JSON 对象。"); } Dictionary map = new(StringComparer.OrdinalIgnoreCase); foreach (JsonProperty property in doc.RootElement.EnumerateObject()) { map[property.Name] = property.Value.Clone(); } return map; } private static string FormatStd(string stderr, string stdout) { string err = string.IsNullOrWhiteSpace(stderr) ? "" : $"stderr: {stderr.Trim()}"; string outText = string.IsNullOrWhiteSpace(stdout) ? "" : $"stdout: {stdout.Trim()}"; return string.Join(" ", new[] { err, outText }.Where(s => s.Length > 0)); } private static void TryKill(Process process) { try { if (!process.HasExited) { process.Kill(entireProcessTree: true); } } catch { // 进程可能已经退出。 } } }