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

@@ -539,14 +539,15 @@ public partial class AgentLoopService
sendMessages = [.. messages, new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
"Call at least one tool RIGHT NOW. Text-only response is rejected."
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
}];
}
// 워크플로우 상세 로그: LLM 요청
llmCallSw.Restart();
var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
var streamedTextPreview = new StringBuilder();
@@ -807,17 +808,14 @@ public partial class AgentLoopService
"[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
"지금 즉시 도구를 1개 이상 호출하세요. " +
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
$"지금 사용 가능한 도구: {activeToolPreview}",
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}",
_ =>
"[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
@@ -850,15 +848,13 @@ public partial class AgentLoopService
{
1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}",
_ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}"
};
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
@@ -884,7 +880,8 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "user",
Content = "html_create 도구를 호출하지 않았습니다. " +
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출하세요." });
"지금 즉시 아래 형식으로 호출하세요:\n" +
"<tool_call>\n{\"name\": \"html_create\", \"arguments\": {\"file_name\": \"...\", \"html_body\": \"...\"}}\n</tool_call>" });
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
continue; // 루프 재시작
}
@@ -1475,6 +1472,26 @@ public partial class AgentLoopService
{
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
failedToolHistogram[effectiveCall.ToolName] = failedCount + 1;
// 같은 도구가 5회 이상 실패하면 해당 도구를 포기하고 LLM에 알림
if (failedCount + 1 >= 5)
{
var abortMsg = $"도구 '{effectiveCall.ToolName}'이(가) {failedCount + 1}회 실패했습니다. 이 도구를 더 이상 호출하지 마세요. 다른 방법을 시도하거나 사용자에게 결과를 보고하세요.";
EmitEvent(AgentEventType.Error, effectiveCall.ToolName, abortMsg);
messages.Add(LlmService.CreateToolResultMessage(
effectiveCall.ToolId, effectiveCall.ToolName, abortMsg));
messages.Add(new ChatMessage { Role = "user", Content = abortMsg });
continue;
}
// 전체 실패 횟수가 총 도구 호출의 60% 이상이면 조기 중단
var totalFails = failedToolHistogram.Values.Sum();
if (totalToolCalls > 6 && totalFails > totalToolCalls * 0.6)
{
EmitEvent(AgentEventType.Error, "",
$"전체 도구 호출 중 실패율이 높아 작업을 중단합니다 (실패 {totalFails}/{totalToolCalls})");
return $"도구 실행 실패율이 높아 작업을 중단했습니다. {totalFails}개 실패 / {totalToolCalls}개 호출. 요청을 다시 시도하거나 작업 방식을 변경해 주세요.";
}
}
// UI 스레드가 이벤트를 렌더링할 시간 확보

View File

@@ -36,17 +36,16 @@ public class ClipboardTool : IAgentTool
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
// 클립보드는 STA 스레드에서만 접근 가능
ToolResult? result = null;
Application.Current.Dispatcher.Invoke(() =>
// 클립보드는 STA 스레드에서만 접근 가능 — InvokeAsync로 UI 스레드 블로킹 방지
var result = await Application.Current.Dispatcher.InvokeAsync(() =>
{
result = action switch
return action switch
{
"read" => ReadClipboard(),
"write" => WriteClipboard(args),
@@ -55,11 +54,11 @@ public class ClipboardTool : IAgentTool
_ => ToolResult.Fail($"Unknown action: {action}"),
};
});
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
return result ?? ToolResult.Fail("클립보드 접근 실패");
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
return ToolResult.Fail($"클립보드 오류: {ex.Message}");
}
}

View File

@@ -43,7 +43,7 @@ public class NotifyTool : IAgentTool
Required = ["title", "message"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.GetProperty("title").GetString() ?? "알림";
var message = args.GetProperty("message").GetString() ?? "";
@@ -51,15 +51,16 @@ public class NotifyTool : IAgentTool
try
{
Application.Current.Dispatcher.Invoke(() =>
// InvokeAsync로 변경 — Dispatcher.Invoke는 UI 스레드가 _convLock 대기 중일 때 데드락 발생
await Application.Current.Dispatcher.InvokeAsync(() =>
{
ShowToast(title, message, level);
});
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
return ToolResult.Ok($"✓ Notification sent: [{level}] {title}");
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
return ToolResult.Fail($"알림 전송 실패: {ex.Message}");
}
}

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 &lt;tool_call&gt;\n{"name":"...", "arguments":{...}}\n&lt;/tool_call&gt;
/// 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
{

View File

@@ -453,7 +453,9 @@ public partial class LlmService : IDisposable
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -462,9 +464,22 @@ public partial class LlmService : IDisposable
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
{
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object &&
message.TryGetProperty("content", out var content))
return content.GetString() ?? "";
if (message.ValueKind == JsonValueKind.Object)
{
if (message.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
return text;
}
// Qwen3.5 thinking 모드 폴백
if (message.TryGetProperty("reasoning_content", out var reasoning))
{
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
return text;
}
}
}
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
@@ -855,10 +870,14 @@ public partial class LlmService : IDisposable
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{
var first = ch[0];
if (first.TryGetProperty("delta", out var delta)
&& delta.TryGetProperty("content", out var cnt))
if (first.TryGetProperty("delta", out var delta))
{
var txt = cnt.GetString();
string? txt = null;
if (delta.TryGetProperty("content", out var cnt))
txt = cnt.GetString();
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc))
txt = rc.GetString();
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
}
else if (first.TryGetProperty("message", out _))
@@ -965,6 +984,8 @@ public partial class LlmService : IDisposable
var delta = ibmChoices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
text = rc.GetString();
}
}
else
@@ -975,6 +996,8 @@ public partial class LlmService : IDisposable
var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
text = rc2.GetString();
}
}
}

