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