에이전트 선택적 탐색 구조 개선과 경고 정리 반영
Some checks failed
Release Gate / gate (push) Has been cancelled

- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
2026-04-09 14:27:59 +09:00
parent 7931566212
commit 33c1db4dae
119 changed files with 4453 additions and 6943 deletions

View File

@@ -27,6 +27,13 @@ public partial class AgentLoopService
private readonly ToolRegistry _tools;
private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
// P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지
private static readonly JsonSerializerOptions s_jsonOpts = new()
{
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
@@ -168,6 +175,11 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
var explorationState = new ExplorationTrackingState
{
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
SelectiveHit = true,
};
// 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
@@ -229,6 +241,17 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
EmitEvent(
AgentEventType.Thinking,
"",
explorationState.Scope switch
{
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
_ => "점진 탐색 · 필요한 범위부터 확인하는 중",
});
if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
@@ -530,12 +553,15 @@ public partial class AgentLoopService
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
// LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew();
try
{
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
var activeTools = cachedActiveTools;
if (activeTools.Count == 0)
{
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
@@ -814,7 +840,7 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var activeToolPreview = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -857,7 +883,7 @@ public partial class AgentLoopService
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var planToolList = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -1058,7 +1084,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = textResponse });
foreach (var tc in toolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
@@ -1136,15 +1162,16 @@ public partial class AgentLoopService
if (toolCalls.Count == 0) continue;
}
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
var activeToolNames = cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var call in toolCalls)
{
if (ct.IsCancellationRequested) break;
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var globallyRegisteredTool = _tools.Get(resolvedToolName);
if (globallyRegisteredTool != null &&
@@ -1305,6 +1332,28 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
FormatToolCallSummary(effectiveCall));
var toolInputJson = effectiveCall.ToolInput?.ToString() ?? "{}";
TrackExplorationToolUse(explorationState, effectiveCall.ToolName, toolInputJson);
if (!explorationState.CorrectiveHintInjected &&
ShouldInjectExplorationCorrection(explorationState, effectiveCall.ToolName, toolInputJson))
{
explorationState.CorrectiveHintInjected = true;
explorationState.BroadScanDetected = true;
explorationState.SelectiveHit = false;
messages.Add(new ChatMessage
{
Role = "system",
Content =
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " +
"Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " +
"Do not repeat folder_map unless repository structure is genuinely required."
});
EmitEvent(
AgentEventType.Thinking,
"",
"탐색 범위를 좁히는 중 · 관련 파일만 다시 선택합니다");
}
var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages);
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
return decisionTransition.TerminalResponse;
@@ -1412,7 +1461,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }, s_jsonOpts),
"cancelled",
success: false);
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
@@ -1613,7 +1662,8 @@ public partial class AgentLoopService
llm,
executionPolicy,
context,
ct);
ct,
documentPlanCalled);
if (terminalCompleted)
{
if (consumedExtraIteration)
@@ -1664,7 +1714,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }, s_jsonOpts),
"cancelled",
success: false);
}
@@ -1699,6 +1749,20 @@ public partial class AgentLoopService
// 통계 기록 (도구 호출이 1회 이상인 세션만)
if (totalToolCalls > 0)
{
AgentPerformanceLogService.LogExplorationBreadth(
_conversationId,
ActiveTab,
new
{
scope = explorationState.Scope.ToString().ToLowerInvariant(),
folder_map_calls = explorationState.FolderMapCalls,
total_files_read = explorationState.TotalFilesRead,
multi_read_files = explorationState.MultiReadFilesRead,
broad_scan = explorationState.BroadScanDetected,
selective_hit = explorationState.SelectiveHit,
corrective_hint = explorationState.CorrectiveHintInjected
});
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
@@ -2985,11 +3049,11 @@ public partial class AgentLoopService
try
{
using var doc = JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameProp))
if (doc.RootElement.SafeTryGetProperty("tool_name", out var toolNameProp))
{
toolName = toolNameProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("content", out var contentProp))
content = contentProp.GetString() ?? "";
toolName = toolNameProp.SafeGetString() ?? "";
if (doc.RootElement.SafeTryGetProperty("content", out var contentProp))
content = contentProp.SafeGetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
}
@@ -4092,7 +4156,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = verifyResponse });
foreach (var tc in verifyToolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
messages.Add(assistantMsg);
addedMessages.Add(assistantMsg);
@@ -4253,9 +4317,9 @@ public partial class AgentLoopService
{
foreach (var name in names)
{
if (inputElement.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
if (inputElement.SafeTryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
var value = prop.GetString();
var value = prop.SafeGetString();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
@@ -4297,8 +4361,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_write"))
{
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString() ?? ""
var content = input.SafeTryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.SafeGetString() ?? ""
: "";
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
string? previous = null;
@@ -4324,17 +4388,17 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_edit"))
{
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
if (input.SafeTryGetProperty("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 oldText = edit.SafeTryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.SafeGetString() ?? ""
: "";
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.GetString() ?? ""
var newText = edit.SafeTryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.SafeGetString() ?? ""
: "";
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
@@ -4352,8 +4416,8 @@ public partial class AgentLoopService
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
var command = input.SafeTryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "command",
@@ -4364,8 +4428,8 @@ public partial class AgentLoopService
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
var url = input.SafeTryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "web",
@@ -4636,10 +4700,25 @@ public partial class AgentLoopService
};
if (Dispatcher != null)
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
{
Dispatcher(() =>
{
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
EventOccurred?.Invoke(evt);
});
}
else
{
Events.Add(evt);
// Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
lock (Events)
{
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
}
EventOccurred?.Invoke(evt);
}
}
@@ -4655,10 +4734,10 @@ public partial class AgentLoopService
// Git 커밋 — 수준에 관계없이 무조건 확인
if (toolName == "git_tool")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
if (action == "commit")
{
var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : "";
var msg = input?.SafeTryGetProperty("args", out var m) == true ? m.SafeGetString() : "";
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
}
}
@@ -4669,7 +4748,7 @@ public partial class AgentLoopService
// process 도구 (외부 명령 실행)
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
return null;
@@ -4681,14 +4760,14 @@ public partial class AgentLoopService
// 외부 명령 실행
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
// 새 파일 생성
if (toolName == "file_write")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
if (!string.IsNullOrEmpty(path))
{
var fullPath = System.IO.Path.IsPathRooted(path) ? path
@@ -4706,15 +4785,15 @@ public partial class AgentLoopService
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return null;
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
var path = (input?.SafeTryGetProperty("path", out var p1) == true ? p1.SafeGetString() : null)
?? (input?.SafeTryGetProperty("file_path", out var p2) == true ? p2.SafeGetString() : "");
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
// 빌드/테스트 실행
if (toolName is "build_run" or "test_loop")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
}
}
@@ -4724,7 +4803,7 @@ public partial class AgentLoopService
{
if (toolName is "file_write" or "file_edit")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
}
}
@@ -4739,12 +4818,12 @@ public partial class AgentLoopService
{
// 주요 파라미터만 표시
var input = call.ToolInput.Value;
if (input.TryGetProperty("path", out var path))
return $"{call.ToolName}: {path.GetString()}";
if (input.TryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.GetString()}";
if (input.TryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.GetString()}";
if (input.SafeTryGetProperty("path", out var path))
return $"{call.ToolName}: {path.SafeGetString()}";
if (input.SafeTryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.SafeGetString()}";
if (input.SafeTryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.SafeGetString()}";
return call.ToolName;
}
catch { return call.ToolName; }