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
{
// 进程可能已经退出。
}
}
}