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/TaskGraph.cs | 179 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/Agent.Planner/TaskGraph.cs (limited to 'src/Agent.Planner/TaskGraph.cs') 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); + } +} -- cgit v1.2.3