AX Agent IBM 응답 정규화 및 도구 노출 순서 추가 보강

이번 커밋은 후속 과제로 남아 있던 IBM Qwen 응답 포맷 차이와 보조 도구 과노출 문제를 추가 정리했다.

핵심 변경 사항:

- LlmService.ToolUse에서 content/reasoning_content/generated_text/output_text가 배열 또는 블록 형태로 와도 텍스트를 추출하도록 메시지 파서 보강

- content 배열 안의 tool_use/tool_call 블록도 직접 ContentBlock으로 복구해 IBM/Qwen 응답 변형에 더 유연하게 대응

- ToolRegistry 활성 도구 목록 노출 순서를 기본 파일/검색/생성/실행 도구 우선으로 재정렬하고 tool_search, MCP, spawn_agent, task 계열은 뒤로 배치

문서 반영:

- README.md, docs/DEVELOPMENT.md에 2026-04-09 23:02 (KST) 기준 이력 추가

검증 결과:

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\

- 경고 0개, 오류 0개
This commit is contained in:
2026-04-09 23:08:33 +09:00
parent 1fab215344
commit 6bd8d5bb2c
4 changed files with 331 additions and 17 deletions

View File

@@ -1254,11 +1254,22 @@ public partial class LlmService
resultsEl.GetArrayLength() > 0)
{
var first = resultsEl[0];
var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.SafeGetString()
: first.SafeTryGetProperty("output_text", out var outputTextEl)
? outputTextEl.SafeGetString()
: null;
string? generatedText = null;
if (first.SafeTryGetProperty("generated_text", out var generatedTextEl))
generatedText = TryExtractTextContent(generatedTextEl, out var extractedGeneratedText) ? extractedGeneratedText : generatedTextEl.SafeGetString();
else if (first.SafeTryGetProperty("output_text", out var outputTextEl))
generatedText = TryExtractTextContent(outputTextEl, out var extractedOutputText) ? extractedOutputText : outputTextEl.SafeGetString();
else if (first.SafeTryGetProperty("message", out var ibmMessageEl) &&
TryExtractMessageToolBlocks(ibmMessageEl, out var ibmMessageText, out var ibmToolBlocks))
{
generatedText = ibmMessageText;
foreach (var toolBlock in ibmToolBlocks)
{
if (prefetchToolCallAsync != null)
toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock);
}
}
if (!string.IsNullOrEmpty(generatedText))
{
if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal))
@@ -1388,27 +1399,33 @@ public partial class LlmService
message = nestedMessage;
var consumed = false;
if (message.SafeTryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
if (message.SafeTryGetProperty("content", out var contentEl))
{
var parsedText = contentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(parsedText))
if (TryExtractTextContent(contentEl, out var parsedText))
{
text = parsedText;
consumed = true;
}
if (contentEl.ValueKind == JsonValueKind.Array)
{
foreach (var block in contentEl.EnumerateArray())
{
if (!TryParseContentArrayToolBlock(block, out var toolBlock))
continue;
toolBlocks.Add(toolBlock);
consumed = true;
}
}
}
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed &&
message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String)
TryExtractTextContent(reasoningContentEl, out var reasoningText))
{
var reasoningText = reasoningContentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(reasoningText))
{
text = reasoningText;
consumed = true;
}
text = reasoningText;
consumed = true;
}
if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
@@ -1452,6 +1469,125 @@ public partial class LlmService
return consumed;
}
private static bool TryExtractTextContent(JsonElement element, out string text)
{
text = "";
if (element.ValueKind == JsonValueKind.String)
{
var parsed = element.SafeGetString();
if (!string.IsNullOrWhiteSpace(parsed))
{
text = parsed;
return true;
}
return false;
}
if (element.ValueKind != JsonValueKind.Array)
return false;
var segments = new List<string>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var str = item.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
continue;
}
if (item.ValueKind != JsonValueKind.Object)
continue;
if (item.SafeTryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String)
{
var str = textEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
continue;
}
if (item.SafeTryGetProperty("content", out var nestedContentEl) && TryExtractTextContent(nestedContentEl, out var nestedText))
{
segments.Add(nestedText);
continue;
}
if (item.SafeTryGetProperty("reasoning_content", out var reasoningEl) && TryExtractTextContent(reasoningEl, out var reasoningText))
{
segments.Add(reasoningText);
continue;
}
if (item.SafeTryGetProperty("output_text", out var outputTextEl) && outputTextEl.ValueKind == JsonValueKind.String)
{
var str = outputTextEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
}
}
if (segments.Count == 0)
return false;
text = string.Join("\n", segments.Where(s => !string.IsNullOrWhiteSpace(s)));
return !string.IsNullOrWhiteSpace(text);
}
private static bool TryParseContentArrayToolBlock(JsonElement block, out ContentBlock toolBlock)
{
toolBlock = default!;
if (block.ValueKind != JsonValueKind.Object)
return false;
var type = block.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() ?? "" : "";
if (!string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, "tool_call", StringComparison.OrdinalIgnoreCase))
return false;
JsonElement? parsedArgs = null;
if (block.SafeTryGetProperty("input", out var inputEl))
parsedArgs = inputEl.Clone();
else if (block.SafeTryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
try
{
using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch
{
parsedArgs = null;
}
}
else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
parsedArgs = argsEl.Clone();
}
}
var toolName = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(toolName))
return false;
toolBlock = new ContentBlock
{
Type = "tool_use",
ToolName = toolName,
ToolId = block.SafeTryGetProperty("id", out var idEl)
? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12]
: Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
};
return true;
}
private static bool LooksLikeCompleteJson(string json)
{
if (string.IsNullOrWhiteSpace(json))