AX Agent MCP 명령 확장 및 에이전트 복구/슬래시 UX 보강

- /mcp 하위 명령 확장: add/remove/reset 지원, 도움말/상태 문구 동기화

- add 파서 추가: stdio(command+args), sse(url) 형식 검증 및 중복 서버명 방지

- remove all/단건 및 reset(세션 MCP 오버라이드 초기화) 실행 경로 구현

- Agentic loop 복구 프롬프트 강화: 미등록/비허용 도구 상황에서 tool_search 우선 가이드 적용

- 반복 실패 중단 응답에 재시도 루트 명시로 루프 복구 가능성 개선

- 슬래시 팝업 힌트 밀도 개선: agentUiExpressionLevel(rich/balanced/simple) 연동

- 테스트 보강: ChatWindowSlashPolicyTests(/mcp add/remove/reset, add 파서, 토크나이저), AgentLoopCodeQualityTests(tool_search 복구 가이드)

- 문서 반영: docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md에 2026-04-04 추가 진행 이력 기록
This commit is contained in:
2026-04-04 01:20:34 +09:00
parent 1256fdc43f
commit 52e9e34ade
6 changed files with 420 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json;
using AxCopilot.Models;
@@ -14,10 +15,17 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
internal const string ApprovedPlanDecisionPrefix = "[PLAN_APPROVED_STEPS]";
public sealed record PermissionPromptPreview(
string Kind,
string Title,
string Summary,
string Content,
string? PreviousContent = null);
private readonly LlmService _llm;
private readonly ToolRegistry _tools;
private readonly SettingsService _settings;
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
public ObservableCollection<AgentEvent> Events { get; } = new();
@@ -66,6 +74,22 @@ public partial class AgentLoopService
_settings = settings;
}
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
{
preview = null;
var key = BuildPermissionPreviewKey(toolName, target);
if (string.IsNullOrWhiteSpace(key))
return false;
if (_pendingPermissionPreviews.TryGetValue(key, out var value))
{
preview = value;
return true;
}
return false;
}
/// <summary>
/// 에이전트 루프를 일시정지합니다.
/// 다음 반복 시작 시점에서 대기 상태가 됩니다.
@@ -414,7 +438,13 @@ public partial class AgentLoopService
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
{
var condensed = await ContextCondenser.CondenseIfNeededAsync(
messages, _llm, llm.MaxContextTokens, ct);
messages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
ct);
if (condensed)
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다");
}
@@ -3474,6 +3504,7 @@ public partial class AgentLoopService
$"- 실패 도구: {unknownToolName}\n" +
aliasHint +
$"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" +
"- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" +
"위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요.";
}
@@ -3493,6 +3524,7 @@ public partial class AgentLoopService
$"- 요청 도구: {requestedToolName}\n" +
policyLine +
$"- 지금 사용 가능한 도구 예시: {activePreview}\n" +
"- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" +
"허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다.";
}
@@ -3506,7 +3538,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedUnknownToolCount}\n" +
$"- 실패 도구: {unknownToolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 위 목록의 실제 도구 이름으로 호출하세요.";
"- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요.";
}
private static string BuildDisallowedToolLoopAbortResponse(
@@ -3519,7 +3551,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedCount}\n" +
$"- 비허용 도구: {toolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 허용 도구만 호출하도록 계획을 수정하세요.";
"- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요.";
}
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
@@ -3541,6 +3573,10 @@ public partial class AgentLoopService
["listfiles"] = "glob",
["list_files"] = "glob",
["grep"] = "grep",
["greptool"] = "grep",
["grep_tool"] = "grep",
["rg"] = "grep",
["ripgrep"] = "grep",
["search"] = "grep",
["globfiles"] = "glob",
["glob_files"] = "glob",
@@ -3552,8 +3588,13 @@ public partial class AgentLoopService
["listmcpresourcestool"] = "mcp_list_resources",
["readmcpresourcetool"] = "mcp_read_resource",
["agent"] = "spawn_agent",
["spawnagent"] = "spawn_agent",
["task"] = "spawn_agent",
["sendmessage"] = "notify_tool",
["shellcommand"] = "process",
["execute"] = "process",
["codesearch"] = "search_codebase",
["code_search"] = "search_codebase",
["powershell"] = "process",
["toolsearch"] = "tool_search",
["todowrite"] = "todo_write",
@@ -4006,6 +4047,99 @@ public partial class AgentLoopService
return primary;
}
private static string BuildPermissionPreviewKey(string toolName, string target)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
return "";
return $"{toolName.Trim()}|{target.Trim()}";
}
private static PermissionPromptPreview? BuildPermissionPreview(string toolName, JsonElement input, string target)
{
var normalizedTool = toolName.Trim().ToLowerInvariant();
if (normalizedTool.Contains("file_write"))
{
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString() ?? ""
: "";
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
string? previous = null;
try
{
if (File.Exists(target))
{
var original = File.ReadAllText(target);
previous = original.Length <= 2000 ? original : original[..2000] + "\n... (truncated)";
}
}
catch
{
previous = null;
}
return new PermissionPromptPreview(
Kind: "file_write",
Title: "Pending file write",
Summary: "The tool will replace or create file content.",
Content: truncated,
PreviousContent: previous);
}
if (normalizedTool.Contains("file_edit"))
{
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
{
var lines = edits.EnumerateArray()
.Take(6)
.Select((edit, index) =>
{
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.GetString() ?? ""
: "";
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.GetString() ?? ""
: "";
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
newText = newText.Length <= 180 ? newText : newText[..180] + "...";
return $"{index + 1}) - {oldText}\n + {newText}";
});
return new PermissionPromptPreview(
Kind: "file_edit",
Title: "Pending file edit",
Summary: $"The tool requested {edits.GetArrayLength()} edit block(s).",
Content: string.Join("\n", lines));
}
}
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
{
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.GetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "command",
Title: "Pending command",
Summary: "The tool wants to run this command.",
Content: command);
}
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
{
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.GetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "web",
Title: "Pending network access",
Summary: "The tool wants to access this URL.",
Content: url);
}
return null;
}
private static bool TryGetHookUpdatedInput(
IEnumerable<HookExecutionResult> results,
out JsonElement updatedInput)
@@ -4161,7 +4295,22 @@ public partial class AgentLoopService
if (PermissionModeCatalog.RequiresUserApproval(effectivePerm))
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요({effectivePerm}) · 대상: {target}");
var allowed = await context.CheckToolPermissionAsync(toolName, target);
var previewKey = BuildPermissionPreviewKey(toolName, target);
var preview = BuildPermissionPreview(toolName, input, target);
if (!string.IsNullOrWhiteSpace(previewKey) && preview != null)
_pendingPermissionPreviews[previewKey] = preview;
bool allowed;
try
{
allowed = await context.CheckToolPermissionAsync(toolName, target);
}
finally
{
if (!string.IsNullOrWhiteSpace(previewKey))
_pendingPermissionPreviews.TryRemove(previewKey, out _);
}
if (allowed)
{
if (PermissionModeCatalog.RequiresUserApproval(effectivePerm))