From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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まで生成可能にした。 --- src/Agent.Planner/Agent.Planner.csproj | 1 + src/Agent.Planner/AssemblyMarker.cs | 3 + src/Agent.Planner/PlannerTask.cs | 18 +++ src/Agent.Planner/PlannerTaskState.cs | 11 ++ src/Agent.Planner/PlannerValidationException.cs | 3 + src/Agent.Planner/TaskGraph.cs | 179 ++++++++++++++++++++++++ src/Agent.Planner/TokenBudgetClass.cs | 31 ++++ src/Agent.Planner/TokenEstimate.cs | 15 ++ 8 files changed, 261 insertions(+) create mode 100644 src/Agent.Planner/Agent.Planner.csproj create mode 100644 src/Agent.Planner/AssemblyMarker.cs create mode 100644 src/Agent.Planner/PlannerTask.cs create mode 100644 src/Agent.Planner/PlannerTaskState.cs create mode 100644 src/Agent.Planner/PlannerValidationException.cs create mode 100644 src/Agent.Planner/TaskGraph.cs create mode 100644 src/Agent.Planner/TokenBudgetClass.cs create mode 100644 src/Agent.Planner/TokenEstimate.cs (limited to 'src/Agent.Planner') diff --git a/src/Agent.Planner/Agent.Planner.csproj b/src/Agent.Planner/Agent.Planner.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/Agent.Planner/Agent.Planner.csproj @@ -0,0 +1 @@ + diff --git a/src/Agent.Planner/AssemblyMarker.cs b/src/Agent.Planner/AssemblyMarker.cs new file mode 100644 index 0000000..e90784d --- /dev/null +++ b/src/Agent.Planner/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public static class AssemblyMarker; diff --git a/src/Agent.Planner/PlannerTask.cs b/src/Agent.Planner/PlannerTask.cs new file mode 100644 index 0000000..edc85c2 --- /dev/null +++ b/src/Agent.Planner/PlannerTask.cs @@ -0,0 +1,18 @@ +namespace Agent.Planner; + +public sealed record PlannerTask( + Guid Id, + string Description, + IReadOnlySet Dependencies, + PlannerTaskState State, + TokenBudgetClass BudgetClass, + TokenEstimate Estimate, + long ActualInputTokens, + long ActualOutputTokens, + bool RequiresExternalEffect) +{ + public double ExpectedTokens => Estimate.ExpectedTokens; + public long ActualTotalTokens => checked(ActualInputTokens + ActualOutputTokens); +} + +public sealed record CriticalPathResult(IReadOnlyList TaskIds, double CumulativeTokenCost); diff --git a/src/Agent.Planner/PlannerTaskState.cs b/src/Agent.Planner/PlannerTaskState.cs new file mode 100644 index 0000000..d0b79bf --- /dev/null +++ b/src/Agent.Planner/PlannerTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Planner; + +public enum PlannerTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Planner/PlannerValidationException.cs b/src/Agent.Planner/PlannerValidationException.cs new file mode 100644 index 0000000..934d549 --- /dev/null +++ b/src/Agent.Planner/PlannerValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public sealed class PlannerValidationException(string message) : InvalidOperationException(message); diff --git a/src/Agent.Planner/TaskGraph.cs b/src/Agent.Planner/TaskGraph.cs new file mode 100644 index 0000000..7a4d45b --- /dev/null +++ b/src/Agent.Planner/TaskGraph.cs @@ -0,0 +1,179 @@ +namespace Agent.Planner; + +public sealed class TaskGraph(int maxTasks = 32) +{ + private readonly Dictionary _tasks = []; + private readonly List _insertionOrder = []; + private CriticalPathResult _criticalPath = new([], 0); + + public int MaxTasks { get; } = maxTasks > 0 ? maxTasks : throw new ArgumentOutOfRangeException(nameof(maxTasks)); + public int Count => _tasks.Count; + public CriticalPathResult CriticalPath => _criticalPath; + public long TotalTokenBudget => _tasks.Values.Aggregate(0L, static (total, task) => checked(total + task.BudgetClass.MaxTokens())); + public IReadOnlyList Tasks => _insertionOrder.Select(id => _tasks[id].Snapshot()).ToArray(); + + public PlannerTask AddTask( + string description, + TokenBudgetClass budgetClass, + TokenEstimate estimate, + IEnumerable? dependencies = null, + bool requiresExternalEffect = false, + Guid? id = null) + { + if (string.IsNullOrWhiteSpace(description)) throw new PlannerValidationException("Task description is required."); + if (_tasks.Count >= MaxTasks) throw new PlannerValidationException($"Task limit of {MaxTasks} exceeded."); + estimate.Validate(budgetClass); + Guid taskId = id ?? Guid.NewGuid(); + if (_tasks.ContainsKey(taskId)) throw new PlannerValidationException($"Task '{taskId}' already exists."); + HashSet dependencySet = dependencies?.ToHashSet() ?? []; + EnsureDependenciesExist(dependencySet); + if (dependencySet.Contains(taskId)) throw new PlannerValidationException("A task cannot depend on itself."); + + _tasks.Add(taskId, new MutableTask(taskId, description, dependencySet, budgetClass, estimate, requiresExternalEffect)); + _insertionOrder.Add(taskId); + try + { + Recalculate(); + } + catch + { + _tasks.Remove(taskId); + _insertionOrder.Remove(taskId); + throw; + } + return _tasks[taskId].Snapshot(); + } + + public void AddDependency(Guid taskId, Guid dependencyId) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + _ = GetMutable(dependencyId); + if (!task.Dependencies.Add(dependencyId)) return; + try + { + Recalculate(); + } + catch + { + task.Dependencies.Remove(dependencyId); + throw; + } + } + + public void SetEstimate(Guid taskId, TokenBudgetClass budgetClass, TokenEstimate estimate) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + estimate.Validate(budgetClass); + (TokenBudgetClass previousBudget, TokenEstimate previousEstimate) = (task.BudgetClass, task.Estimate); + task.BudgetClass = budgetClass; + task.Estimate = estimate; + try { Recalculate(); } + catch + { + task.BudgetClass = previousBudget; + task.Estimate = previousEstimate; + throw; + } + } + + public void SetState(Guid taskId, PlannerTaskState state) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.State = state; + } + + public void AddUsage(Guid taskId, long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) throw new ArgumentOutOfRangeException(nameof(inputTokens), "Token usage cannot be negative."); + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.ActualInputTokens = checked(task.ActualInputTokens + inputTokens); + task.ActualOutputTokens = checked(task.ActualOutputTokens + outputTokens); + } + + public PlannerTask Get(Guid taskId) => GetMutable(taskId).Snapshot(); + + private void Recalculate() + { + IReadOnlyList order = TopologicalOrder(); + var costs = new Dictionary(); + var predecessors = new Dictionary(); + foreach (Guid id in order) + { + MutableTask task = _tasks[id]; + Guid? best = null; + double bestCost = 0; + foreach (Guid dependency in task.Dependencies.OrderBy(InsertionIndex)) + { + double candidate = costs[dependency]; + if (best is null || candidate > bestCost) + { + best = dependency; + bestCost = candidate; + } + } + costs[id] = bestCost + task.Estimate.ExpectedTokens; + predecessors[id] = best; + } + if (order.Count == 0) + { + _criticalPath = new([], 0); + return; + } + Guid end = order[0]; + foreach (Guid id in order.Skip(1)) if (costs[id] > costs[end]) end = id; + var path = new List(); + for (Guid? current = end; current is not null; current = predecessors[current.Value]) path.Add(current.Value); + path.Reverse(); + _criticalPath = new(path, costs[end]); + } + + private IReadOnlyList TopologicalOrder() + { + var indegree = _tasks.ToDictionary(static pair => pair.Key, static pair => pair.Value.Dependencies.Count); + var ready = new SortedSet(_insertionOrder.Select((id, index) => (id, index)).Where(item => indegree[item.id] == 0).Select(static item => item.index)); + var result = new List(_tasks.Count); + while (ready.Count > 0) + { + int index = ready.Min; + ready.Remove(index); + Guid id = _insertionOrder[index]; + result.Add(id); + foreach (Guid dependent in _insertionOrder.Where(candidate => _tasks[candidate].Dependencies.Contains(id))) + { + indegree[dependent]--; + if (indegree[dependent] == 0) ready.Add(InsertionIndex(dependent)); + } + } + if (result.Count != _tasks.Count) throw new PlannerValidationException("TaskGraph must be a DAG; the update creates a cycle."); + return result; + } + + private int InsertionIndex(Guid id) => _insertionOrder.IndexOf(id); + private MutableTask GetMutable(Guid id) => _tasks.TryGetValue(id, out MutableTask? task) ? task : throw new KeyNotFoundException($"Task '{id}' was not found."); + private void EnsureDependenciesExist(IEnumerable dependencies) + { + foreach (Guid dependency in dependencies) _ = GetMutable(dependency); + } + private static void EnsureMutable(MutableTask task) + { + if (task.State == PlannerTaskState.Completed) throw new PlannerValidationException("Completed tasks are immutable."); + } + + private sealed class MutableTask(Guid id, string description, HashSet dependencies, TokenBudgetClass budgetClass, TokenEstimate estimate, bool requiresExternalEffect) + { + public Guid Id { get; } = id; + public string Description { get; } = description; + public HashSet Dependencies { get; } = dependencies; + public PlannerTaskState State { get; set; } = PlannerTaskState.Queued; + public TokenBudgetClass BudgetClass { get; set; } = budgetClass; + public TokenEstimate Estimate { get; set; } = estimate; + public long ActualInputTokens { get; set; } + public long ActualOutputTokens { get; set; } + public bool RequiresExternalEffect { get; } = requiresExternalEffect; + public PlannerTask Snapshot() => new(Id, Description, Dependencies.ToHashSet(), State, BudgetClass, Estimate, ActualInputTokens, ActualOutputTokens, RequiresExternalEffect); + } +} diff --git a/src/Agent.Planner/TokenBudgetClass.cs b/src/Agent.Planner/TokenBudgetClass.cs new file mode 100644 index 0000000..35894c7 --- /dev/null +++ b/src/Agent.Planner/TokenBudgetClass.cs @@ -0,0 +1,31 @@ +namespace Agent.Planner; + +public enum TokenBudgetClass +{ + K1 = 1_024, + K2 = 2_048, + K4 = 4_096, + K8 = 8_192, + K16 = 16_384, + K32 = 32_768, + K64 = 65_536, + K128 = 131_072 +} + +public static class TokenBudgetClassExtensions +{ + public static long MaxTokens(this TokenBudgetClass value) => (long)value; + + public static TokenBudgetClass Parse(string value) => value switch + { + "1K" => TokenBudgetClass.K1, + "2K" => TokenBudgetClass.K2, + "4K" => TokenBudgetClass.K4, + "8K" => TokenBudgetClass.K8, + "16K" => TokenBudgetClass.K16, + "32K" => TokenBudgetClass.K32, + "64K" => TokenBudgetClass.K64, + "128K" => TokenBudgetClass.K128, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown token budget class.") + }; +} diff --git a/src/Agent.Planner/TokenEstimate.cs b/src/Agent.Planner/TokenEstimate.cs new file mode 100644 index 0000000..4b8920a --- /dev/null +++ b/src/Agent.Planner/TokenEstimate.cs @@ -0,0 +1,15 @@ +namespace Agent.Planner; + +public readonly record struct TokenEstimate(long Optimistic, long MostLikely, long Pessimistic) +{ + public double ExpectedTokens => (Optimistic + (4d * MostLikely) + Pessimistic) / 6d; + public double VarianceTokens => Math.Pow((Pessimistic - Optimistic) / 6d, 2); + + public void Validate(TokenBudgetClass budgetClass) + { + if (Optimistic <= 0 || Optimistic > MostLikely || MostLikely > Pessimistic || Pessimistic > budgetClass.MaxTokens()) + { + throw new PlannerValidationException("Estimate must satisfy 0 < O <= M <= P <= token budget."); + } + } +} -- cgit v1.2.3