using System.Net; using System.Text.RegularExpressions; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.RepresentationModel; namespace Agent.App.Configuration; public static partial class ConfigurationLoader { public static AgentConfiguration Load(string path) { string absolutePath = Path.GetFullPath(path); if (!File.Exists(absolutePath)) { throw new ConfigurationException($"Configuration file does not exist: {absolutePath}"); } try { string yaml = File.ReadAllText(absolutePath); string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml"); if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath)) yaml = MergeYaml(yaml, File.ReadAllText(localPath)); var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); AgentConfiguration configuration = deserializer.Deserialize(yaml) ?? throw new ConfigurationException("Configuration document is empty."); Validate(configuration, Path.GetDirectoryName(absolutePath)!); return configuration; } catch (ConfigurationException) { throw; } catch (Exception ex) { throw new ConfigurationException($"Could not parse YAML: {ex.Message}", ex); } } public static void Validate(AgentConfiguration value, string baseDirectory) { if (value.SchemaVersion != 1) Throw("schema_version must be 1."); if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535."); if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive."); if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each."); if (value.Mcp.Tasks.TimeToLiveHours <= 0 || value.Mcp.Tasks.PollIntervalMs <= 0) Throw("mcp.tasks TTL and poll interval must be positive."); if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive."); if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2."); if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32."); if (value.Sandbox.Process.MaxProcesses <= 0 || value.Sandbox.Resources.MemoryMb <= 0 || value.Sandbox.Resources.ProcessTimeoutSeconds <= 0) Throw("sandbox resource limits must be positive."); if (value.Sandbox.Network.Mode is not ("deny" or "allow")) Throw("sandbox.network.mode must be deny or allow."); foreach (NetworkAllowance allowance in value.Sandbox.Network.Allow) { if (string.IsNullOrWhiteSpace(allowance.Host) || allowance.Ports.Count == 0 || allowance.Ports.Any(static p => p is < 1 or > 65535)) Throw("sandbox.network.allow contains an invalid host or port."); } foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals) { if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters."); string approvalPath = Path.GetFullPath(approval.Path); bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath)); if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute."); } foreach (string executeEntry in value.Sandbox.Filesystem.Execute) { string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry); if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval => string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase))) Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals."); } if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal."); RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt"); RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory"); RequireDirectory(baseDirectory, value.Skills.Directory, "skills.directory"); } private static void RequireFile(string baseDirectory, string path, string key) { if (!File.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); } private static void RequireDirectory(string baseDirectory, string path, string key) { if (!Directory.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); } public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); private static bool ContainsPath(string baseDirectory, string configuredPath, string target) { string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath); if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true; string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } private static string MergeYaml(string baseYaml, string localYaml) { YamlNode baseRoot = LoadRoot(baseYaml, "base configuration"); YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml"); YamlNode merged = MergeNode(baseRoot, localRoot); var stream = new YamlStream(new YamlDocument(merged)); using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); stream.Save(writer, assignAnchors: false); return writer.ToString(); } private static YamlNode LoadRoot(string yaml, string source) { var stream = new YamlStream(); using var reader = new StringReader(yaml); stream.Load(reader); if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document."); return stream.Documents[0].RootNode; } private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode) { if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap) return Clone(localNode); var result = (YamlMappingNode)Clone(baseMap); foreach ((YamlNode key, YamlNode localValue) in localMap.Children) { YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal)); if (existingKey is null) result.Add(Clone(key), Clone(localValue)); else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue); } return result; } private static YamlNode Clone(YamlNode node) { var stream = new YamlStream(new YamlDocument(node)); using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); stream.Save(writer, assignAnchors: false); return LoadRoot(writer.ToString(), "merged configuration"); } private static void Throw(string message) => throw new ConfigurationException(message); [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] private static partial Regex Sha256Regex(); }