View File

@@ -53,7 +53,7 @@ internal sealed class PerformanceMonitorService
if (_timer != null)
return;
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000);
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 5000);
}
public void StopPolling()

View File

@@ -33,7 +33,7 @@ internal sealed class ServerStatusService
if (_timer != null)
return;
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15000);
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 60000);
}
public void Stop()

View File

@@ -162,13 +162,44 @@ public class SettingsService
return string.Compare(current, target, StringComparison.Ordinal) < 0;
}
private static readonly object _saveLock = new();
public void Save()
{
EnsureDirectories();
NormalizeRuntimeSettings();
var json = JsonSerializer.Serialize(_settings, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json);
File.WriteAllText(SettingsPath, encrypted);
// 임시 파일에 쓰고 교체 (atomic write) — 동시 읽기 충돌 방지
lock (_saveLock)
{
var tmpPath = SettingsPath + ".tmp";
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
File.WriteAllText(tmpPath, encrypted);
// File.Move with overwrite (atomic on NTFS)
File.Move(tmpPath, SettingsPath, overwrite: true);
break;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(50 * (attempt + 1));
}
catch (Exception ex) when (attempt < 2)
{
LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1));
}
finally
{
// 임시 파일 잔여 방지
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
}
}
}
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -39,10 +39,42 @@ public static class WorkflowLogService
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
public static int RetentionDays { get; set; } = 3;
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
public static void Log(WorkflowLogEntry entry)
// ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ───
// AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다.
// AsyncLocal: async/await 전후로 올바르게 전파됨 (ThreadStatic은 continuation 스레드에서 유실)
private static readonly AsyncLocal<string?> _ctxConversationId = new();
private static readonly AsyncLocal<string?> _ctxRunId = new();
private static readonly AsyncLocal<int> _ctxIteration = new();
/// <summary>현재 LLM 호출 컨텍스트를 설정합니다 (AgentLoopService에서 호출).</summary>
public static void SetCallContext(string conversationId, string runId, int iteration)
{
if (!IsEnabled) return;
_ctxConversationId.Value = conversationId;
_ctxRunId.Value = runId;
_ctxIteration.Value = iteration;
}
/// <summary>Raw LLM 통신 로깅 활성화 여부 (요청 JSON + 응답 원문).</summary>
public static bool IsRawLogEnabled { get; set; }
/// <summary>현재 컨텍스트를 사용하여 raw 요청을 기록합니다 (LlmService에서 호출).</summary>
public static void LogLlmRawRequestFromContext(string url, string requestBody)
{
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
LogLlmRawRequest(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, url, requestBody);
}
/// <summary>현재 컨텍스트를 사용하여 raw 응답을 기록합니다 (LlmService에서 호출).</summary>
public static void LogLlmRawResponseFromContext(string rawResponse, long elapsedMs)
{
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
LogLlmRawResponse(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, rawResponse, elapsedMs);
}
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
public static void Log(WorkflowLogEntry entry, bool bypassEnabledCheck = false)
{
if (!bypassEnabledCheck && !IsEnabled) return;
try
{
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.ToString("yyyy-MM-dd"));
@@ -82,6 +114,42 @@ public static class WorkflowLogService
});
}
/// <summary>LLM에 보낸 실제 HTTP 요청 body (raw JSON)를 기록합니다.</summary>
public static void LogLlmRawRequest(string conversationId, string runId, int iteration,
string url, string requestBody)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_raw_request",
Iteration = iteration,
Details = new Dictionary<string, object?>
{
["url"] = url,
["body"] = requestBody,
}
}, bypassEnabledCheck: true);
}
/// <summary>LLM이 돌려준 raw 응답 텍스트를 기록합니다 (SSE 전체 or 단일 JSON).</summary>
public static void LogLlmRawResponse(string conversationId, string runId, int iteration,
string rawResponse, long elapsedMs)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_raw_response",
Iteration = iteration,
ElapsedMs = elapsedMs,
Details = new Dictionary<string, object?>
{
["raw"] = Truncate(rawResponse, 20000),
}
}, bypassEnabledCheck: true);
}
/// <summary>LLM 응답을 기록합니다.</summary>
public static void LogLlmResponse(string conversationId, string runId, int iteration,
string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs)