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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
|
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));
RejectLegacySandbox(yaml);
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.");
ValidateSandbox(value.Sandbox);
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 void ValidateSandbox(SandboxOptions sandbox)
{
if (sandbox.Profile is not ("readonly" or "development")) Throw("sandbox.profile must be readonly or development.");
if (sandbox.Workspaces.Any(string.IsNullOrWhiteSpace)) Throw("sandbox.workspaces contains an empty Logical Path.");
foreach (MountOptions mount in sandbox.Mounts)
{
if (string.IsNullOrWhiteSpace(mount.Path) || mount.Access is not ("read_only" or "read_write" or "read_write_delete"))
Throw("sandbox.mounts requires a Logical Path and access read_only, read_write, or read_write_delete.");
if (sandbox.Profile == "readonly" && mount.Access != "read_only")
Throw("readonly sandbox.profile permits only read_only mounts.");
}
string[] aliases = sandbox.Execution.Executables.Concat(sandbox.Execution.Shells).ToArray();
if (aliases.Any(alias => !AliasRegex().IsMatch(alias))) Throw("Executable Alias must match ^[a-z0-9][a-z0-9._-]{0,63}$.");
if (aliases.Distinct(StringComparer.OrdinalIgnoreCase).Count() != aliases.Length)
Throw("Executable Alias values must be unique across sandbox.execution.executables and shells.");
if (sandbox.Network.Access is not ("none" or "local" or "restricted"))
Throw("sandbox.network.access must be none, local, or restricted.");
if (sandbox.Network.LocalEndpoints.Any(static endpoint => endpoint.Port is < 1 or > 65535))
Throw("sandbox.network.local_endpoints contains an invalid port.");
foreach (NetworkAllowance endpoint in sandbox.Network.RestrictedEndpoints)
if (string.IsNullOrWhiteSpace(endpoint.Host) || endpoint.Ports.Count == 0 || endpoint.Ports.Any(static port => port is < 1 or > 65535))
Throw("sandbox.network.restricted_endpoints contains an invalid host or port.");
if (sandbox.Network.Access == "none" && (sandbox.Network.LocalEndpoints.Count != 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
Throw("sandbox.network.access none cannot contain endpoints.");
if (sandbox.Network.Access == "local" && (sandbox.Network.LocalEndpoints.Count == 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
Throw("sandbox.network.access local requires local_endpoints and forbids restricted_endpoints.");
if (sandbox.Network.Access == "restricted" && (sandbox.Network.RestrictedEndpoints.Count == 0 || sandbox.Network.LocalEndpoints.Count != 0))
Throw("sandbox.network.access restricted requires restricted_endpoints and forbids local_endpoints.");
if (sandbox.Resources.Profile is not ("constrained" or "normal" or "extended"))
Throw("sandbox.resources.profile must be constrained, normal, or extended.");
if (sandbox.Resources.MemoryMb is <= 0 || sandbox.Resources.ProcessLimit is <= 0 || sandbox.Resources.ExecutionTimeoutSeconds is <= 0)
Throw("sandbox resource overrides must be positive.");
}
private static void RejectLegacySandbox(string yaml)
{
YamlNode root = LoadRoot(yaml, "merged configuration");
if (root is not YamlMappingNode rootMap || !TryGet(rootMap, "sandbox", out YamlNode? sandboxNode) || sandboxNode is not YamlMappingNode sandbox) return;
if (TryGet(sandbox, "filesystem", out _) || TryGet(sandbox, "process", out _))
Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to sandbox.profile, workspaces, mounts, execution, network, and resources; then run 'sandbox resolve' and 'sandbox confirm'.");
}
private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value)
{
foreach (KeyValuePair<YamlNode, YamlNode> pair in map.Children)
{
if (pair.Key is YamlScalarNode key && key.Value == name)
{
value = pair.Value;
return true;
}
}
value = null;
return false;
}
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("^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant)]
private static partial Regex AliasRegex();
}
|