Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Spawns a background sub-agent that runs an isolated read-only agent loop.
/// The parent agent can later collect all finished results with wait_agents.
/// </summary>
public class SubAgentTool : IAgentTool
{
public static event Action<SubAgentStatusEvent>? StatusChanged;
public string Name => "spawn_agent";
public string Description =>
"Create a read-only sub-agent for bounded parallel research or codebase analysis.\n" +
"Use this when a side task can run independently while the main agent continues.\n" +
"The sub-agent can inspect files, search code, review diffs, and summarize findings.\n" +
"Collect results later with wait_agents.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["task"] = new ToolProperty
{
Type = "string",
Description = "The self-contained task for the sub-agent."
},
["id"] = new ToolProperty
{
Type = "string",
Description = "A unique sub-agent identifier used by wait_agents."
},
},
Required = new() { "task", "id" }
};
private static readonly Dictionary<string, SubAgentTask> _activeTasks = new();
private static readonly object _lock = new();
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
CleanupStale();
var app = System.Windows.Application.Current as App;
var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 3;
lock (_lock)
{
if (_activeTasks.ContainsKey(id))
return Task.FromResult(ToolResult.Fail($"Sub-agent id already exists: {id}"));
var running = _activeTasks.Values.Count(x => x.CompletedAt == null);
if (running >= maxAgents)
return Task.FromResult(ToolResult.Fail($"Maximum concurrent sub-agents reached ({maxAgents})."));
}
var subTask = new SubAgentTask
{
Id = id,
Task = task,
StartedAt = DateTime.Now,
};
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
{
try
{
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Completed,
Summary = $"Sub-agent '{id}' completed.",
Result = result,
Timestamp = DateTime.Now,
});
}
catch (Exception ex)
{
subTask.Result = $"Error: {ex.Message}";
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = $"Sub-agent '{id}' failed: {ex.Message}",
Result = subTask.Result,
Timestamp = DateTime.Now,
});
}
finally
{
subTask.CompletedAt = DateTime.Now;
}
}, CancellationToken.None);
lock (_lock)
_activeTasks[id] = subTask;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Started,
Summary = $"Sub-agent '{id}' started.",
Timestamp = DateTime.Now,
});
return Task.FromResult(ToolResult.Ok(
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
{
var settings = CreateSubAgentSettings(parentContext);
using var llm = new LlmService(settings);
using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false);
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = parentContext.ActiveTab,
};
var messages = new List<ChatMessage>
{
new()
{
Role = "system",
Content = BuildSubAgentSystemPrompt(task, parentContext),
},
new()
{
Role = "user",
Content = task,
}
};
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
var eventSummary = SummarizeEvents(loop.Events);
var sb = new StringBuilder();
sb.AppendLine($"[Sub-agent {id}]");
sb.AppendLine($"Task: {task}");
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(eventSummary))
{
sb.AppendLine();
sb.AppendLine("Observed work:");
sb.AppendLine(eventSummary);
}
sb.AppendLine();
sb.AppendLine("Result:");
sb.AppendLine(string.IsNullOrWhiteSpace(finalText) ? "(empty)" : finalText.Trim());
return sb.ToString().TrimEnd();
}
private static SettingsService CreateSubAgentSettings(AgentContext parentContext)
{
var settings = new SettingsService();
settings.Load();
var llm = settings.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = "Deny";
llm.PlanMode = "off";
llm.AgentHooks = new();
llm.ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
llm.DisabledTools = new List<string>
{
"spawn_agent",
"wait_agents",
"file_write",
"file_edit",
"process",
"build_run",
"snippet_runner",
"memory",
"notify",
"open_external",
"user_ask",
"checkpoint",
"diff_preview",
"playbook",
"http_tool",
"clipboard",
"sql_tool",
};
return settings;
}
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
{
var registry = new ToolRegistry();
registry.Register(new FileReadTool());
registry.Register(new GlobTool());
registry.Register(new GrepTool());
registry.Register(new FolderMapTool());
registry.Register(new DocumentReaderTool());
registry.Register(new DevEnvDetectTool());
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new CodeSearchTool());
registry.Register(new CodeReviewTool());
registry.Register(new ProjectRuleTool());
registry.Register(new SkillManagerTool());
registry.Register(new JsonTool());
registry.Register(new RegexTool());
registry.Register(new DiffTool());
registry.Register(new Base64Tool());
registry.Register(new HashTool());
registry.Register(new DateTimeTool());
registry.Register(new MathTool());
registry.Register(new XmlTool());
registry.Register(new MultiReadTool());
registry.Register(new FileInfoTool());
registry.Register(new DocumentReviewTool());
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
return registry;
}
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext)
{
var sb = new StringBuilder();
sb.AppendLine("You are a focused sub-agent for AX Copilot.");
sb.AppendLine("You are running a bounded, read-only investigation.");
sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result.");
sb.AppendLine("Do not ask the user questions.");
sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
sb.AppendLine("Prefer direct evidence from files and tool results over speculation.");
sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing.");
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
sb.AppendLine();
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab);
if (!string.IsNullOrWhiteSpace(workflowHints))
{
sb.AppendLine();
sb.AppendLine("Task-specific guidance:");
sb.AppendLine(workflowHints);
}
sb.AppendLine("Final answer format:");
sb.AppendLine("1. Short conclusion");
sb.AppendLine("2. Files checked");
sb.AppendLine("3. Key evidence");
sb.AppendLine("4. Recommended next action for the main agent");
sb.AppendLine("5. Risks or unknowns");
return sb.ToString().TrimEnd();
}
private static string BuildSubAgentWorkflowHints(string task, string? activeTab)
{
var normalizedTask = task ?? "";
var sb = new StringBuilder();
if (normalizedTask.Contains("review", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("검토", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("리뷰", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Review tasks should name the concrete issue first, then cite the supporting file evidence.");
sb.AppendLine("- Mention missing or weak tests when behavior could regress.");
}
if (normalizedTask.Contains("bug", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("error", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("실패", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("오류", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Bug investigations should identify the most likely root cause and the exact files that support that conclusion.");
sb.AppendLine("- Suggest the smallest safe fix path for the main agent.");
}
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- For code tasks, always mention impacted callers/references if you found any.");
sb.AppendLine("- Call out related tests or note explicitly when tests were not found.");
}
if (normalizedTask.Contains("plan", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("설계", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("계획", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Planning tasks should identify the minimum file set, order of work, and the primary validation step.");
}
return sb.ToString().TrimEnd();
}
private static string SummarizeEvents(IEnumerable<AgentEvent> events)
{
var lines = events
.Where(e => e.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.StepStart)
.TakeLast(12)
.Select(e =>
{
var label = e.Type switch
{
AgentEventType.ToolCall => $"tool:{e.ToolName}",
AgentEventType.ToolResult => $"result:{e.ToolName}",
AgentEventType.StepStart => "step",
_ => e.Type.ToString().ToLowerInvariant()
};
var summary = string.IsNullOrWhiteSpace(e.Summary) ? "" : $" - {e.Summary.Trim()}";
return $"- {label}{summary}";
})
.ToList();
return lines.Count == 0 ? "" : string.Join(Environment.NewLine, lines);
}
public static IReadOnlyDictionary<string, SubAgentTask> ActiveTasks
{
get
{
lock (_lock)
return new Dictionary<string, SubAgentTask>(_activeTasks);
}
}
public static async Task<string> WaitAsync(
IEnumerable<string>? ids = null,
bool completedOnly = false,
CancellationToken ct = default)
{
List<SubAgentTask> tasks;
lock (_lock)
tasks = _activeTasks.Values.ToList();
var requestedIds = ids?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (requestedIds is { Count: > 0 })
tasks = tasks
.Where(t => requestedIds.Contains(t.Id, StringComparer.OrdinalIgnoreCase))
.ToList();
if (tasks.Count == 0)
{
if (requestedIds is { Count: > 0 })
return $"No matching sub-agents found for: {string.Join(", ", requestedIds)}";
return "No active sub-agents.";
}
if (!completedOnly)
await Task.WhenAll(tasks.Where(t => t.RunTask != null).Select(t => t.RunTask!)).WaitAsync(ct);
else
tasks = tasks.Where(t => t.CompletedAt != null).ToList();
if (tasks.Count == 0)
return "No requested sub-agents have completed yet.";
var sb = new StringBuilder();
sb.AppendLine($"Collected {tasks.Count} sub-agent result(s):");
foreach (var task in tasks.OrderBy(t => t.StartedAt))
{
var status = task.Success ? "OK" : "FAIL";
var duration = task.CompletedAt.HasValue
? $"{(task.CompletedAt.Value - task.StartedAt).TotalSeconds:F1}s"
: "running";
sb.AppendLine();
sb.AppendLine($"--- [{status}] {task.Id} ({duration}) ---");
sb.AppendLine(task.Result ?? "(no result)");
}
lock (_lock)
{
foreach (var task in tasks)
_activeTasks.Remove(task.Id);
}
return sb.ToString().TrimEnd();
}
public static void CleanupStale()
{
lock (_lock)
{
var stale = _activeTasks
.Where(kv => kv.Value.CompletedAt.HasValue &&
(DateTime.Now - kv.Value.CompletedAt.Value).TotalMinutes > 10)
.Select(kv => kv.Key)
.ToList();
foreach (var key in stale)
_activeTasks.Remove(key);
}
}
private static void NotifyStatus(SubAgentStatusEvent evt)
{
try { StatusChanged?.Invoke(evt); } catch { }
}
}
public class WaitAgentsTool : IAgentTool
{
public string Name => "wait_agents";
public string Description =>
"Wait for sub-agents and collect their results.\n" +
"You may wait for all sub-agents or only specific ids.\n" +
"Use completed_only=true to collect only already finished sub-agents without blocking.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["ids"] = new ToolProperty
{
Type = "array",
Description = "Optional list of sub-agent ids to collect. Omit to collect all.",
Items = new ToolProperty { Type = "string", Description = "Sub-agent id" }
},
["completed_only"] = new ToolProperty
{
Type = "boolean",
Description = "If true, collect only already completed sub-agents and do not wait."
},
},
Required = new()
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
List<string>? ids = null;
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = idsEl.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
completedEl.ValueKind == JsonValueKind.True;
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
return ToolResult.Ok(result);
}
}
public class SubAgentTask
{
public string Id { get; init; } = "";
public string Task { get; init; } = "";
public DateTime StartedAt { get; init; }
public DateTime? CompletedAt { get; set; }
public bool Success { get; set; }
public string? Result { get; set; }
public Task? RunTask { get; set; }
}
public enum SubAgentRunStatus
{
Started,
Completed,
Failed
}
public class SubAgentStatusEvent
{
public string Id { get; init; } = "";
public string Task { get; init; } = "";
public SubAgentRunStatus Status { get; init; }
public string Summary { get; init; } = "";
public string? Result { get; init; }
public DateTime Timestamp { get; init; } = DateTime.Now;
}