1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
|
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<AgentConfiguration>(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.Think is string booleanText && bool.TryParse(booleanText, out bool booleanThink))
value.Ollama.Think = booleanThink;
if (value.Ollama.Think is not bool &&
(value.Ollama.Think is not string thinkLevel || thinkLevel is not ("low" or "medium" or "high" or "max")))
Throw("ollama.think must be true, false, low, medium, high, or max.");
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();
}
|