summaryrefslogtreecommitdiff
path: root/src/Agent.App/Configuration/ConfigurationLoader.cs
blob: b6c4c2d857b66b0c7e0ea0fe653c66cd4b906913 (plain)
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
using System.Net;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

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
        {
            var deserializer = new DeserializerBuilder()
                .WithNamingConvention(UnderscoredNamingConvention.Instance)
                .Build();
            AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath))
                ?? 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.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.");
        }
        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.");

        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 Throw(string message) => throw new ConfigurationException(message);
    [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)]
    private static partial Regex Sha256Regex();
}