Initial commit to new repository
This commit is contained in:
498
src/AxCopilot/Services/Agent/SubAgentTool.cs
Normal file
498
src/AxCopilot/Services/Agent/SubAgentTool.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user