Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
lacvet 2c047d062d
Some checks failed
Release Gate / gate (push) Has been cancelled
claw-code 동등 품질 4단계 연속 반영: Agentic 루프/상태복원/설정연동/릴리즈 게이트 정렬
- 도구 동등화: task/todo/tool-search + plan/worktree/team/cron 도구군 추가 및 ToolRegistry 등록\n- claw-code CamelCase 별칭 정규화 확장: EnterPlanMode/EnterWorktree/TeamCreate/CronCreate 등 -> 내부 snake_case 매핑\n- AgentLoop 런타임 강화: Code 탭 전용 도구 토글(CodeSettings) 반영, 비활성 도구 자동 차단\n- Worktree 상태 복원 연결: .ax/worktree_state.json 기반 루트 탐색/활성 worktree 복원 및 BuildContext 연동\n- 권한/플러그인 하드닝 기존 반영분 유지: target 기반 권한 판정 + internal 모드 플러그인 경로/manifest 검증\n- 설정 연동(UI): SettingsWindow Code 패널에 Plan/Worktree/Team/Cron 도구 on/off 토글 추가\n- 테스트 보강: AgentParityTools/AgentLoopE2E에 worktree 지속성, alias 정규화, 설정 차단 시나리오 추가\n- 검증 완료: dotnet build(경고0/오류0), ParityBenchmark 11/11, ReplayStability 12/12, 전체 371/371, release-gate 통과\n- 문서 동기화: AGENT_ROADMAP/NEXT_ROADMAP/CLAW_CODE_PARITY_PLAN 수치 및 기준 최신화
2026-04-03 20:16:23 +09:00

247 lines
9.5 KiB
C#

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<string> 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",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
{
var parallel = new List<LlmService.ContentBlock>();
var sequential = new List<LlmService.ContentBlock>();
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);
}
/// <summary>병렬 실행용 가변 상태.</summary>
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;
}
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls,
List<ChatMessage> messages,
AgentContext context,
List<string> planSteps,
ParallelState state,
int baseMax, int maxRetry,
Models.LlmSettings llm,
int iteration,
CancellationToken ct,
List<string> 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<LlmService.ContentBlock>();
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);
}
}
}
}