AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영

- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함

- 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함

- README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
2026-04-08 23:20:53 +09:00
parent 6e99837a4c
commit 1b4a2bfb1c
24 changed files with 1103 additions and 173 deletions

View File

@@ -527,6 +527,9 @@ public partial class LlmService
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
// Raw 요청 로깅 (상세 로그 활성 시)
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
@@ -546,6 +549,7 @@ public partial class LlmService
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
@@ -574,29 +578,35 @@ public partial class LlmService
/// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call>
/// 3. 여러 건의 연속 tool_call 태그
/// </summary>
// ── 텍스트 폴백 파싱용 정규식 (static 캐싱 — 매 호출 재생성 방지) ──
private static readonly System.Text.RegularExpressions.Regex ToolCallTagRegex = new(
@"<\s*tool_call\s*>\s*([\s\S]*?)\s*<\s*/\s*tool_call\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ToolCallFunctionRegex = new(
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ToolCallJsonRegex = new(
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
// 패턴 1: <tool_call>...</tool_call> 태그 (Qwen 계열 기본 출력)
var tagPattern = new System.Text.RegularExpressions.Regex(
@"<\s*tool_call\s*>\s*(\{[\s\S]*?\})\s*<\s*/\s*tool_call\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in tagPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallTagRegex.Matches(text))
{
var block = TryParseToolCallJson(m.Groups[1].Value);
if (block != null) results.Add(block);
}
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
// 패턴 2: ✿FUNCTION✿ (일부 Qwen 변형)
if (results.Count == 0)
{
var fnPattern = new System.Text.RegularExpressions.Regex(
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallFunctionRegex.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
@@ -606,9 +616,7 @@ public partial class LlmService
// 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}})
if (results.Count == 0)
{
var jsonPattern = new System.Text.RegularExpressions.Regex(
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}");
foreach (System.Text.RegularExpressions.Match m in jsonPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallJsonRegex.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
@@ -623,6 +631,12 @@ public partial class LlmService
{
try
{
json = json.Trim();
// <tool_call> 태그 내용에서 JSON 객체 부분만 추출 (앞뒤 비-JSON 텍스트 제거)
var braceStart = json.IndexOf('{');
var braceEnd = json.LastIndexOf('}');
if (braceStart >= 0 && braceEnd > braceStart)
json = json[braceStart..(braceEnd + 1)];
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
@@ -642,7 +656,11 @@ public partial class LlmService
ToolInput = args,
};
}
catch { return null; }
catch (Exception ex)
{
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패: {ex.Message} | 원본: {(json.Length > 200 ? json[..200] + "" : json)}");
return null;
}
}
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
@@ -660,7 +678,11 @@ public partial class LlmService
ToolInput = doc.RootElement.Clone(),
};
}
catch { return null; }
catch (Exception ex)
{
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패 (name={name}): {ex.Message}");
return null;
}
}
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
@@ -818,10 +840,28 @@ public partial class LlmService
string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
var msgs = new List<object>();
// 시스템 프롬프트
// 시스템 프롬프트 + IBM/vLLM 도구 호출 가이드 주입
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
msgs.Add(new { role = "system", content = systemPrompt });
// tools 이름 목록을 시스템 프롬프트에 직접 삽입 → Qwen이 도구 이름을 확실히 인식
var toolNameList = string.Join(", ", tools.Select(t => t.Name));
var toolCallGuidance =
"\n\n[Tool Calling Instructions]\n" +
"You have access to the following tools: " + toolNameList + "\n" +
"When the user's request requires action, you MUST call a tool. NEVER describe what you would do — call the tool directly.\n\n" +
"To call a tool, output EXACTLY this format (one per tool):\n" +
"<tool_call>\n" +
"{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" +
"</tool_call>\n\n" +
"Rules:\n" +
"- You MUST call at least one tool for every user request that requires action.\n" +
"- Do NOT explain what you plan to do. Do NOT say \"I will\" or \"Let me\". Just output <tool_call> immediately.\n" +
"- If multiple tools are needed, output multiple <tool_call> blocks.\n" +
"- After receiving tool results, use them to answer the user.\n";
var fullSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt)
? toolCallGuidance.TrimStart()
: systemPrompt + toolCallGuidance;
msgs.Add(new { role = "system", content = fullSystemPrompt });
foreach (var m in messages)
{
@@ -893,7 +933,7 @@ public partial class LlmService
msgs.Add(new
{
role = "user",
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: <tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n</tool_call>"
});
}
@@ -923,8 +963,9 @@ public partial class LlmService
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
return new
@@ -932,11 +973,13 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
tool_choice = "required",
tool_choice_option = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -948,7 +991,8 @@ public partial class LlmService
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -969,19 +1013,28 @@ public partial class LlmService
{
var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder();
var rawSseBuilder = WorkflowLogService.IsRawLogEnabled ? new StringBuilder() : null;
var rawSw = System.Diagnostics.Stopwatch.StartNew();
await foreach (var evt in StreamOpenAiToolEventsAsync(resp, usesIbmDeploymentApi, prefetchToolCallAsync, ct).WithCancellation(ct))
{
if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
{
textBuilder.Append(evt.Text);
rawSseBuilder?.Append("[text] ").AppendLine(evt.Text);
}
else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{
blocks.Add(evt.ToolCall);
rawSseBuilder?.Append("[tool_call] ").Append(evt.ToolCall.ToolName)
.Append(' ').AppendLine(evt.ToolCall.ToolInput?.GetRawText() ?? "{}");
}
}
// Raw 응답 로깅
if (rawSseBuilder != null && rawSseBuilder.Length > 0)
WorkflowLogService.LogLlmRawResponseFromContext(rawSseBuilder.ToString(), rawSw.ElapsedMilliseconds);
var text = textBuilder.ToString().Trim();
var result = new List<ContentBlock>();
if (!string.IsNullOrWhiteSpace(text))
@@ -1037,6 +1090,7 @@ public partial class LlmService
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
@@ -1052,6 +1106,7 @@ public partial class LlmService
{
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
@@ -1082,6 +1137,35 @@ public partial class LlmService
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
[EnumeratorCancellation] CancellationToken ct)
{
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
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);
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
var trimmed = respJson.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
{
using var doc = JsonDocument.Parse(respJson);
TryParseOpenAiUsage(doc.RootElement);
if (TryExtractMessageToolBlocks(doc.RootElement, out var msgText, out var directBlocks))
{
if (!string.IsNullOrWhiteSpace(msgText))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, msgText);
foreach (var block in directBlocks)
{
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block);
}
}
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
yield break;
}
}
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
@@ -1174,12 +1258,26 @@ public partial class LlmService
var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl))
{
var emittedContent = false;
if (deltaEl.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var chunk = contentEl.GetString();
if (!string.IsNullOrEmpty(chunk))
{
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
emittedContent = true;
}
}
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
if (!emittedContent &&
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) &&
reasoningEl.ValueKind == JsonValueKind.String)
{
var reasoningChunk = reasoningEl.GetString();
if (!string.IsNullOrEmpty(reasoningChunk))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
}
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
@@ -1274,6 +1372,18 @@ public partial class LlmService
consumed = true;
}
}
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed &&
message.TryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String)
{
var reasoningText = reasoningContentEl.GetString();
if (!string.IsNullOrWhiteSpace(reasoningText))
{
text = reasoningText;
consumed = true;
}
}
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
@@ -1325,6 +1435,22 @@ public partial class LlmService
if (!(json.StartsWith('{') || json.StartsWith('[')))
return false;
// 빠른 사전 검사: 중괄호/대괄호 균형이 맞지 않으면 파싱 시도 불필요
int depth = 0;
bool inString = false;
bool escape = false;
for (int i = 0; i < json.Length; i++)
{
var ch = json[i];
if (escape) { escape = false; continue; }
if (ch == '\\' && inString) { escape = true; continue; }
if (ch == '"') { inString = !inString; continue; }
if (inString) continue;
if (ch is '{' or '[') depth++;
else if (ch is '}' or ']') depth--;
}
if (depth != 0) return false;
try
{
using var _ = JsonDocument.Parse(json);
@@ -1362,6 +1488,12 @@ public partial class LlmService
return null;
}
}
else if (!forceEmit)
{
// 스트리밍 중 이름만 도착하고 arguments가 아직 비어 있는 경우
// → 후속 청크에서 arguments가 올 수 있으므로 조기 방출하지 않음
return null;
}
var block = new ContentBlock
{