summaryrefslogtreecommitdiff
path: root/src/Agent.App
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.App
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.App')
-rw-r--r--src/Agent.App/Agent.App.csproj24
-rw-r--r--src/Agent.App/Cli/CommandLine.cs39
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs76
-rw-r--r--src/Agent.App/Configuration/ConfigurationException.cs4
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs85
-rw-r--r--src/Agent.App/Configuration/OllamaSettingsFactory.cs39
-rw-r--r--src/Agent.App/Logging/JsonLinesRuntimeObserver.cs37
-rw-r--r--src/Agent.App/Logging/RollingFileLog.cs55
-rw-r--r--src/Agent.App/Program.cs130
9 files changed, 489 insertions, 0 deletions
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj
new file mode 100644
index 0000000..a42ebad
--- /dev/null
+++ b/src/Agent.App/Agent.App.csproj
@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <AssemblyName>agent-mcp</AssemblyName>
+ <RootNamespace>Agent.App</RootNamespace>
+ <Version>1.0.0</Version>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Mcp\Agent.Mcp.csproj" />
+ <ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting" />
+ <PackageReference Include="YamlDotNet" />
+ </ItemGroup>
+ <ItemGroup>
+ <Content Include="..\..\config\agent.yaml" Link="agent.yaml" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\config\locales\**\*.json" Link="config\locales\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\prompts\user-system.md" Link="prompts\user-system.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\skills\.keep" Link="skills\.keep" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
+ <Content Include="..\..\README.md" Link="README.md" CopyToPublishDirectory="PreserveNewest" />
+ </ItemGroup>
+</Project>
diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs
new file mode 100644
index 0000000..df65080
--- /dev/null
+++ b/src/Agent.App/Cli/CommandLine.cs
@@ -0,0 +1,39 @@
+namespace Agent.App.Cli;
+
+public enum AppCommand { Serve, Check }
+
+public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport)
+{
+ public static CommandLine Parse(string[] args)
+ {
+ if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'.");
+ AppCommand command = args[0] switch
+ {
+ "serve" => AppCommand.Serve,
+ "check" => AppCommand.Check,
+ _ => throw new CommandLineException("Expected 'serve' or 'check'.")
+ };
+
+ string? config = null;
+ string? language = null;
+ string? transport = null;
+ for (int i = 1; i < args.Length; i++)
+ {
+ string option = args[i];
+ if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'.");
+ string value = args[++i];
+ switch (option)
+ {
+ case "--config" when config is null: config = value; break;
+ case "--lang" when language is null: language = value; break;
+ case "--transport" when command == AppCommand.Serve && transport is null && value is "stdio" or "http": transport = value; break;
+ case "--transport" when command == AppCommand.Check: throw new CommandLineException("--transport is only valid for serve.");
+ case "--transport": throw new CommandLineException("Transport must be stdio or http.");
+ default: throw new CommandLineException($"Unknown or duplicate option '{option}'.");
+ }
+ }
+ return new CommandLine(command, config, language, transport);
+ }
+}
+
+public sealed class CommandLineException(string message) : Exception(message);
diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs
new file mode 100644
index 0000000..800286b
--- /dev/null
+++ b/src/Agent.App/Configuration/AgentConfiguration.cs
@@ -0,0 +1,76 @@
+namespace Agent.App.Configuration;
+
+public sealed class AgentConfiguration
+{
+ public int SchemaVersion { get; set; }
+ public ApplicationOptions Application { get; set; } = new();
+ public McpOptions Mcp { get; set; } = new();
+ public RuntimeOptions Agent { get; set; } = new();
+ public OllamaConfiguration Ollama { get; set; } = new();
+ public PlannerOptions Planner { get; set; } = new();
+ public SandboxOptions Sandbox { get; set; } = new();
+ public FileEditOptions FileEdit { get; set; } = new();
+ public LimitOptions Limits { get; set; } = new();
+ public LocalizationOptions Localization { get; set; } = new();
+ public SkillOptions Skills { get; set; } = new();
+ public LoggingOptions Logging { get; set; } = new();
+}
+
+public sealed class ApplicationOptions { public string Language { get; set; } = "en"; }
+
+public sealed class McpOptions
+{
+ public string Transport { get; set; } = "stdio";
+ public HttpOptions Http { get; set; } = new();
+ public ImageOptions Images { get; set; } = new();
+}
+
+public sealed class HttpOptions { public string Host { get; set; } = "127.0.0.1"; public int Port { get; set; } = 8080; }
+public sealed class ImageOptions { public int MaxCount { get; set; } = 4; public long MaxBytesEach { get; set; } = 10 * 1024 * 1024; public long MaxTotalBytes { get; set; } = 20 * 1024 * 1024; }
+public sealed class RuntimeOptions { public string UserSystemPrompt { get; set; } = "prompts/user-system.md"; public int MaxIterations { get; set; } = 128; public int SameFailureLimit { get; set; } = 5; }
+
+public sealed class OllamaConfiguration
+{
+ public string Endpoint { get; set; } = "http://127.0.0.1:11434";
+ public string Model { get; set; } = string.Empty;
+ public bool Think { get; set; } = true;
+ public string KeepAlive { get; set; } = "5m";
+ public OllamaGenerationOptions Options { get; set; } = new();
+ public Dictionary<string, object?> ExtraOptions { get; set; } = new(StringComparer.Ordinal);
+}
+
+public sealed class OllamaGenerationOptions
+{
+ public int? NumCtx { get; set; }
+ public int? NumKeep { get; set; }
+ public int? NumPredict { get; set; }
+ public double? Temperature { get; set; }
+ public int? Seed { get; set; }
+ public int? TopK { get; set; }
+ public double? TopP { get; set; }
+ public double? MinP { get; set; }
+ public double? TypicalP { get; set; }
+ public double? TfsZ { get; set; }
+ public int? RepeatLastN { get; set; }
+ public double? RepeatPenalty { get; set; }
+ public double? PresencePenalty { get; set; }
+ public double? FrequencyPenalty { get; set; }
+ public int? Mirostat { get; set; }
+ public double? MirostatTau { get; set; }
+ public double? MirostatEta { get; set; }
+ public List<string> Stop { get; set; } = [];
+}
+
+public sealed class PlannerOptions { public int MaxTasks { get; set; } = 32; }
+public sealed class SandboxOptions { public FilesystemOptions Filesystem { get; set; } = new(); public ProcessOptions Process { get; set; } = new(); public NetworkOptions Network { get; set; } = new(); public ResourceOptions Resources { get; set; } = new(); }
+public sealed class FilesystemOptions { public List<string> Read { get; set; } = []; public List<string> Write { get; set; } = []; public List<string> Execute { get; set; } = []; public List<string> Delete { get; set; } = []; }
+public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List<ExecutableApproval> Approvals { get; set; } = []; public int MaxProcesses { get; set; } = 16; }
+public sealed class ExecutableApproval { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; }
+public sealed class NetworkOptions { public string Mode { get; set; } = "deny"; public List<NetworkAllowance> Allow { get; set; } = []; }
+public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List<int> Ports { get; set; } = []; }
+public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; }
+public sealed class FileEditOptions { public int MemoryBackupLimitMb { get; set; } = 16; }
+public sealed class LimitOptions { public long WholeReadBytes { get; set; } = 1024 * 1024; public int SearchMaxResults { get; set; } = 1000; public int TailMaxLines { get; set; } = 10000; public double ContextSummaryRatio { get; set; } = 0.8; }
+public sealed class LocalizationOptions { public string Directory { get; set; } = "config/locales"; public string Fallback { get; set; } = "en"; }
+public sealed class SkillOptions { public string Directory { get; set; } = "skills"; }
+public sealed class LoggingOptions { public string Directory { get; set; } = "logs"; public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; public int RetainedFiles { get; set; } = 5; }
diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs
new file mode 100644
index 0000000..67f38a5
--- /dev/null
+++ b/src/Agent.App/Configuration/ConfigurationException.cs
@@ -0,0 +1,4 @@
+namespace Agent.App.Configuration;
+
+public sealed class ConfigurationException(string message, Exception? innerException = null)
+ : Exception(message, innerException);
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
new file mode 100644
index 0000000..b6c4c2d
--- /dev/null
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -0,0 +1,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();
+}
diff --git a/src/Agent.App/Configuration/OllamaSettingsFactory.cs b/src/Agent.App/Configuration/OllamaSettingsFactory.cs
new file mode 100644
index 0000000..cd71139
--- /dev/null
+++ b/src/Agent.App/Configuration/OllamaSettingsFactory.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using Agent.Core.Ollama;
+
+namespace Agent.App.Configuration;
+
+public static class OllamaSettingsFactory
+{
+ public static OllamaSettings Create(OllamaConfiguration configuration)
+ {
+ var options = configuration.ExtraOptions.ToDictionary(
+ static pair => pair.Key,
+ static pair => JsonSerializer.SerializeToElement(pair.Value, pair.Value?.GetType() ?? typeof(object)),
+ StringComparer.Ordinal);
+ Set(options, "num_ctx", configuration.Options.NumCtx);
+ Set(options, "num_keep", configuration.Options.NumKeep);
+ Set(options, "num_predict", configuration.Options.NumPredict);
+ Set(options, "temperature", configuration.Options.Temperature);
+ Set(options, "seed", configuration.Options.Seed);
+ Set(options, "top_k", configuration.Options.TopK);
+ Set(options, "top_p", configuration.Options.TopP);
+ Set(options, "min_p", configuration.Options.MinP);
+ Set(options, "typical_p", configuration.Options.TypicalP);
+ Set(options, "tfs_z", configuration.Options.TfsZ);
+ Set(options, "repeat_last_n", configuration.Options.RepeatLastN);
+ Set(options, "repeat_penalty", configuration.Options.RepeatPenalty);
+ Set(options, "presence_penalty", configuration.Options.PresencePenalty);
+ Set(options, "frequency_penalty", configuration.Options.FrequencyPenalty);
+ Set(options, "mirostat", configuration.Options.Mirostat);
+ Set(options, "mirostat_tau", configuration.Options.MirostatTau);
+ Set(options, "mirostat_eta", configuration.Options.MirostatEta);
+ if (configuration.Options.Stop.Count > 0) options["stop"] = JsonSerializer.SerializeToElement(configuration.Options.Stop);
+ return new OllamaSettings(new Uri(configuration.Endpoint, UriKind.Absolute), configuration.Model, configuration.Think, configuration.KeepAlive, options);
+ }
+
+ private static void Set<T>(Dictionary<string, JsonElement> options, string key, T? value) where T : struct
+ {
+ if (value is not null) options[key] = JsonSerializer.SerializeToElement(value.Value);
+ }
+}
diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
new file mode 100644
index 0000000..b7cbd26
--- /dev/null
+++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using Agent.Core.Ollama;
+using Agent.Core.Runtime;
+using Agent.Core.Tools;
+
+namespace Agent.App.Logging;
+
+public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver
+{
+ private readonly object _sync = new();
+ private readonly string _path = Path.Combine(logDirectory, "llm.jsonl");
+
+ public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) => Write(new
+ {
+ timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration,
+ messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName })
+ });
+
+ public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new
+ {
+ timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration,
+ result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens,
+ tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? []
+ });
+
+ public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new
+ {
+ timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName,
+ result.Success, result.ExternalEffect, result.Output, result.FailureFingerprint
+ });
+
+ private void Write(object value)
+ {
+ string line = JsonSerializer.Serialize(value);
+ lock (_sync) File.AppendAllText(_path, line + Environment.NewLine);
+ }
+}
diff --git a/src/Agent.App/Logging/RollingFileLog.cs b/src/Agent.App/Logging/RollingFileLog.cs
new file mode 100644
index 0000000..801768d
--- /dev/null
+++ b/src/Agent.App/Logging/RollingFileLog.cs
@@ -0,0 +1,55 @@
+using System.Globalization;
+
+namespace Agent.App.Logging;
+
+public sealed class RollingFileLog : IDisposable
+{
+ private readonly object _sync = new();
+ private readonly string _path;
+ private readonly long _maxBytes;
+ private readonly int _retainedFiles;
+ private StreamWriter _writer;
+
+ public RollingFileLog(string directory, long maxBytes, int retainedFiles)
+ {
+ Directory.CreateDirectory(directory);
+ _path = Path.Combine(directory, "application.log");
+ _maxBytes = maxBytes;
+ _retainedFiles = retainedFiles;
+ _writer = Open();
+ }
+
+ public void Information(string message) => Write("INF", message);
+ public void Error(string message) => Write("ERR", message);
+
+ private void Write(string level, string message)
+ {
+ lock (_sync)
+ {
+ if (_writer.BaseStream.Length >= _maxBytes) Rotate();
+ string line = string.Create(CultureInfo.InvariantCulture, $"{DateTimeOffset.UtcNow:O} [{level}] {message}");
+ Console.Error.WriteLine(line);
+ _writer.WriteLine(line);
+ _writer.Flush();
+ }
+ }
+
+ private StreamWriter Open() => new(new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read), new System.Text.UTF8Encoding(false));
+
+ private void Rotate()
+ {
+ _writer.Dispose();
+ for (int index = _retainedFiles - 1; index >= 1; index--)
+ {
+ string source = $"{_path}.{index}";
+ if (File.Exists(source)) File.Move(source, $"{_path}.{index + 1}", true);
+ }
+ if (File.Exists(_path)) File.Move(_path, $"{_path}.1", true);
+ _writer = Open();
+ }
+
+ public void Dispose()
+ {
+ lock (_sync) _writer.Dispose();
+ }
+}
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
new file mode 100644
index 0000000..3fae93f
--- /dev/null
+++ b/src/Agent.App/Program.cs
@@ -0,0 +1,130 @@
+using Agent.App.Cli;
+using Agent.App.Configuration;
+using Agent.App.Logging;
+using Agent.Core.Localization;
+using Agent.Core.Prompts;
+using Agent.Mcp;
+using Agent.Core.Runtime;
+using Agent.Windows.Diagnostics;
+using Agent.Core.Tools;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+using Agent.Windows.Tools;
+
+namespace Agent.App;
+
+internal static class Program
+{
+ public static async Task<int> Main(string[] args)
+ {
+ try
+ {
+ CommandLine commandLine = CommandLine.Parse(args);
+ string configPath = commandLine.ConfigPath is null
+ ? Path.Combine(AppContext.BaseDirectory, "agent.yaml")
+ : Path.GetFullPath(commandLine.ConfigPath);
+ AgentConfiguration configuration = ConfigurationLoader.Load(configPath);
+ if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport;
+ ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!);
+
+ string language = commandLine.Language ?? configuration.Application.Language ?? "en";
+ string baseDirectory = Path.GetDirectoryName(configPath)!;
+ string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory);
+ LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback);
+ _ = FixedPrompt.Load();
+
+ string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory);
+ UserOnlyDirectory.Ensure(logDirectory);
+ using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
+ if (commandLine.Command == AppCommand.Check)
+ {
+ WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run();
+ Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}");
+ Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}");
+ Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}");
+ Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}");
+ if (!platform.Success)
+ {
+ string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}";
+ string failure = locale.Format("check.failure", new Dictionary<string, object?> { ["message"] = detail });
+ log.Error(failure);
+ Console.Error.WriteLine(failure);
+ return 2;
+ }
+ log.Information(locale.Get("check.success"));
+ Console.Out.WriteLine(locale.Get("check.success"));
+ return 0;
+ }
+
+ log.Information(locale.Get("app.starting"));
+ InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory);
+ var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory);
+ if (configuration.Mcp.Transport == "stdio")
+ {
+ string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
+ string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
+ string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray();
+ var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
+ var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
+ await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false);
+ return 0;
+ }
+ if (configuration.Mcp.Transport == "http")
+ {
+ string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
+ string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
+ string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray();
+ var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
+ var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
+ await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits,
+ configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false);
+ return 0;
+ }
+ throw new ConfigurationException("Unsupported MCP transport.");
+ }
+ catch (Exception ex) when (ex is CommandLineException or ConfigurationException or LocaleException or IOException or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine($"agent-mcp: {ex.Message}");
+ return 2;
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ Console.Error.WriteLine($"agent-mcp: [APP-UNHANDLED] {ex.Message}");
+ return 2;
+ }
+ }
+
+ private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory)
+ {
+ string stateDirectory = Path.Combine(baseDirectory, "state");
+ string journalDirectory = Path.Combine(stateDirectory, "journals");
+ string backupDirectory = Path.Combine(stateDirectory, "backups");
+ Directory.CreateDirectory(journalDirectory);
+ Directory.CreateDirectory(backupDirectory);
+ var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
+ RecoveryResult recovery = new RecoveryManager(journalDirectory, audit).RecoverAll();
+ if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors));
+ var fileEntries = new List<FilePolicyEntry>();
+ Add(configuration.Sandbox.Filesystem.Read, FileCapability.Read);
+ Add(configuration.Sandbox.Filesystem.Write, FileCapability.Write);
+ Add(configuration.Sandbox.Filesystem.Execute, FileCapability.Execute);
+ Add(configuration.Sandbox.Filesystem.Delete, FileCapability.Delete);
+ var settings = new WindowsSandboxSettings(
+ fileEntries,
+ configuration.Sandbox.Process.Approvals.Select(approval => new Agent.Windows.Security.ExecutableApproval(approval.Path, approval.Sha256)).ToArray(),
+ configuration.Sandbox.Process.AllowShell,
+ new SandboxJobLimits((uint)configuration.Sandbox.Process.MaxProcesses, checked((ulong)configuration.Sandbox.Resources.MemoryMb * 1024 * 1024)),
+ configuration.Sandbox.Network.Mode,
+ configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
+ journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
+ configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines);
+ var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks));
+ var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
+ return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
+
+ void Add(IEnumerable<string> paths, FileCapability capability)
+ {
+ foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability));
+ }
+ }
+}