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, IReadOnlyDictionary? credentialBindings, CancellationToken cancellationToken) { PluginManifest manifest = plugin.Manifest; ValidateDeclaredInputs(manifest, inputs); Dictionary payload = ResolveCredentials(manifest, credentialId, credentialBindings); PluginRequest request = new() { ProtocolVersion = PluginProtocol.Normalize(manifest.ProtocolVersion), 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, IReadOnlyDictionary? credentialBindings) { Dictionary map = new(StringComparer.OrdinalIgnoreCase); foreach (PluginCredentialNeed need in manifest.Credentials) { if (string.IsNullOrWhiteSpace(need.Type) && string.IsNullOrWhiteSpace(need.Name)) { continue; } string declaredType = string.IsNullOrWhiteSpace(need.Type) ? need.Name : need.Type; string? preferredId = null; if (credentialBindings is not null && !string.IsNullOrWhiteSpace(need.Name) && credentialBindings.TryGetValue(need.Name, out string? bound) && !string.IsNullOrWhiteSpace(bound)) { preferredId = bound; } else if (PluginCredentialTypes.IsLlm(declaredType) || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase)) { preferredId = credentialId; } CredentialRecord? record = credentials.TryResolveForType(declaredType, preferredId); if (record is null) { if (need.Required) { throw new InvalidOperationException( $"插件 {manifest.Id} 需要类型 `{declaredType}` 的凭据 `{need.Name}`,但宿主 Credentials 里没有匹配项。"); } continue; } if (need.Required && PluginCredentialTypes.IsLlm(declaredType) && string.IsNullOrWhiteSpace(record.ApiKey)) { throw new InvalidOperationException( $"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); } map[string.IsNullOrWhiteSpace(need.Name) ? declaredType : need.Name] = credentials.ToPayload(record); } return map; } /// 工作目录=插件文件夹。凭据写入子进程环境变量,不读宿主 appsettings 路径。 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); } ApplyDotEnv(plugin.FolderPath, start); foreach (KeyValuePair pair in plugin.Manifest.Env) { start.Environment[pair.Key] = pair.Value; } PluginStdio.ApplyCredentialsToEnvironment(payload, start.Environment); 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 { // 进程可能已经退出。 } } }