AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영
- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함 - 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함 - README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user