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