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

@@ -528,6 +528,12 @@ public partial class LlmService
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Send: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Send 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
// Raw 요청 로깅 (상세 로그 활성 시)
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
@@ -543,6 +549,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Send API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
@@ -596,6 +604,23 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
// 패턴 4: 파이프-래핑 커스텀 포맷 (FastAPI로 호스팅된 Gemma 계열, 일부 IBM/Kimi/GLM 배포에서 leak)
// 예: <|tool_call>call;document_read{path:<|"|>전략보고서.html<|"|>}<tool_call|>
// - 앞 여는 태그 `<|tool_call>` / 닫는 태그 `<tool_call|>` 혹은 `</tool_call|>`
// - 본문은 `call;NAME{args}` 또는 `NAME{args}` 형태
// - args 내부의 `<|"|>` 는 따옴표로 디코딩, 비인용 키는 따옴표 부여
private static readonly System.Text.RegularExpressions.Regex ToolCallPipeWrappedRegex = new(
@"<\|\s*tool_call\s*\|?>\s*(?:call\s*;\s*)?(\w+)\s*(\{[\s\S]*?\})\s*<\s*/?\s*tool_call\s*\|\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex PipeQuoteDecodeRegex = new(
@"<\|\s*""\s*\|>",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex UnquotedJsonKeyRegex = new(
@"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:",
System.Text.RegularExpressions.RegexOptions.Compiled);
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
@@ -628,9 +653,39 @@ public partial class LlmService
}
}
// 패턴 4: 파이프-래핑 커스텀 포맷 (<|tool_call>call;NAME{args}<tool_call|>)
if (results.Count == 0)
{
foreach (System.Text.RegularExpressions.Match m in ToolCallPipeWrappedRegex.Matches(text))
{
var name = m.Groups[1].Value;
var rawArgs = m.Groups[2].Value;
// `<|"|>` → `"` 디코딩
var decoded = PipeQuoteDecodeRegex.Replace(rawArgs, "\"");
// 비인용 키를 JSON 키로 변환 ({path:"x"} → {"path":"x"})
var normalized = UnquotedJsonKeyRegex.Replace(decoded, "\"$1\":");
var block = TryParseToolCallJsonWithName(name, normalized);
if (block != null) results.Add(block);
}
}
return results;
}
/// <summary>
/// 텍스트에서 파싱된 tool_call 태그(4가지 형식 전부)를 제거합니다.
/// 폴백 파싱으로 도구 호출이 추출된 경우, 사용자 화면에 원본 토큰이 남지 않도록 최종 표시 텍스트를 정화.
/// </summary>
internal static string StripToolCallTokens(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = ToolCallTagRegex.Replace(text, "");
text = ToolCallFunctionRegex.Replace(text, "");
text = ToolCallJsonRegex.Replace(text, "");
text = ToolCallPipeWrappedRegex.Replace(text, "");
return text.Trim();
}
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
private static ContentBlock? TryParseToolCallJson(string json)
{
@@ -1049,8 +1104,11 @@ public partial class LlmService
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// tool_choice: OpenAI 표준 필드만 전송.
// 이전에는 `tool_choice` + `tool_choice_option` 둘 다 보내 구버전 호환을 시도했지만,
// 최신 IBM vLLM 배포는 다음 오류로 요청을 거부합니다:
// "400 Json document validation error: tool_choice_option should not be defined if a value is given for ToolChoice"
// 구버전 배포(tool_choice_option 전용)는 상위 ToolCallNotSupportedException 폴백이 처리함.
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
@@ -1059,7 +1117,6 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
tool_choice = "required",
tool_choice_option = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
@@ -1239,8 +1296,19 @@ public partial class LlmService
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
}
// 사용자 화면에 원본 tool_call 토큰이 남지 않도록 텍스트 정화
var cleanedText = StripToolCallTokens(textBlock.Text);
result.Remove(textBlock);
if (!string.IsNullOrWhiteSpace(cleanedText))
result.Add(new ContentBlock { Type = "text", Text = cleanedText });
result.AddRange(extracted);
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
var toolNames = string.Join(", ", extracted.Select(e => e.ToolName));
IbmDiagInfo($"[IBM진단] 텍스트 폴백에서 도구 호출 {extracted.Count}건 추출: [{toolNames}]");
}
else if (usesIbmDeploymentApi)
{
var preview = textBlock.Text.Length > 300 ? textBlock.Text[..300] + "…" : textBlock.Text;
IbmDiagError($"[IBM진단] 응답에 tool_calls 없음, 텍스트 폴백 파싱도 실패. 응답 텍스트: {preview}");
}
}
}
@@ -1275,6 +1343,11 @@ public partial class LlmService
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Stream: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Stream 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
@@ -1287,6 +1360,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Stream API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
{
LogService.Warn(isIbmDeployment
@@ -1329,11 +1404,18 @@ public partial class LlmService
{
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream: ContentType={contentType}");
if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase)
&& !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase))
{
// 비-SSE 전체 JSON 응답 (Ollama stream:false 등)
var rawJson = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var preview = rawJson.Length > 500 ? rawJson[..500] + "…" : rawJson;
IbmDiagInfo($"[IBM진단] ToolUse 비-SSE 응답(전체 JSON): len={rawJson.Length}자\n 미리보기: {preview}");
}
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
var trimmed = respJson.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
@@ -1362,6 +1444,7 @@ public partial class LlmService
var firstChunkReceived = false;
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
var lastIbmGeneratedText = "";
var ibmToolChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
@@ -1380,12 +1463,41 @@ public partial class LlmService
firstChunkReceived = true;
var data = line["data: ".Length..].Trim();
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream 완료: 총 {ibmToolChunkCount}개 청크, toolAccumulators={toolAccumulators.Count}개");
break;
}
using var doc = JsonDocument.Parse(data);
JsonDocument doc;
try
{
doc = JsonDocument.Parse(data);
}
catch (JsonException jex)
{
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] ToolUse.ParseStream JSON 파싱 실패: {jex.Message}\n 원본: {preview}");
}
continue;
}
using (doc)
{
var root = doc.RootElement;
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi)
{
ibmToolChunkCount++;
if (ibmToolChunkCount <= 3 || ibmToolChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream chunk#{ibmToolChunkCount}: {preview}");
}
}
if (usesIbmDeploymentApi &&
root.SafeTryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
@@ -1393,6 +1505,7 @@ public partial class LlmService
var detail = root.SafeTryGetProperty("message", out var msgEl)
? msgEl.SafeGetString()
: "IBM vLLM 도구 호출 응답 오류";
IbmDiagError($"[IBM진단] ToolUse.ParseStream 서버 오류: {detail}");
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
@@ -1497,13 +1610,25 @@ public partial class LlmService
toolAccumulators[index] = acc;
}
// IBM vLLM은 첫 청크 이후 후속 delta에서 id/name을 ""(빈 문자열)로 다시 보내는 경우가 있음.
// 빈 값으로 덮어쓰면 누적된 name/id가 사라져 TryCreateCompletedToolCallAsync의
// IsNullOrWhiteSpace(acc.Name) 체크에 걸려 도구 호출이 방출되지 않는다.
// → 비-공백 값일 때만 갱신한다.
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.SafeGetString() ?? acc.Id;
{
var idStr = idEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(idStr))
acc.Id = idStr;
}
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
{
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.SafeGetString() ?? acc.Name;
{
var nameStr = nameEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(nameStr))
acc.Name = nameStr;
}
if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
{
@@ -1539,6 +1664,7 @@ public partial class LlmService
}
}
}
} // using (doc)
}
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))