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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user