using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Services.Agent; public partial class AgentLoopService { private static int GetMaxParallelToolConcurrency() { var raw = Environment.GetEnvironmentVariable("AXCOPILOT_MAX_PARALLEL_TOOLS"); if (int.TryParse(raw, out var parsed) && parsed > 0) return Math.Min(parsed, 12); return 4; } // 읽기 전용 도구 (파일 상태를 변경하지 않음) private static readonly HashSet ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) { "file_read", "glob", "grep_tool", "folder_map", "document_read", "search_codebase", "code_search", "env_tool", "datetime_tool", "dev_env_detect", "memory", "skill_manager", "json_tool", "regex_tool", "base64_tool", "hash_tool", "image_analyze", }; /// 도구 호출을 병렬 가능 / 순차 필수로 분류합니다. private static (List Parallel, List Sequential) ClassifyToolCalls(List calls) { var parallel = new List(); var sequential = new List(); var collectParallelPrefix = true; foreach (var call in calls) { var requestedToolName = call.ToolName ?? ""; var normalizedToolName = NormalizeAliasToken(requestedToolName); var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName) ? mappedToolName : requestedToolName; if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName)) parallel.Add(call); else { collectParallelPrefix = false; sequential.Add(call); } } // 읽기 전용 도구가 1개뿐이면 병렬화 의미 없음 if (parallel.Count <= 1) { sequential.InsertRange(0, parallel); parallel.Clear(); } return (parallel, sequential); } /// 병렬 실행용 가변 상태. private class ParallelState { public int CurrentStep; public int TotalToolCalls; public int MaxIterations; public int ConsecutiveReadOnlySuccessTools; public int ConsecutiveNonMutatingSuccessTools; public int ConsecutiveErrors; public int StatsSuccessCount; public int StatsFailCount; public int StatsInputTokens; public int StatsOutputTokens; public int StatsRepeatedFailureBlocks; public int StatsRecoveredAfterFailure; public bool RecoveryPendingAfterFailure; public string? LastFailedToolSignature; public int RepeatedFailedToolSignatureCount; } /// 읽기 전용 도구들을 병렬 실행합니다. private async Task ExecuteToolsInParallelAsync( List calls, List messages, AgentContext context, List planSteps, ParallelState state, int baseMax, int maxRetry, Models.LlmSettings llm, int iteration, CancellationToken ct, List statsUsedTools) { EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중..."); var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides: null) .Select(t => t.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); var executableCalls = new List(); foreach (var call in calls) { var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames); var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase) ? call : new LlmService.ContentBlock { Type = call.Type, Text = call.Text, ToolName = resolvedToolName, ToolId = call.ToolId, ToolInput = call.ToolInput, }; var signature = BuildToolCallSignature(effectiveCall); if (ShouldBlockRepeatedFailedCall( signature, state.LastFailedToolSignature, state.RepeatedFailedToolSignatureCount, maxRetry)) { messages.Add(LlmService.CreateToolResultMessage( call.ToolId, call.ToolName, BuildRepeatedFailureGuardMessage(call.ToolName, state.RepeatedFailedToolSignatureCount, maxRetry))); EmitEvent( AgentEventType.Thinking, call.ToolName, $"병렬 배치에서도 동일 호출 반복 실패를 감지해 실행을 건너뜁니다 ({state.RepeatedFailedToolSignatureCount}/{maxRetry})"); state.StatsRepeatedFailureBlocks++; continue; } executableCalls.Add(effectiveCall); } if (executableCalls.Count == 0) return; var maxConcurrency = GetMaxParallelToolConcurrency(); using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency); var tasks = executableCalls.Select(async call => { await gate.WaitAsync(ct).ConfigureAwait(false); var tool = _tools.Get(call.ToolName); try { if (tool == null) return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L); var sw = System.Diagnostics.Stopwatch.StartNew(); try { var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement; var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, messages, ct); sw.Stop(); return (call, result, sw.ElapsedMilliseconds); } catch (Exception ex) { sw.Stop(); return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds); } } finally { gate.Release(); } }).ToList(); var results = await Task.WhenAll(tasks); // 결과를 순서대로 메시지에 추가 foreach (var (call, result, elapsed) in results) { EmitEvent( result.Success ? AgentEventType.ToolResult : AgentEventType.Error, call.ToolName, TruncateOutput(result.Output, 200), result.FilePath, elapsedMs: elapsed, iteration: iteration); if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++; state.ConsecutiveReadOnlySuccessTools = UpdateConsecutiveReadOnlySuccessTools( state.ConsecutiveReadOnlySuccessTools, call.ToolName, result.Success); state.ConsecutiveNonMutatingSuccessTools = UpdateConsecutiveNonMutatingSuccessTools( state.ConsecutiveNonMutatingSuccessTools, call.ToolName, result.Success); if (!statsUsedTools.Contains(call.ToolName)) statsUsedTools.Add(call.ToolName); state.TotalToolCalls++; messages.Add(LlmService.CreateToolResultMessage( call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000))); if (!result.Success) { var signature = BuildToolCallSignature(call); if (string.Equals(state.LastFailedToolSignature, signature, StringComparison.Ordinal)) state.RepeatedFailedToolSignatureCount++; else { state.LastFailedToolSignature = signature; state.RepeatedFailedToolSignatureCount = 1; } state.ConsecutiveErrors++; state.RecoveryPendingAfterFailure = true; if (state.ConsecutiveErrors > maxRetry) { messages.Add(LlmService.CreateToolResultMessage( call.ToolId, call.ToolName, $"[FAILED after retries] {TruncateOutput(result.Output, 500)}")); } } else { state.ConsecutiveErrors = 0; if (state.RecoveryPendingAfterFailure) { state.StatsRecoveredAfterFailure++; state.RecoveryPendingAfterFailure = false; } state.LastFailedToolSignature = null; state.RepeatedFailedToolSignatureCount = 0; } // 감사 로그 if (llm.EnableAuditLog) { AuditLogService.LogToolCall( _conversationId, ActiveTab ?? "", call.ToolName, call.ToolInput?.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success); } } } }