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