변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
259 lines
10 KiB
C#
259 lines
10 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;
|
|
}
|
|
|
|
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
|
|
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
|
|
private static (List<ContentBlock> Parallel, List<ContentBlock> Sequential)
|
|
ClassifyToolCalls(List<ContentBlock> calls)
|
|
{
|
|
var parallel = new List<ContentBlock>();
|
|
var sequential = new List<ContentBlock>();
|
|
var collectParallelPrefix = true;
|
|
|
|
foreach (var call in calls)
|
|
{
|
|
var requestedToolName = call.ToolName ?? "";
|
|
var classificationToolName = AgentToolCatalog.Canonicalize(requestedToolName);
|
|
|
|
if (collectParallelPrefix && AgentToolCatalog.IsReadOnly(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<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<ContentBlock>();
|
|
foreach (var call in calls)
|
|
{
|
|
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
|
|
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
|
|
? call
|
|
: new 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 =>
|
|
{
|
|
// gate.WaitAsync를 try 안에서 호출: ct 취소 시 WaitAsync가 OperationCanceledException을
|
|
// 던져도 Release()가 잘못 호출되지 않도록 보호 (SemaphoreFullException 방지)
|
|
var acquired = false;
|
|
try
|
|
{
|
|
await gate.WaitAsync(ct).ConfigureAwait(false);
|
|
acquired = true;
|
|
|
|
var tool = _tools.Get(call.ToolName);
|
|
if (tool == null)
|
|
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
try
|
|
{
|
|
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
|
// 병렬 실행 중에는 messages를 null로 전달:
|
|
// 훅이 messages.Add()를 동시 호출하면 List<T> race condition 발생.
|
|
// 읽기 전용 도구이므로 hook 추가 컨텍스트는 이 배치 후 순차로 처리됨.
|
|
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, null, ct);
|
|
sw.Stop();
|
|
return (call, result, sw.ElapsedMilliseconds);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
sw.Stop();
|
|
return (call, ToolResult.Fail($"도구 실행이 취소되었습니다: {call.ToolName}"), sw.ElapsedMilliseconds);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
// WaitAsync 도중 취소됨 — 세마포어 미취득 상태이므로 Release 하지 않음
|
|
return (call, ToolResult.Fail($"도구 실행 대기 중 취소됨: {call.ToolName}"), 0L);
|
|
}
|
|
finally
|
|
{
|
|
if (acquired) 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);
|
|
}
|
|
|
|
// 워크플로우 상세 로그: 병렬 도구 실행 결과
|
|
WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
|
|
call.ToolName, TruncateOutput(result.Output, 2000),
|
|
result.Success, 0);
|
|
}
|
|
}
|
|
}
|