AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강

변경 목적:
- 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
This commit is contained in:
2026-04-14 17:52:46 +09:00
parent fa33b98f7e
commit 8cb08576d5
200 changed files with 13522 additions and 5764 deletions

View File

@@ -26,6 +26,25 @@ public partial class LlmService : ILlmService
private string? _systemPrompt;
private const int MaxRetries = 2;
/// <summary>IBM+Qwen 진단 로그 활성 여부 (EnableIbmDiagnosticLog 설정 연동).</summary>
private bool IsIbmDiagEnabled => _settings.Settings.Llm.EnableIbmDiagnosticLog;
/// <summary>IBM 진단 전용 Debug 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagDebug(string msg)
{
if (IsIbmDiagEnabled) LogService.Info($"[IBM진단:DBG] {msg}");
}
/// <summary>IBM 진단 전용 Info 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagInfo(string msg)
{
if (IsIbmDiagEnabled) LogService.Info(msg);
}
/// <summary>IBM 진단 전용 Error 로그. 설정 무관하게 항상 출력 (에러는 항상 기록).</summary>
private static void IbmDiagError(string msg) => LogService.Error(msg);
// 첫 청크: 모델이 컨텍스트를 처리하는 시간 (대용량 컨텍스트에서 3분까지 허용)
private static readonly TimeSpan FirstChunkTimeout = TimeSpan.FromSeconds(180);
// 이후 청크: 스트리밍이 시작된 후 청크 간 최대 간격
@@ -357,9 +376,11 @@ public partial class LlmService : ILlmService
return false;
var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant();
return normalizedEndpoint.Contains("/ml/") ||
var result = normalizedEndpoint.Contains("/ml/") ||
normalizedEndpoint.Contains("/deployments/") ||
normalizedEndpoint.Contains("/text/chat");
LogService.Debug($"[IBM진단] UsesIbmDeploymentChatApi: service={service}, authType={authType}, endpoint={endpoint?.Length ?? 0}자, result={result}");
return result;
}
private string BuildIbmDeploymentChatUrl(string endpoint, bool stream)
@@ -369,14 +390,18 @@ public partial class LlmService : ILlmService
throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다.");
var normalized = trimmed.ToLowerInvariant();
string url;
if (normalized.Contains("/text/chat_stream"))
return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
if (normalized.Contains("/text/chat"))
return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
if (normalized.Contains("/deployments/"))
return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
url = stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
else if (normalized.Contains("/text/chat"))
url = stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
else if (normalized.Contains("/deployments/"))
url = trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
else
url = trimmed;
return trimmed;
IbmDiagDebug($"[IBM진단] BuildUrl: stream={stream}, url={url}");
return url;
}
private object BuildIbmDeploymentBody(List<ChatMessage> messages)
@@ -384,6 +409,7 @@ public partial class LlmService : ILlmService
var msgs = new List<object>();
if (!string.IsNullOrWhiteSpace(_systemPrompt))
msgs.Add(new { role = "system", content = _systemPrompt });
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody: messages={messages.Count}건, systemPrompt={(_systemPrompt?.Length ?? 0)}자");
foreach (var m in messages)
{
@@ -440,13 +466,16 @@ public partial class LlmService : ILlmService
});
}
var temperature = ResolveTemperature();
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody 완료: finalMessages={msgs.Count}건, temp={temperature}, maxTokens={maxTokens}");
return new
{
messages = msgs,
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
temperature,
max_new_tokens = maxTokens
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
@@ -509,11 +538,21 @@ public partial class LlmService : ILlmService
if (registered != null &&
registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase))
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
return token;
IbmDiagDebug($"[IBM진단] IBM IAM 인증 시도: model={modelName}, hasApiKey={!string.IsNullOrWhiteSpace(registered.ApiKey)}");
try
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
IbmDiagDebug($"[IBM진단] IBM IAM 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] IBM IAM 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// CP4D 인증 방식인 경우
@@ -523,10 +562,20 @@ public partial class LlmService : ILlmService
registered.AuthType.Equals("cp4d_api_key", StringComparison.OrdinalIgnoreCase)) &&
!string.IsNullOrWhiteSpace(registered.Cp4dUrl))
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
return token;
IbmDiagDebug($"[IBM진단] CP4D 인증 시도: authType={registered.AuthType}, cp4dUrl={registered.Cp4dUrl}, user={registered.Cp4dUsername}");
try
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
IbmDiagDebug($"[IBM진단] CP4D 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] CP4D 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// 기본 Bearer 인증 — 기존 API 키 반환
@@ -802,15 +851,38 @@ public partial class LlmService : ILlmService
: ep.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (usesIbmDeploymentApi)
IbmDiagInfo($"[IBM진단] SendOpenAi(비스트리밍): url={url}, bodyLen={json.Length}자, messages={messages.Count}건");
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] SendOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
using (resp)
{
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
var preview = respBody.Length > 500 ? respBody[..500] + "…" : respBody;
IbmDiagInfo($"[IBM진단] SendOpenAi 응답: HTTP {(int)resp.StatusCode}, ContentType={contentType}, bodyLen={respBody.Length}자");
IbmDiagDebug($"[IBM진단] SendOpenAi 응답본문: {preview}");
}
// IBM vLLM이 stream:false 요청에도 SSE 형식(id:/event/data: 라인)으로 응답하는 경우 처리
var effectiveBody = ExtractJsonFromSseIfNeeded(respBody);
@@ -834,6 +906,7 @@ public partial class LlmService : ILlmService
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "vLLM 응답");
} // using (resp)
}
/// <summary>
@@ -931,14 +1004,39 @@ public partial class LlmService : ILlmService
? BuildIbmDeploymentChatUrl(ep, stream: true)
: ep.TrimEnd('/') + "/v1/chat/completions";
if (usesIbmDeploymentApi)
{
var bodyJson = JsonSerializer.Serialize(body);
IbmDiagInfo($"[IBM진단] StreamOpenAi: url={url}, bodyLen={bodyJson.Length}자, messages={messages.Count}건");
IbmDiagDebug($"[IBM진단] StreamOpenAi 요청본문(앞500자): {(bodyJson.Length > 500 ? bodyJson[..500] + "" : bodyJson)}");
}
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] StreamOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
if (usesIbmDeploymentApi)
{
var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
IbmDiagInfo($"[IBM진단] StreamOpenAi 연결 성공: HTTP {(int)resp.StatusCode}, ContentType={ct2}");
}
using var stream2 = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream2);
var firstChunkReceived = false;
var ibmChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
@@ -954,7 +1052,12 @@ public partial class LlmService : ILlmService
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
if (data == "[DONE]")
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] StreamOpenAi 완료: 총 {ibmChunkCount}개 청크 수신");
break;
}
string? text = null;
try
@@ -963,12 +1066,21 @@ public partial class LlmService : ILlmService
TryParseOpenAiUsage(doc.RootElement);
if (usesIbmDeploymentApi)
{
ibmChunkCount++;
// 첫 3개 청크 + 이후 50개마다 로깅 (과도한 로그 방지)
if (ibmChunkCount <= 3 || ibmChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] StreamOpenAi chunk#{ibmChunkCount}: {preview}");
}
if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
? message.SafeGetString()
: "IBM vLLM 스트리밍 오류";
IbmDiagError($"[IBM진단] StreamOpenAi 서버 오류 응답: {detail}");
throw new InvalidOperationException(detail);
}
@@ -1029,7 +1141,13 @@ public partial class LlmService : ILlmService
}
catch (JsonException ex)
{
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] StreamOpenAi JSON 파싱 오류: {ex.Message}\n 청크 내용: {preview}");
}
else
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (!string.IsNullOrEmpty(text)) yield return text;
}