using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
///
/// 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.
///
public class SubAgentTool : IAgentTool
{
public static event Action? 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."
},
["profile"] = new ToolProperty
{
Type = "string",
Description = "Execution profile: researcher (default, read-only), coder (can edit/build), writer (doc creation), reviewer (code review), planner (task decomposition)."
},
},
Required = new() { "task", "id" }
};
private static readonly Dictionary _activeTasks = new();
private static readonly object _lock = new();
public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
var profileName = args.SafeTryGetProperty("profile", out var p) ? p.SafeGetString() : null;
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,
};
// P2: 부모 취소 토큰 연동 — 부모 에이전트 중지 시 자식도 즉시 취소
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
subTask.Cts = cts;
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
{
try
{
var result = await RunSubAgentAsync(id, task, context, profileName, cts.Token).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 (OperationCanceledException)
{
subTask.Result = "Cancelled: parent agent was stopped.";
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = $"Sub-agent '{id}' cancelled.",
Result = subTask.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;
cts.Dispose();
}
}, cts.Token);
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 RunSubAgentAsync(string id, string task, AgentContext parentContext, string? profileName, CancellationToken ct)
{
var profile = SubAgentProfileCatalog.Get(profileName);
var settings = CreateSubAgentSettings(parentContext, profile);
using var llm = new LlmService(settings);
// P2: 프로파일별 temperature override
if (profile.TemperatureOverride.HasValue)
llm.PushInferenceOverride(temperature: profile.TemperatureOverride.Value);
using var tools = await CreateSubAgentRegistryAsync(settings, profile).ConfigureAwait(false);
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = parentContext.ActiveTab,
};
var messages = new List
{
new()
{
Role = "system",
Content = BuildSubAgentSystemPrompt(task, parentContext, profile),
},
new()
{
Role = "user",
Content = task,
}
};
var finalText = await loop.RunAsync(messages, ct).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, SubAgentProfile profile)
{
var settings = new SettingsService();
settings.Load();
var llm = settings.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = profile.FilePermission;
llm.AgentHooks = new();
llm.ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase);
llm.DisabledTools = profile.DisabledToolNames.ToList();
return settings;
}
/// 도구 이름 → 팩토리 매핑 (인스턴스를 필요한 것만 생성).
private static readonly Dictionary> ToolFactories =
new(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = () => new FileReadTool(),
["glob"] = () => new GlobTool(),
["grep"] = () => new GrepTool(),
["folder_map"] = () => new FolderMapTool(),
["document_read"] = () => new DocumentReaderTool(),
["dev_env_detect"] = () => new DevEnvDetectTool(),
["git_tool"] = () => new GitTool(),
["lsp_code_intel"] = () => new LspTool(),
["code_search"] = () => new CodeSearchTool(),
["code_review"] = () => new CodeReviewTool(),
["project_rule"] = () => new ProjectRuleTool(),
["skill_manager"] = () => new SkillManagerTool(),
["json_tool"] = () => new JsonTool(),
["regex_tool"] = () => new RegexTool(),
["diff_tool"] = () => new DiffTool(),
["base64_tool"] = () => new Base64Tool(),
["hash_tool"] = () => new HashTool(),
["datetime_tool"] = () => new DateTimeTool(),
["math_tool"] = () => new MathTool(),
["xml_tool"] = () => new XmlTool(),
["multi_read"] = () => new MultiReadTool(),
["file_info"] = () => new FileInfoTool(),
["document_review"] = () => new DocumentReviewTool(),
// coder 프로파일용
["file_write"] = () => new FileWriteTool(),
["file_edit"] = () => new FileEditTool(),
["build_run"] = () => new BuildRunTool(),
["process"] = () => new ProcessTool(),
["test_loop"] = () => new TestLoopTool(),
["snippet_runner"] = () => new SnippetRunnerTool(),
// writer 프로파일용
["html_create"] = () => new HtmlSkill(),
["docx_create"] = () => new DocxSkill(),
["markdown_create"] = () => new MarkdownSkill(),
["csv_create"] = () => new CsvSkill(),
["excel_create"] = () => new ExcelSkill(),
["pptx_create"] = () => new PptxSkill(),
["document_plan"] = () => new DocumentPlannerTool(),
};
private static async Task CreateSubAgentRegistryAsync(SettingsService settings, SubAgentProfile profile)
{
var registry = new ToolRegistry();
// 필요한 도구만 인스턴스 생성 (기존: 전체 63개 생성 후 필터 → 개선: 필요한 것만 팩토리 호출)
foreach (var name in profile.EnabledToolNames)
{
if (ToolFactories.TryGetValue(name, out var factory))
registry.Register(factory());
}
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
return registry;
}
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext, SubAgentProfile profile)
{
var sb = new StringBuilder();
// P2: 프로파일별 시스템 프롬프트 접두사 사용
sb.AppendLine(profile.SystemPromptPrefix);
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
// P4: 워크스페이스 컨텍스트 자동 주입
var wsContext = WorkspaceContextGenerator.LoadContext(parentContext.WorkFolder);
if (!string.IsNullOrWhiteSpace(wsContext))
{
sb.AppendLine();
sb.AppendLine("Workspace context:");
sb.AppendLine(wsContext.Length > 2000 ? wsContext[..2000] + "\n...(truncated)" : wsContext);
}
sb.AppendLine();
// 프로파일별 작업 규칙
switch (profile.Name)
{
case "coder":
sb.AppendLine("Coding rules:");
sb.AppendLine("1. Read the relevant files first to understand existing patterns.");
sb.AppendLine("2. Make the minimal correct change — do not refactor unrelated code.");
sb.AppendLine("3. After editing, verify with build_run or test_loop.");
sb.AppendLine("4. If the build fails, fix the issue immediately.");
sb.AppendLine("5. Report what was changed and the verification result.");
break;
case "writer":
sb.AppendLine("Document creation rules:");
sb.AppendLine("1. Inspect existing documents or source files for context.");
sb.AppendLine("2. Produce well-structured, complete documents.");
sb.AppendLine("3. Use appropriate formatting for the target format.");
sb.AppendLine("4. Verify file was created successfully.");
break;
case "reviewer":
sb.AppendLine("Review rules:");
sb.AppendLine("1. Start by reading the directly relevant files.");
sb.AppendLine("2. Rate each finding P0 (critical) through P3 (minor).");
sb.AppendLine("3. Prioritize concrete defects, regressions, and missing tests.");
sb.AppendLine("4. Cite exact file paths and line ranges as evidence.");
sb.AppendLine("5. Do not suggest edits — only report findings.");
break;
case "planner":
sb.AppendLine("Planning rules:");
sb.AppendLine("1. Inspect the codebase to understand the current architecture.");
sb.AppendLine("2. Decompose the task into ordered steps with clear dependencies.");
sb.AppendLine("3. Identify the minimum file set for each step.");
sb.AppendLine("4. Highlight the primary risk for each step.");
sb.AppendLine("5. Suggest a validation strategy.");
break;
default: // researcher
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.");
break;
}
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 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 ActiveTasks
{
get
{
lock (_lock)
return new Dictionary(_activeTasks);
}
}
public static async Task WaitAsync(
IEnumerable? ids = null,
bool completedOnly = false,
CancellationToken ct = default)
{
List 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 ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
List? ids = null;
if (args.SafeTryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = idsEl.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.SafeGetString() ?? "")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
var completedOnly = args.SafeTryGetProperty("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 CancellationTokenSource? Cts { 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;
}