IBM vLLM 도구 호출 스트리밍과 모델 프로파일 기반 실행 정책 강화
Some checks failed
Release Gate / gate (push) Has been cancelled

- IBM 배포형 도구 호출 바디에 프로파일 기반 tool temperature를 적용하고 tool_call_strict 프로파일에서 더 직접적인 tool-only 지시를 추가함
- IBM 경로가 tool_choice를 거부할 때 tool_choice만 제거한 대체 강제 재시도 경로를 추가함
- OpenAI/vLLM tool-use 응답을 SSE로 수신하고 delta.tool_calls를 부분 조립해 도구 호출을 더 빨리 감지하도록 변경함
- read-only 도구 조기 실행과 결과 재사용 경로를 도입해 Cowork/Code 도구 착수 속도를 개선함
- README와 DEVELOPMENT 문서를 2026-04-08 11:14(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-08 16:48:11 +09:00
parent a2c952879d
commit 90ef3400f6
20 changed files with 1231 additions and 241 deletions

View File

@@ -1483,3 +1483,8 @@ MIT License
- OpenAI/vLLM tool calling 바디에 `parallel_tool_calls` 힌트를 추가해 읽기 도구 병렬 실행 성향이 모델 요청 바디에도 반영되도록 보강했습니다.
- Cowork/Code 진행 표시에는 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 같은 단계 메타를 더 직접적으로 붙여, 오래 걸릴 때도 현재 단계가 더 잘 읽히게 했습니다.
- [docs/AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)를 전면 정리해 `tool_call_strict`, `fast_readonly`, `document_heavy`, `reasoning_first` 프로파일별 회귀 시나리오를 고정했습니다.
- 업데이트: 2026-04-08 11:14 (KST)
- IBM 인증 경유 vLLM 도구 호출 경로를 강화했습니다. IBM tool body도 이제 프로파일 기반 `ResolveToolTemperature()`를 사용하고, `tool_call_strict` 프로파일에서는 더 짧고 직접적인 `tool-only` 지시를 추가해 plain text 응답으로 빠지는 경향을 줄였습니다.
- IBM 배포형 엔드포인트가 `tool_choice`를 400으로 거부하면, `tool_choice`만 제거하고 동일한 강제 지시를 유지한 채 한 번 더 재시도하는 대체 강제 전략을 넣었습니다.
- OpenAI/vLLM tool-use 응답은 이제 `stream=true` 기반 SSE 수신기로 읽으며, `delta.tool_calls`를 부분 조립해 완성된 도구 호출을 더 빨리 감지합니다.
- read-only 도구는 조립이 끝나는 즉시 조기 실행을 시작하고, 최종 루프에서는 그 결과를 재사용하도록 바꿔 도구 착수 속도를 끌어올렸습니다.

Binary file not shown.

Binary file not shown.

View File

@@ -5386,3 +5386,18 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Cowork/Code 진행 카드에 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 단계 메타를 더 직접적으로 붙여, 오래 걸리는 작업도 어느 단계인지 읽기 쉽게 정리했다.
- [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)
- 회귀 프롬프트 문서를 전면 교체하고, `tool_call_strict`, `fast_readonly`, `document_heavy`, `reasoning_first` 프로파일별 검증 시나리오를 추가했다.
## 2026-04-08 11:14 (KST)
- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs)
- IBM 배포형 도구 호출 바디가 일반 temperature 대신 프로파일 기반 `ResolveToolTemperature()`를 사용하도록 수정했다.
- `tool_call_strict` + `forceToolCall` 조합일 때 IBM 경로에 짧고 직접적인 `tool-only` 지시를 추가했다.
- IBM 경로가 `tool_choice`를 400으로 거부하면 `tool_choice`만 제거한 대체 강제 요청으로 한 번 더 재시도하도록 보강했다.
- OpenAI/vLLM tool-use 응답을 `stream=true` SSE로 읽는 전용 파서를 추가하고, `choices[].delta.tool_calls`를 부분 조립해 완성된 도구 호출을 더 빨리 감지하도록 바꿨다.
- SSE에서 완성된 read-only tool call은 즉시 조기 실행 task를 연결하고, 최종 루프에서는 그 결과를 재사용할 수 있도록 `ContentBlock`에 prefetch 메타를 추가했다.
- [AgentLoopTransitions.Execution.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs)
- `SendWithToolsWithRecoveryAsync(...)`에 prefetch callback을 연결해 OpenAI/vLLM tool SSE 파서가 조기 실행을 요청할 수 있게 했다.
- read-only 도구 조기 실행 헬퍼를 추가해 `file_read`, `glob`, `grep`, `folder_map`, `multi_read` 등 읽기 위주 도구는 스트리밍 도중 바로 실행을 시작하도록 보강했다.
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)
- 메인 루프의 tool-use 요청 경로에 조기 read-only prefetch callback을 연결했다.
- 최종 도구 실행 단계에서는 조기 실행이 이미 끝난 도구의 결과를 재사용해 중복 실행을 피하고, transcript에는 `조기 실행 결과 재사용` 힌트를 남기도록 정리했다.

View File

@@ -75,6 +75,11 @@ public partial class App : System.Windows.Application
var settings = _settings;
settings.Load();
// ─── 워크플로우 상세 로그 초기화 ─────────────────────────────────────
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
try
{

View File

@@ -995,6 +995,14 @@ public class LlmSettings
[JsonPropertyName("enableAuditLog")]
public bool EnableAuditLog { get; set; } = true;
/// <summary>워크플로우 상세 로그 기록 활성화. 워크플로우 분석기와 함께 사용하면 LLM 요청/응답, 도구 호출 전체 이력을 기록합니다.</summary>
[JsonPropertyName("enableDetailedLog")]
public bool EnableDetailedLog { get; set; } = false;
/// <summary>상세 로그 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. 기본 3일.</summary>
[JsonPropertyName("detailedLogRetentionDays")]
public int DetailedLogRetentionDays { get; set; } = 3;
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
[JsonPropertyName("enableAgentMemory")]
public bool EnableAgentMemory { get; set; } = true;

View File

@@ -143,10 +143,15 @@ public partial class AgentLoopService
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
var tasks = executableCalls.Select(async call =>
{
await gate.WaitAsync(ct).ConfigureAwait(false);
var tool = _tools.Get(call.ToolName);
// gate.WaitAsync를 try 안에서 호출: ct 취소 시 WaitAsync가 OperationCanceledException을
// 던져도 Release()가 잘못 호출되지 않도록 보호 (SemaphoreFullException 방지)
var acquired = false;
try
{
await gate.WaitAsync(ct).ConfigureAwait(false);
acquired = true;
var tool = _tools.Get(call.ToolName);
if (tool == null)
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
@@ -154,19 +159,32 @@ public partial class AgentLoopService
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, messages, ct);
// 병렬 실행 중에는 messages를 null로 전달:
// 훅이 messages.Add()를 동시 호출하면 List<T> race condition 발생.
// 읽기 전용 도구이므로 hook 추가 컨텍스트는 이 배치 후 순차로 처리됨.
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, null, ct);
sw.Stop();
return (call, result, sw.ElapsedMilliseconds);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행이 취소되었습니다: {call.ToolName}"), sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// WaitAsync 도중 취소됨 — 세마포어 미취득 상태이므로 Release 하지 않음
return (call, ToolResult.Fail($"도구 실행 대기 중 취소됨: {call.ToolName}"), 0L);
}
finally
{
gate.Release();
if (acquired) gate.Release();
}
}).ToList();
@@ -241,6 +259,11 @@ public partial class AgentLoopService
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
// 워크플로우 상세 로그: 병렬 도구 실행 결과
WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
call.ToolName, TruncateOutput(result.Output, 2000),
result.Success, 0);
}
}
}

View File

@@ -150,6 +150,10 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
// 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
userPrompt: userQuery);
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
var totalToolCalls = 0; // 복잡도 추정용
string? lastFailedToolSignature = null;
@@ -510,6 +514,7 @@ public partial class AgentLoopService
// LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew();
try
{
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
@@ -521,14 +526,43 @@ public partial class AgentLoopService
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
// IBM/Qwen 등 chatty 모델 대응: 첫 번째 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder 주입.
// recovery 메시지가 이미 추가된 경우(NoToolCallLoopRetry > 0)에는 중복 주입하지 않음.
// 임시 메시지이므로 실제 messages 목록은 수정하지 않고, 별도 sendMessages로 전달.
List<ChatMessage> sendMessages = messages;
if (forceFirst
&& executionPolicy.InjectPreCallToolReminder
&& runState.NoToolCallLoopRetry == 0)
{
sendMessages = [.. messages, new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
"Call at least one tool RIGHT NOW. Text-only response is rejected."
}];
}
// 워크플로우 상세 로그: LLM 요청
llmCallSw.Restart();
var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
blocks = await SendWithToolsWithRecoveryAsync(
messages,
sendMessages,
activeTools,
ct,
$"메인 루프 {iteration}",
runState,
forceToolCall: forceFirst);
forceToolCall: forceFirst,
prefetchToolCallAsync: block => TryPrefetchReadOnlyToolAsync(
block,
activeTools,
context,
ct));
runState.ContextRecoveryAttempts = 0;
llmCallSw.Stop();
runState.TransientLlmErrorRetries = 0;
NotifyPostCompactionTurnIfNeeded(runState);
}
@@ -646,6 +680,13 @@ public partial class AgentLoopService
var textResponse = string.Join("\n", textParts);
consecutiveNoToolResponses = toolCalls.Count == 0 ? consecutiveNoToolResponses + 1 : 0;
// 워크플로우 상세 로그: LLM 응답
WorkflowLogService.LogLlmResponse(_conversationId, _currentRunId, iteration,
textResponse, toolCalls.Count,
_llm.LastTokenUsage?.PromptTokens ?? 0,
_llm.LastTokenUsage?.CompletionTokens ?? 0,
llmCallSw.ElapsedMilliseconds);
// Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
{
@@ -1126,22 +1167,27 @@ public partial class AgentLoopService
repeatedUnknownToolCount = 0;
lastDisallowedToolName = null;
repeatedDisallowedToolCount = 0;
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
var effectiveToolName = !string.IsNullOrWhiteSpace(call.ResolvedToolName)
? call.ResolvedToolName
: resolvedToolName;
var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase)
? call
: new LlmService.ContentBlock
{
Type = call.Type,
Text = call.Text,
ToolName = resolvedToolName,
ToolName = effectiveToolName,
ToolId = call.ToolId,
ToolInput = call.ToolInput,
ResolvedToolName = effectiveToolName,
PrefetchedExecutionTask = call.PrefetchedExecutionTask,
};
if (!string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase))
{
EmitEvent(
AgentEventType.Thinking,
resolvedToolName,
$"도구명 정규화 적용: '{call.ToolName}' → '{resolvedToolName}'");
effectiveToolName,
$"도구명 정규화 적용: '{call.ToolName}' → '{effectiveToolName}'");
}
var toolCallSignature = BuildToolCallSignature(effectiveCall);
@@ -1279,11 +1325,37 @@ public partial class AgentLoopService
}
ToolResult result;
long elapsedMs;
var sw = Stopwatch.StartNew();
try
{
var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
if (effectiveCall.PrefetchedExecutionTask != null)
{
var prefetched = await effectiveCall.PrefetchedExecutionTask.ConfigureAwait(false);
if (prefetched != null)
{
result = prefetched.Result;
elapsedMs = prefetched.ElapsedMilliseconds;
EmitEvent(
AgentEventType.Thinking,
effectiveCall.ToolName,
$"조기 실행 결과 재사용: {effectiveCall.ToolName}");
}
else
{
var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
sw.Stop();
elapsedMs = sw.ElapsedMilliseconds;
}
}
else
{
var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
sw.Stop();
elapsedMs = sw.ElapsedMilliseconds;
}
}
catch (OperationCanceledException)
{
@@ -1300,8 +1372,9 @@ public partial class AgentLoopService
catch (Exception ex)
{
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
sw.Stop();
elapsedMs = sw.ElapsedMilliseconds;
}
sw.Stop();
// ── Post-Hook 실행 ──
if (llm.EnableToolHooks && runtimeHooks.Count > 0)
@@ -1340,7 +1413,7 @@ public partial class AgentLoopService
effectiveCall.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: sw.ElapsedMilliseconds,
elapsedMs: elapsedMs,
inputTokens: tokenUsage?.PromptTokens ?? 0,
outputTokens: tokenUsage?.CompletionTokens ?? 0,
toolInput: effectiveCall.ToolInput?.ToString(),
@@ -1533,6 +1606,10 @@ public partial class AgentLoopService
if (runtimeOverrideApplied)
_llm.PopInferenceOverride();
// 워크플로우 상세 로그: 에이전트 루프 종료
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "end",
summary: $"iterations={iteration}, tools={totalToolCalls}, success={statsSuccessCount}, fail={statsFailCount}");
IsRunning = false;
_currentRunId = "";
_runPendingPostCompactionTurn = false;

View File

@@ -2,6 +2,8 @@
using AxCopilot.Services;
using System.Text.Json;
using System.Diagnostics;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
@@ -375,7 +377,7 @@ public partial class AgentLoopService
return sawDiff;
}
// 蹂€寃??꾧뎄 ?먯껜媛€ ?놁쑝硫?diff 寃뚯씠?몃? ?붽뎄?? ?딆쓬
// 蹂€寃??꾧뎄 ?먯껜媛€ ?놁쑝硫?diff 寃뚯씠???꾧뎄?? ?뺤씤
return true;
}
@@ -395,7 +397,7 @@ public partial class AgentLoopService
"deletions",
"file changed",
"異붽?",
"??젣");
"?낅뜲?댄듃");
}
private bool TryApplyDocumentArtifactGateTransition(
@@ -413,18 +415,18 @@ public partial class AgentLoopService
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "?붿껌???곗텧臾?臾몄꽌 ?뚯씪" : $"'{suggestedPath}'";
var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "요청한 출력 문서 파일" : $"'{suggestedPath}'";
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:DocumentArtifactGate] 寃곌낵 ?ㅻ챸留뚯쑝濡?醫낅즺?????놁뒿?덈떎. " +
$"吏€湲?利됱떆 {targetHint}???ㅼ젣 ?뚯씪濡??앹꽦?섏꽭?? " +
"html_create/markdown_create/document_assemble/file_write 以??곸젅???꾧뎄瑜?諛섎뱶???몄텧?섏꽭??"
Content = "[System:DocumentArtifactGate] 결과 파일이 누락되었습니다. " +
$"즉시 {targetHint} 파일을 실제로 생성하세요. " +
"html_create/markdown_create/document_assemble/file_write 중 적절한 도구를 호출하세요."
});
EmitEvent(
AgentEventType.Thinking,
"",
$"臾몄꽌 ?묒뾽 ?곗텧臾??뚯씪???놁뼱 ?꾧뎄 ?ㅽ뻾???ъ슂泥?빀?덈떎 ({runState.DocumentArtifactGateRetry}/2)");
$"문서 생성 결과 파일이 없어 재생성이 필요합니다. ({runState.DocumentArtifactGateRetry}/2)");
return true;
}
@@ -689,7 +691,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽瑜?媛먯????ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽媛€ 媛먯??섏뼱 ?ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
return true;
}
@@ -716,7 +718,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"?쎄린 ?꾩슜 ?꾧뎄留??곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜瑜?媛먯??덉뒿?덈떎. ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲?? (threshold={threshold})");
$"?쎄린 ?꾩슜 ?꾧뎄媛€ ?곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜媛€ 媛먯??섏뿀?듬땲?? ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲??(threshold={threshold})");
return true;
}
@@ -750,7 +752,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
return true;
}
@@ -809,8 +811,8 @@ public partial class AgentLoopService
return false;
var lower = content.ToLowerInvariant();
if (ContainsAny(lower, "success", "succeeded", "passed", "?듦낵", "?깃났", "鍮뚮뱶?덉뒿?덈떎", "tests passed", "build succeeded")
&& !ContainsAny(lower, "fail", "failed", "error", "?ㅻ쪟", "?ㅽ뙣", "exception", "denied", "not found"))
if (ContainsAny(lower, "success", "succeeded", "passed", "성공", "tests passed", "build succeeded")
&& !ContainsAny(lower, "fail", "failed", "error", "오류", "실패", "exception", "denied", "not found"))
return false;
return ContainsAny(
@@ -829,7 +831,7 @@ public partial class AgentLoopService
"?ㅻ쪟",
"?덉쇅",
"?쒓컙 珥덇낵",
"沅뚰븳",
"沅뚰븳 嫄곕?",
"李⑤떒",
"찾을 수");
}
@@ -1103,7 +1105,55 @@ public partial class AgentLoopService
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return ToolResult.Fail($"?꾧뎄 ?ㅽ뻾 ?€?꾩븘??({timeoutMs}ms): {toolName}");
return ToolResult.Fail($"도구 실행 시간 초과 ({timeoutMs}ms): {toolName}");
}
}
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
"hash_tool", "image_analyze", "multi_read"
};
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct)
{
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var resolvedToolName = ResolveRequestedToolName(block.ToolName, activeToolNames);
block.ResolvedToolName = resolvedToolName;
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
return null;
var tool = _tools.Get(resolvedToolName);
if (tool == null)
return null;
EmitEvent(
AgentEventType.Thinking,
resolvedToolName,
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
var sw = Stopwatch.StartNew();
try
{
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await ExecuteToolWithTimeoutAsync(tool, resolvedToolName, input, context, null, ct);
sw.Stop();
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
}
catch (Exception ex)
{
sw.Stop();
return new LlmService.ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
sw.ElapsedMilliseconds,
resolvedToolName);
}
}
@@ -1113,7 +1163,8 @@ public partial class AgentLoopService
CancellationToken ct,
string phaseLabel,
RunState? runState = null,
bool forceToolCall = false)
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
@@ -1121,7 +1172,7 @@ public partial class AgentLoopService
{
try
{
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall);
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
}
catch (Exception ex)
{
@@ -1191,6 +1242,11 @@ public partial class AgentLoopService
result.FilePath, result.Success);
}
// 워크플로우 상세 로그: 도구 실행 결과
WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
call.ToolName, TruncateOutput(result.Output, 2000),
result.Success, 0);
totalToolCalls++;
if (totalToolCalls > 15 && maxIterations < baseMax * 2)
maxIterations = Math.Min(baseMax * 2, 50);
@@ -1294,7 +1350,7 @@ public partial class AgentLoopService
result.Output)
});
}
EmitEvent(AgentEventType.Thinking, "", "鍮꾩옱?쒕룄 ?ㅽ뙣濡?遺꾨쪟?섏뼱 ?숈씪 ?몄텧 諛섎났??以묐떒?섍퀬 ?고쉶 ?꾨왂?쇰줈 ?꾪솚?⑸땲??");
EmitEvent(AgentEventType.Thinking, "", "현재도 실패로 오류 발생, 다시 실행 경로로 전환합니다.");
return true;
}
@@ -1310,7 +1366,7 @@ public partial class AgentLoopService
if (memSvc != null && (app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false))
{
memSvc.Add("correction",
$"?꾧뎄 '{call.ToolName}' 諛섎났 ?ㅽ뙣: {TruncateOutput(result.Output, 200)}",
$"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output, 200)}",
$"conv:{_conversationId}", context.WorkFolder);
}
}
@@ -1453,8 +1509,8 @@ public partial class AgentLoopService
taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
? "怨좎쁺??肄붾뱶 蹂€寃쎌씠??李몄“ 寃€?됯낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??.."
: "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??..");
? "怨좎쁺??肄붾뱶 蹂€寃쎌쑝濡?李몄“ 寃€利앷낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??."
: "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??.");
}
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
{
@@ -1541,7 +1597,7 @@ public partial class AgentLoopService
if (devShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 媛쒕컻?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??"));
return (true, null);
}
}
@@ -1562,7 +1618,7 @@ public partial class AgentLoopService
if (scopeShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 媛쒕컻?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
return (true, null);
}
}

View File

@@ -287,6 +287,22 @@ public sealed class AxAgentExecutionEngine
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
static bool IsInternalStatusSummary(string? summary)
{
if (string.IsNullOrWhiteSpace(summary)) return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("반복 ") && summary.Contains('/')
|| summary.Contains("[System:");
}
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
@@ -310,9 +326,11 @@ public sealed class AxAgentExecutionEngine
: fileLine;
}
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외)
var latestSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary) && IsSignificantEventType(evt.Type))
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
&& IsSignificantEventType(evt.Type)
&& !IsInternalStatusSummary(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();

View File

@@ -26,7 +26,12 @@ public static class ModelExecutionProfileCatalog
int RecentExecutionGateMaxRetries,
int ExecutionSuccessGateMaxRetries,
int DocumentVerificationGateMaxRetries,
int TerminalEvidenceGateMaxRetries);
int TerminalEvidenceGateMaxRetries,
/// <summary>
/// true이면 첫 번째 LLM 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder를 주입합니다.
/// IBM Qwen 등 system prompt instruction을 약하게 따르는 chatty 모델용.
/// </summary>
bool InjectPreCallToolReminder = false);
public static string Normalize(string? key)
{
@@ -51,9 +56,9 @@ public static class ModelExecutionProfileCatalog
ForceToolCallAfterPlan: true,
ToolTemperatureCap: 0.2,
NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1,
PlanExecutionRetryMax: 1,
DocumentPlanRetryMax: 1,
NoToolRecoveryMaxRetries: 4, // IBM/Qwen 등 chatty 모델: 재시도 횟수 늘려 도구 호출 강제
PlanExecutionRetryMax: 2,
DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: true,
ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false,
@@ -68,7 +73,8 @@ public static class ModelExecutionProfileCatalog
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1),
TerminalEvidenceGateMaxRetries: 1,
InjectPreCallToolReminder: true), // IBM/Qwen: 첫 호출 직전 reminder 주입으로 이중 강제
"reasoning_first" => new ExecutionPolicy(
"reasoning_first",
"추론 우선",

View File

@@ -1,5 +1,6 @@
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
@@ -22,8 +23,15 @@ public partial class LlmService
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -34,7 +42,8 @@ public partial class LlmService
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct = default,
bool forceToolCall = false)
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
EnsureOperationModeAllowsLlmService(activeService);
@@ -42,7 +51,7 @@ public partial class LlmService
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
@@ -435,7 +444,8 @@ public partial class LlmService
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
bool forceToolCall = false)
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
@@ -453,7 +463,7 @@ public partial class LlmService
string url;
if (isIbmDeployment)
url = BuildIbmDeploymentChatUrl(endpoint, stream: false);
url = BuildIbmDeploymentChatUrl(endpoint, stream: true);
else if (activeService.ToLowerInvariant() == "ollama")
url = endpoint.TrimEnd('/') + "/api/chat";
else
@@ -467,13 +477,28 @@ public partial class LlmService
// CP4D 또는 Bearer 인증 적용
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct);
using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400)
{
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (retryResp.IsSuccessStatusCode)
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct);
}
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
if ((int)resp.StatusCode == 400)
throw new ToolCallNotSupportedException(
@@ -482,83 +507,103 @@ public partial class LlmService
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
}
var rawResp = await resp.Content.ReadAsStringAsync(ct);
return await ReadOpenAiToolBlocksFromStreamAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct);
}
// SSE 형식 응답 사전 처리 (stream:false 요청에도 SSE로 응답하는 경우)
var respJson = ExtractJsonFromSseIfNeeded(rawResp);
/// <summary>
/// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다.
/// 지원 패턴:
/// 1. &lt;tool_call&gt;{"name":"...", "arguments":{...}}&lt;/tool_call&gt;
/// 2. Qwen3 &lt;tool_call&gt;\n{"name":"...", "arguments":{...}}\n&lt;/tool_call&gt;
/// 3. 여러 건의 연속 tool_call 태그
/// </summary>
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
// 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거
// 패턴 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))
{
var trimmedResp = respJson.TrimStart();
if (!trimmedResp.StartsWith('{') && !trimmedResp.StartsWith('['))
throw new ToolCallNotSupportedException(
$"vLLM 응답이 JSON이 아닙니다 (도구 호출 미지원 가능성): {respJson[..Math.Min(120, respJson.Length)]}");
var block = TryParseToolCallJson(m.Groups[1].Value);
if (block != null) results.Add(block);
}
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;
TryParseOpenAiUsage(root);
var blocks = new List<ContentBlock>();
// Ollama 형식: root.message
// OpenAI 형식: root.choices[0].message
JsonElement message;
if (root.TryGetProperty("message", out var ollamaMsg))
message = ollamaMsg;
else if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
message = choices[0].TryGetProperty("message", out var choiceMsg) ? choiceMsg : default;
else
return blocks;
// 텍스트 응답
if (message.TryGetProperty("content", out var content))
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
if (results.Count == 0)
{
var text = content.GetString();
if (!string.IsNullOrWhiteSpace(text))
blocks.Add(new ContentBlock { Type = "text", Text = text });
}
// 도구 호출 (tool_calls 배열)
if (message.TryGetProperty("tool_calls", out var toolCalls))
{
foreach (var tc in toolCalls.EnumerateArray())
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))
{
if (!tc.TryGetProperty("function", out var func)) continue;
// arguments: 표준(OpenAI)은 JSON 문자열, Ollama/qwen 등은 JSON 객체를 직접 반환하기도 함
JsonElement? parsedArgs = null;
if (func.TryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
// 표준: 문자열로 감싸진 JSON → 파싱
try
{
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch { parsedArgs = null; }
}
else if (argsEl.ValueKind == JsonValueKind.Object || argsEl.ValueKind == JsonValueKind.Array)
{
// Ollama/qwen 방식: 이미 JSON 객체 — 그대로 사용
parsedArgs = argsEl.Clone();
}
}
blocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = func.TryGetProperty("name", out var fnm) ? fnm.GetString() ?? "" : "",
ToolId = tc.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
});
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
}
}
return blocks;
// 패턴 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))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
}
}
return results;
}
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
private static ContentBlock? TryParseToolCallJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) return null;
JsonElement? args = null;
if (root.TryGetProperty("arguments", out var a))
args = a.Clone();
else if (root.TryGetProperty("parameters", out var p))
args = p.Clone();
return new ContentBlock
{
Type = "tool_use",
ToolName = name,
ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
ToolInput = args,
};
}
catch { return null; }
}
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
private static ContentBlock? TryParseToolCallJsonWithName(string name, string argsJson)
{
if (string.IsNullOrWhiteSpace(name)) return null;
try
{
using var doc = JsonDocument.Parse(argsJson);
return new ContentBlock
{
Type = "tool_use",
ToolName = name.Trim(),
ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
ToolInput = doc.RootElement.Clone(),
};
}
catch { return null; }
}
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
@@ -688,7 +733,7 @@ public partial class LlmService
["model"] = activeModel,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
["stream"] = true,
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
@@ -704,8 +749,16 @@ public partial class LlmService
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
private object BuildIbmToolBody(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
bool useToolChoice = true)
{
var executionPolicy = GetActiveExecutionPolicy();
var strictToolOnlyDirective =
forceToolCall &&
string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
var msgs = new List<object>();
// 시스템 프롬프트
@@ -778,6 +831,15 @@ public partial class LlmService
msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content });
}
if (strictToolOnlyDirective)
{
msgs.Add(new
{
role = "user",
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
});
}
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
var toolDefs = tools.Select(t =>
{
@@ -806,7 +868,7 @@ public partial class LlmService
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
if (forceToolCall)
if (forceToolCall && useToolChoice)
{
return new
{
@@ -815,7 +877,7 @@ public partial class LlmService
tool_choice = "required",
parameters = new
{
temperature = ResolveTemperature(),
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
@@ -827,12 +889,314 @@ public partial class LlmService
tools = toolDefs,
parameters = new
{
temperature = ResolveTemperature(),
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
private sealed class ToolCallAccumulator
{
public int Index { get; init; }
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StringBuilder Arguments { get; } = new();
public bool Emitted { get; set; }
}
private async Task<List<ContentBlock>> ReadOpenAiToolBlocksFromStreamAsync(
HttpResponseMessage resp,
bool usesIbmDeploymentApi,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
CancellationToken ct)
{
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
var textBuilder = new StringBuilder();
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
var emittedTools = new List<ContentBlock>();
var lastIbmGeneratedText = "";
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
throw new TimeoutException("도구 호출 응답 첫 청크를 받지 못했습니다.");
break;
}
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ", StringComparison.Ordinal))
continue;
firstChunkReceived = true;
var data = line["data: ".Length..].Trim();
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
break;
using var doc = JsonDocument.Parse(data);
var root = doc.RootElement;
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi &&
root.TryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString()
: "IBM vLLM 도구 호출 응답 오류";
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
if (TryExtractMessageToolBlocks(root, textBuilder, emittedTools))
continue;
if (usesIbmDeploymentApi &&
root.TryGetProperty("results", out var resultsEl) &&
resultsEl.ValueKind == JsonValueKind.Array &&
resultsEl.GetArrayLength() > 0)
{
var first = resultsEl[0];
var generatedText = first.TryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.GetString()
: first.TryGetProperty("output_text", out var outputTextEl)
? outputTextEl.GetString()
: null;
if (!string.IsNullOrEmpty(generatedText))
{
if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal))
{
textBuilder.Append(generatedText[lastIbmGeneratedText.Length..]);
lastIbmGeneratedText = generatedText;
}
else
{
textBuilder.Clear();
textBuilder.Append(generatedText);
lastIbmGeneratedText = generatedText;
}
}
}
if (root.TryGetProperty("choices", out var choicesEl) &&
choicesEl.ValueKind == JsonValueKind.Array &&
choicesEl.GetArrayLength() > 0)
{
var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl))
{
if (deltaEl.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var chunk = contentEl.GetString();
if (!string.IsNullOrEmpty(chunk))
textBuilder.Append(chunk);
}
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var toolCallEl in toolCallsEl.EnumerateArray())
{
var index = toolCallEl.TryGetProperty("index", out var indexEl) &&
indexEl.TryGetInt32(out var parsedIndex)
? parsedIndex
: toolAccumulators.Count;
if (!toolAccumulators.TryGetValue(index, out var acc))
{
acc = new ToolCallAccumulator { Index = index };
toolAccumulators[index] = acc;
}
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.GetString() ?? acc.Id;
if (toolCallEl.TryGetProperty("function", out var functionEl))
{
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.GetString() ?? acc.Name;
if (functionEl.TryGetProperty("arguments", out var argumentsEl))
{
if (argumentsEl.ValueKind == JsonValueKind.String)
acc.Arguments.Append(argumentsEl.GetString());
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
acc.Arguments.Append(argumentsEl.GetRawText());
}
}
await TryEmitCompletedToolCallAsync(acc, emittedTools, prefetchToolCallAsync).ConfigureAwait(false);
}
}
}
if (firstChoice.TryGetProperty("message", out var messageEl))
TryExtractMessageToolBlocks(messageEl, textBuilder, emittedTools);
}
}
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))
await TryEmitCompletedToolCallAsync(acc, emittedTools, prefetchToolCallAsync, forceEmit: true).ConfigureAwait(false);
var blocks = new List<ContentBlock>();
var text = textBuilder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(text))
blocks.Add(new ContentBlock { Type = "text", Text = text });
blocks.AddRange(emittedTools);
if (!blocks.Any(b => b.Type == "tool_use"))
{
var textBlock = blocks.FirstOrDefault(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text));
if (textBlock != null)
{
var extracted = TryExtractToolCallsFromText(textBlock.Text);
if (extracted.Count > 0)
{
foreach (var block in extracted)
{
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
}
blocks.AddRange(extracted);
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
}
}
}
return blocks;
}
private static bool TryExtractMessageToolBlocks(
JsonElement messageOrRoot,
StringBuilder textBuilder,
List<ContentBlock> emittedTools)
{
JsonElement message = messageOrRoot;
if (messageOrRoot.TryGetProperty("message", out var nestedMessage))
message = nestedMessage;
var consumed = false;
if (message.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var text = contentEl.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
textBuilder.Append(text);
consumed = true;
}
}
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tc in toolCallsEl.EnumerateArray())
{
if (!tc.TryGetProperty("function", out var functionEl))
continue;
JsonElement? parsedArgs = null;
if (functionEl.TryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
try
{
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch { parsedArgs = null; }
}
else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
parsedArgs = argsEl.Clone();
}
}
emittedTools.Add(new ContentBlock
{
Type = "tool_use",
ToolName = functionEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : "",
ToolId = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
});
}
consumed = true;
}
return consumed;
}
private static bool LooksLikeCompleteJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
return false;
json = json.Trim();
if (!(json.StartsWith('{') || json.StartsWith('[')))
return false;
try
{
using var _ = JsonDocument.Parse(json);
return true;
}
catch
{
return false;
}
}
private static async Task TryEmitCompletedToolCallAsync(
ToolCallAccumulator acc,
List<ContentBlock> emittedTools,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
bool forceEmit = false)
{
if (acc.Emitted || string.IsNullOrWhiteSpace(acc.Name))
return;
var argsJson = acc.Arguments.ToString().Trim();
JsonElement? parsedArgs = null;
if (!string.IsNullOrEmpty(argsJson))
{
if (!forceEmit && !LooksLikeCompleteJson(argsJson))
return;
try
{
using var argsDoc = JsonDocument.Parse(argsJson);
parsedArgs = argsDoc.RootElement.Clone();
}
catch
{
if (!forceEmit)
return;
}
}
var block = new ContentBlock
{
Type = "tool_use",
ToolName = acc.Name,
ToolId = string.IsNullOrWhiteSpace(acc.Id) ? Guid.NewGuid().ToString("N")[..12] : acc.Id,
ToolInput = parsedArgs,
};
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
emittedTools.Add(block);
acc.Emitted = true;
}
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>

View File

@@ -0,0 +1,268 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 워크플로우 상세 로그 서비스.
/// 설정에서 EnableDetailedLog가 true이고 WorkflowVisualizer가 활성화된 경우
/// LLM 요청/응답, 도구 호출/결과, 에이전트 판단 등 상세 이력을 JSON 파일로 기록합니다.
/// 파일 위치: %APPDATA%\AxCopilot\audit\workflow\{yyyy-MM-dd}\{conversationId}.jsonl
/// 보관 기간: DetailedLogRetentionDays 설정에 따라 자동 삭제 (기본 3일).
/// </summary>
public static class WorkflowLogService
{
private static readonly string WorkflowDir;
private static readonly object _lock = new();
private static bool _purged;
private static readonly JsonSerializerOptions _jsonOpts = new()
{
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
static WorkflowLogService()
{
WorkflowDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "audit", "workflow");
try { Directory.CreateDirectory(WorkflowDir); } catch { }
}
/// <summary>상세 로그가 활성 상태인지 확인합니다.</summary>
public static bool IsEnabled { get; set; }
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
public static int RetentionDays { get; set; } = 3;
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
public static void Log(WorkflowLogEntry entry)
{
if (!IsEnabled) return;
try
{
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.ToString("yyyy-MM-dd"));
Directory.CreateDirectory(dayDir);
var safeId = SanitizeFileName(entry.ConversationId);
var filePath = Path.Combine(dayDir, $"{safeId}.jsonl");
var json = JsonSerializer.Serialize(entry, _jsonOpts);
lock (_lock)
{
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
}
if (!_purged) { _purged = true; PurgeOldLogs(); }
}
catch { /* 로깅 실패는 무시 */ }
}
/// <summary>LLM 요청을 기록합니다.</summary>
public static void LogLlmRequest(string conversationId, string runId, int iteration,
string model, int messageCount, int toolCount, bool forceToolCall)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_request",
Iteration = iteration,
Details = new Dictionary<string, object?>
{
["model"] = model,
["messageCount"] = messageCount,
["toolCount"] = toolCount,
["forceToolCall"] = forceToolCall,
}
});
}
/// <summary>LLM 응답을 기록합니다.</summary>
public static void LogLlmResponse(string conversationId, string runId, int iteration,
string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_response",
Iteration = iteration,
ElapsedMs = elapsedMs,
Details = new Dictionary<string, object?>
{
["textLength"] = textResponse?.Length ?? 0,
["textPreview"] = Truncate(textResponse, 500),
["toolCallCount"] = toolCallCount,
["inputTokens"] = inputTokens,
["outputTokens"] = outputTokens,
}
});
}
/// <summary>도구 호출을 기록합니다.</summary>
public static void LogToolCall(string conversationId, string runId, int iteration,
string toolName, string? parameters, bool isParallel = false)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "tool_call",
Iteration = iteration,
ToolName = toolName,
Details = new Dictionary<string, object?>
{
["parameters"] = Truncate(parameters, 2000),
["isParallel"] = isParallel,
}
});
}
/// <summary>도구 실행 결과를 기록합니다.</summary>
public static void LogToolResult(string conversationId, string runId, int iteration,
string toolName, string? result, bool success, long elapsedMs)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "tool_result",
Iteration = iteration,
ToolName = toolName,
ElapsedMs = elapsedMs,
Details = new Dictionary<string, object?>
{
["result"] = Truncate(result, 2000),
["success"] = success,
}
});
}
/// <summary>에이전트 상태 전환을 기록합니다.</summary>
public static void LogTransition(string conversationId, string runId, int iteration,
string transitionType, string? detail = null)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "transition",
Iteration = iteration,
Details = new Dictionary<string, object?>
{
["type"] = transitionType,
["detail"] = detail,
}
});
}
/// <summary>에이전트 루프 시작/종료를 기록합니다.</summary>
public static void LogAgentLifecycle(string conversationId, string runId,
string phase, string? userPrompt = null, string? summary = null)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = $"agent_{phase}",
Details = new Dictionary<string, object?>
{
["prompt"] = Truncate(userPrompt, 1000),
["summary"] = Truncate(summary, 1000),
}
});
}
/// <summary>보관 기간을 초과한 로그를 삭제합니다.</summary>
public static void PurgeOldLogs()
{
try
{
var cutoff = DateTime.Now.Date.AddDays(-RetentionDays);
foreach (var dir in Directory.GetDirectories(WorkflowDir))
{
var dirName = Path.GetFileName(dir);
if (DateTime.TryParseExact(dirName, "yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dirDate))
{
if (dirDate < cutoff)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
}
}
catch { }
}
/// <summary>특정 대화의 워크플로우 로그를 읽습니다.</summary>
public static List<WorkflowLogEntry> LoadConversation(string conversationId, int daysBack = 3)
{
var entries = new List<WorkflowLogEntry>();
var safeId = SanitizeFileName(conversationId);
for (int d = 0; d < daysBack; d++)
{
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.AddDays(-d).ToString("yyyy-MM-dd"));
var filePath = Path.Combine(dayDir, $"{safeId}.jsonl");
if (!File.Exists(filePath)) continue;
try
{
foreach (var line in File.ReadAllLines(filePath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var entry = JsonSerializer.Deserialize<WorkflowLogEntry>(line, _jsonOpts);
if (entry != null) entries.Add(entry);
}
}
catch { }
}
return entries.OrderBy(e => e.Timestamp).ToList();
}
/// <summary>워크플로우 로그 폴더 경로.</summary>
public static string GetLogFolder() => WorkflowDir;
private static string Truncate(string? s, int maxLen) =>
string.IsNullOrEmpty(s) ? "" : s.Length <= maxLen ? s : s[..maxLen] + "…";
private static string SanitizeFileName(string name) =>
string.IsNullOrEmpty(name) ? "unknown"
: string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
}
/// <summary>워크플로우 상세 로그 항목.</summary>
public class WorkflowLogEntry
{
[JsonPropertyName("ts")]
public DateTime Timestamp { get; init; } = DateTime.Now;
[JsonPropertyName("convId")]
public string ConversationId { get; init; } = "";
[JsonPropertyName("runId")]
public string RunId { get; init; } = "";
[JsonPropertyName("event")]
public string EventType { get; init; } = "";
[JsonPropertyName("iter")]
public int Iteration { get; init; }
[JsonPropertyName("tool")]
public string? ToolName { get; init; }
[JsonPropertyName("ms")]
public long ElapsedMs { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, object?>? Details { get; init; }
}

View File

@@ -407,6 +407,20 @@ public class SettingsViewModel : INotifyPropertyChanged
set { _enableAuditLog = value; OnPropertyChanged(); }
}
private bool _enableDetailedLog;
public bool EnableDetailedLog
{
get => _enableDetailedLog;
set { _enableDetailedLog = value; OnPropertyChanged(); }
}
private int _detailedLogRetentionDays;
public int DetailedLogRetentionDays
{
get => _detailedLogRetentionDays;
set { _detailedLogRetentionDays = Math.Clamp(value, 1, 30); OnPropertyChanged(); }
}
private bool _enableAgentMemory;
public bool EnableAgentMemory
{
@@ -1156,6 +1170,8 @@ public class SettingsViewModel : INotifyPropertyChanged
_enableFilePathHighlight = llm.EnableFilePathHighlight;
_folderDataUsage = string.IsNullOrEmpty(llm.FolderDataUsage) ? "active" : llm.FolderDataUsage;
_enableAuditLog = llm.EnableAuditLog;
_enableDetailedLog = llm.EnableDetailedLog;
_detailedLogRetentionDays = llm.DetailedLogRetentionDays > 0 ? llm.DetailedLogRetentionDays : 3;
_enableAgentMemory = llm.EnableAgentMemory;
_enableProjectRules = llm.EnableProjectRules;
_maxMemoryEntries = llm.MaxMemoryEntries;
@@ -1600,6 +1616,8 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Llm.EnableFilePathHighlight = _enableFilePathHighlight;
s.Llm.FolderDataUsage = _folderDataUsage;
s.Llm.EnableAuditLog = _enableAuditLog;
s.Llm.EnableDetailedLog = _enableDetailedLog;
s.Llm.DetailedLogRetentionDays = _detailedLogRetentionDays;
s.Llm.EnableAgentMemory = _enableAgentMemory;
s.Llm.EnableProjectRules = _enableProjectRules;
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
@@ -1786,6 +1804,11 @@ public class SettingsViewModel : INotifyPropertyChanged
sc.CommandAliases = cmdAliases;
_service.Save();
// 워크플로우 상세 로그 설정 즉시 반영
WorkflowLogService.IsEnabled = _enableDetailedLog;
WorkflowLogService.RetentionDays = _detailedLogRetentionDays > 0 ? _detailedLogRetentionDays : 3;
SaveCompleted?.Invoke(this, EventArgs.Empty);
}

View File

@@ -12,6 +12,10 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
// 레이아웃 재계산 억제용 캐시: 동일한 높이면 WPF measure/arrange 생략
private double _cachedInputBoxHeight = -1;
private int _cachedInputBoxMaxLines = -1;
private void UpdateInputBoxHeight()
{
if (InputBox == null)
@@ -33,11 +37,19 @@ public partial class ChatWindow
const double lineStep = 22;
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
var needsScroll = explicitLineCount > maxLines;
// 값이 바뀐 경우에만 WPF 속성 쓰기 (매 키입력마다 레이아웃 통과 방지)
if (Math.Abs(targetHeight - _cachedInputBoxHeight) < 0.5 && maxLines == _cachedInputBoxMaxLines)
return;
_cachedInputBoxHeight = targetHeight;
_cachedInputBoxMaxLines = maxLines;
InputBox.MinLines = 1;
InputBox.MaxLines = maxLines;
InputBox.Height = targetHeight;
InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines
InputBox.VerticalScrollBarVisibility = needsScroll
? ScrollBarVisibility.Auto
: ScrollBarVisibility.Disabled;
}

View File

@@ -6,6 +6,11 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
// 토큰 추정 캐시: 메시지 수/대화 ID가 바뀔 때만 재계산
private int _cachedMessageTokens;
private int _cachedMessageCountForTokens = -1;
private string? _cachedConvIdForTokens;
private void RefreshContextUsageVisual()
{
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
@@ -27,11 +32,22 @@ public partial class ChatWindow
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
var triggerRatio = triggerPercent / 100.0;
// 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지)
int messageTokens;
lock (_convLock)
messageTokens = _currentConversation?.Messages?.Count > 0
? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages)
: 0;
{
var convId = _currentConversation?.Id;
var msgCount = _currentConversation?.Messages?.Count ?? 0;
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens)
{
_cachedMessageTokens = msgCount > 0
? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages)
: 0;
_cachedConvIdForTokens = convId;
_cachedMessageCountForTokens = msgCount;
}
messageTokens = _cachedMessageTokens;
}
var draftText = InputBox?.Text ?? "";
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;

View File

@@ -1750,41 +1750,45 @@
<!-- 주 행: 다이아몬드 아이콘 + 상태 텍스트 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<!-- 미니 다이아몬드 아이콘 (런처 아이콘 축소판, 코드비하인드에서 애니메이션 적용) -->
<Canvas x:Name="StatusDiamondIcon" Width="16" Height="16"
VerticalAlignment="Center" ClipToBounds="False"
Margin="0,1,8,0">
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
<Canvas.RenderTransform>
<TransformGroup>
<RotateTransform x:Name="StatusIconRotate" Angle="45"/>
<ScaleTransform x:Name="StatusIconScale" ScaleX="1" ScaleY="1"/>
</TransformGroup>
</Canvas.RenderTransform>
<!-- 파란 픽셀 (좌상) -->
<Rectangle x:Name="StatusPixelBlue"
Canvas.Left="0.5" Canvas.Top="0.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#4488FF"/>
<!-- 초록 픽셀 (우상) -->
<Rectangle x:Name="StatusPixelGreen1"
Canvas.Left="8.5" Canvas.Top="0.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 초록 픽셀 (좌하) -->
<Rectangle x:Name="StatusPixelGreen2"
Canvas.Left="0.5" Canvas.Top="8.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 빨간 픽셀 (우하) -->
<Rectangle x:Name="StatusPixelRed"
Canvas.Left="8.5" Canvas.Top="8.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#FF4466"/>
</Canvas>
<!-- 회전 후 실제 차지 크기 ≈ 22px이므로 외부 Border로 공간 확보 + 센터링 -->
<Border Width="20" Height="16" VerticalAlignment="Center" Margin="0,0,6,0"
ClipToBounds="False" Background="Transparent">
<Canvas x:Name="StatusDiamondIcon" Width="16" Height="16"
HorizontalAlignment="Center" VerticalAlignment="Center"
ClipToBounds="False">
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
<Canvas.RenderTransform>
<TransformGroup>
<RotateTransform x:Name="StatusIconRotate" Angle="45"/>
<ScaleTransform x:Name="StatusIconScale" ScaleX="1" ScaleY="1"/>
</TransformGroup>
</Canvas.RenderTransform>
<!-- 파란 픽셀 (좌상) -->
<Rectangle x:Name="StatusPixelBlue"
Canvas.Left="1" Canvas.Top="1"
Width="6" Height="6"
RadiusX="1" RadiusY="1"
Fill="#4488FF"/>
<!-- 초록 픽셀 (우상) -->
<Rectangle x:Name="StatusPixelGreen1"
Canvas.Left="9" Canvas.Top="1"
Width="6" Height="6"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 초록 픽셀 (좌하) -->
<Rectangle x:Name="StatusPixelGreen2"
Canvas.Left="1" Canvas.Top="9"
Width="6" Height="6"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 빨간 픽셀 (우하) -->
<Rectangle x:Name="StatusPixelRed"
Canvas.Left="9" Canvas.Top="9"
Width="6" Height="6"
RadiusX="1" RadiusY="1"
Fill="#FF4466"/>
</Canvas>
</Border>
<!-- MDL2 아이콘 (호환성 유지, 숨김) -->
<TextBlock x:Name="PulseDotStatusIcon"
FontFamily="Segoe MDL2 Assets"

View File

@@ -231,7 +231,7 @@ public partial class ChatWindow : Window
_conversationSearchTimer.Stop();
RefreshConversationList();
};
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_inputUiRefreshTimer.Tick += (_, _) =>
{
_inputUiRefreshTimer.Stop();
@@ -6016,45 +6016,51 @@ public partial class ChatWindow : Window
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var sb = new System.Text.StringBuilder();
// ══════════════════════════════════════════════════════════════════
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
// ══════════════════════════════════════════════════════════════════
sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)");
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure.");
sb.AppendLine("");
sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always");
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
sb.AppendLine("Every single response MUST include at least one tool call.");
sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '작업을 시작하겠습니다' / '먼저 ... 하겠습니다'");
sb.AppendLine(" ✗ '다음과 같이 진행하겠습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...' / 'First, I need to ...'");
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 간단한 설명은 도구 결과 이후에만 허용됩니다.");
sb.AppendLine("CORRECT: call the tool immediately. A brief explanation may follow AFTER the tool result, never before.");
sb.AppendLine("");
sb.AppendLine("### [규칙 3] 한 번에 여러 도구를 호출하라 — Batch Multiple Tools");
sb.AppendLine("독립적인 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 순차적으로 하지 마세요.");
sb.AppendLine("You MUST call multiple tools in a single response when tasks are independent. Never do them one by one.");
sb.AppendLine(" 나쁜 예(BAD): 응답1: folder_map → 응답2: document_read → 응답3: html_create (순차 처리 — 금지)");
sb.AppendLine(" 좋은 예(GOOD): 응답1: folder_map + document_read 동시 호출 → 응답2: html_create (배치 처리)");
sb.AppendLine("");
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty");
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때도 사용자에게 묻지 말고 즉시 folder_map이나 document_read를 호출하세요.");
sb.AppendLine("If unsure which file to read: call folder_map or document_read NOW. Do NOT ask the user.");
sb.AppendLine("");
sb.AppendLine("### [규칙 5] 완료까지 계속 도구를 호출하라 — Continue Until Complete");
sb.AppendLine("사용자의 요청이 완전히 완료될 때까지 도구 호출을 계속하세요. '작업을 시작했습니다'는 완료가 아닙니다.");
sb.AppendLine("'완료'의 정의: 결과 파일이 실제로 존재하고, 검증되고, 사용자에게 보고된 상태.");
sb.AppendLine("Keep calling tools until fully done. 'Done' = output file exists + verified + reported to user.");
sb.AppendLine("");
// 소개 및 메타 정보
sb.AppendLine("---");
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
sb.AppendLine("\n## TOOL CALLING RULES — ABSOLUTE MANDATORY");
sb.AppendLine("These rules override everything else. Violating them causes task failure.");
sb.AppendLine("");
sb.AppendLine("### Rule 1 — Tools First, Always");
sb.AppendLine("Every single response MUST contain at least one tool call.");
sb.AppendLine("A response with text but NO tool call is INVALID and will be rejected.");
sb.AppendLine("The FIRST thing in your output must be a tool call, not words.");
sb.AppendLine("");
sb.AppendLine("### Rule 2 — No Verbal Preamble");
sb.AppendLine("FORBIDDEN opening phrases (never write these before calling a tool):");
sb.AppendLine(" ✗ '알겠습니다, ...' / '네, ...' / '확인했습니다.' / '작업을 시작하겠습니다.'");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...'");
sb.AppendLine(" ✗ '먼저 ... 하겠습니다.' / '다음과 같이 진행하겠습니다.'");
sb.AppendLine("CORRECT behavior: call the tool immediately. Brief explanation goes AFTER the tool result.");
sb.AppendLine("");
sb.AppendLine("### Rule 3 — Batch Multiple Tools in One Response");
sb.AppendLine("You are allowed to call MULTIPLE tools in a single response.");
sb.AppendLine("When tasks are independent, call them all at once — do NOT do them one by one across separate responses.");
sb.AppendLine("Example: creating a document requires reading context + creating file → call both in ONE response.");
sb.AppendLine("Example: reviewing a document requires reading it + running checks → call both in ONE response.");
sb.AppendLine("");
sb.AppendLine("### Rule 4 — Act on Uncertainty");
sb.AppendLine("If you are unsure which file to read or which tool to use, DO NOT ask. Call the most likely tool immediately.");
sb.AppendLine("Use folder_map or document_read to orient yourself if needed — but call them now, not 'in the next step'.");
sb.AppendLine("");
sb.AppendLine("### Rule 5 — Continue Until Complete");
sb.AppendLine("Keep calling tools until the user's request is fully and verifiably done.");
sb.AppendLine("'Done' means: the output file exists, has been verified, and the result has been reported. NOT just 'I started working'.");
sb.AppendLine("");
sb.AppendLine("### Bad vs. Good Response Examples");
sb.AppendLine("BAD (text only — REJECTED): '보고서를 작성하겠습니다. 먼저 요구사항을 분석하고...'");
sb.AppendLine("GOOD (tool call first): [call html_create with full content] → then one-line summary");
sb.AppendLine("BAD (sequential one-by-one): Response 1: folder_map / Response 2: document_read / Response 3: create...");
sb.AppendLine("GOOD (batched): Response 1: folder_map + document_read simultaneously / Response 2: create file");
sb.AppendLine("\nOnly present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely.");
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact.");
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
@@ -6160,49 +6166,61 @@ public partial class ChatWindow : Window
var code = llm.Code;
var sb = new System.Text.StringBuilder();
// ══════════════════════════════════════════════════════════════════
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
// ══════════════════════════════════════════════════════════════════
sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)");
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure.");
sb.AppendLine("");
sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always");
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
sb.AppendLine("Every single response MUST include at least one tool call.");
sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — folder_map을 즉시 호출하세요, 예고하지 마세요");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'");
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 한 문장 설명은 도구 결과 이후에만 허용됩니다.");
sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, never precede it.");
sb.AppendLine("");
sb.AppendLine("### [규칙 3] 모든 독립 작업은 병렬로 호출하라 — Parallelise Everything Possible");
sb.AppendLine("독립적인 읽기/검색 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 절대 순차적으로 하지 마세요.");
sb.AppendLine("You MUST call multiple tools in a single response for independent operations. Do this aggressively.");
sb.AppendLine(" 나쁜 예(BAD): R1: folder_map → R2: grep → R3: file_read → R4: file_edit (순차 — 금지)");
sb.AppendLine(" 좋은 예(GOOD): R1: folder_map + grep + file_read 동시 호출 → R2: file_edit + build_run");
sb.AppendLine("모든 독립적인 읽기/검색 작업은 같은 응답에 배치해야 합니다.");
sb.AppendLine("Every independent read/search operation MUST be batched into the same response.");
sb.AppendLine("");
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty, Do Not Ask");
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때: 즉시 glob이나 grep을 호출하세요.");
sb.AppendLine("의존성이 불확실할 때: 즉시 dev_env_detect를 호출하세요.");
sb.AppendLine("If unsure which file: call glob or grep RIGHT NOW. If unsure about deps: call dev_env_detect RIGHT NOW.");
sb.AppendLine("'잘 모르겠습니다, 알려주시겠어요?' — 도구 호출로 답을 구할 수 있다면 절대 사용자에게 묻지 마세요.");
sb.AppendLine("Never say 'I'm not sure, could you clarify?' when a tool call would answer the question.");
sb.AppendLine("");
sb.AppendLine("### [규칙 5] 도구로 검증하라, 가정하지 마라 — Verify with Tools, Not Assumptions");
sb.AppendLine("편집 후에는 반드시 file_read로 최종 상태를 확인하세요. 편집이 성공했다고 가정하지 마세요.");
sb.AppendLine("빌드/테스트 후에는 build_run의 실제 출력을 인용하세요. '정상 작동할 것입니다'라고 말하지 마세요.");
sb.AppendLine("After editing: call file_read to confirm. Do not assume the edit succeeded.");
sb.AppendLine("After build/test: cite actual build_run output. Do not say 'it should work'.");
sb.AppendLine("'완료'의 정의: 빌드 통과 + 테스트 통과 + 편집 파일 재확인 + 결과 보고 — 모두 도구 출력으로 증명된 상태.");
sb.AppendLine("'Done' = build passes + tests pass + edited files re-read + result reported — all proven by tool output.");
sb.AppendLine("");
// 소개 및 메타 정보
sb.AppendLine("---");
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("\n## TOOL CALLING RULES — ABSOLUTE MANDATORY");
sb.AppendLine("These rules override everything else. Violating them causes task failure.");
sb.AppendLine("");
sb.AppendLine("### Rule 1 — Tools First, Always");
sb.AppendLine("Every single response MUST contain at least one tool call.");
sb.AppendLine("A response with text but NO tool call is INVALID and will be rejected.");
sb.AppendLine("The FIRST thing in your output must be a tool call, not words.");
sb.AppendLine("");
sb.AppendLine("### Rule 2 — No Verbal Preamble");
sb.AppendLine("FORBIDDEN opening phrases (never write these before calling a tool):");
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'");
sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — just call folder_map, don't announce it");
sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, not precede it.");
sb.AppendLine("");
sb.AppendLine("### Rule 3 — Parallelise Everything Possible");
sb.AppendLine("You are allowed to call MULTIPLE tools in a single response. Do this aggressively.");
sb.AppendLine("BAD (sequential): R1: folder_map → R2: grep → R3: file_read → R4: file_edit");
sb.AppendLine("GOOD (parallel): R1: folder_map + grep + file_read simultaneously → R2: file_edit + build_run");
sb.AppendLine("Every independent read/search operation must be batched into the same response.");
sb.AppendLine("");
sb.AppendLine("### Rule 4 — Act on Uncertainty, Do Not Ask");
sb.AppendLine("If you are unsure which file to read: call glob or grep to find it — right now.");
sb.AppendLine("If you are unsure about dependencies: call dev_env_detect — right now.");
sb.AppendLine("Never say 'I'm not sure, could you clarify?' when a tool call would answer the question.");
sb.AppendLine("");
sb.AppendLine("### Rule 5 — Verify with Tools, Not Assumptions");
sb.AppendLine("After editing, call file_read to confirm the final state. Do not assume the edit succeeded.");
sb.AppendLine("After build/test, cite the actual output from build_run. Do not say 'it should work'.");
sb.AppendLine("'Done' means: build passes, tests pass, edited files re-read, result reported — all proven by tool output.");
sb.AppendLine("");
sb.AppendLine("### Bad vs. Good Response Examples");
sb.AppendLine("BAD: '먼저 폴더 구조를 살펴보겠습니다. 그 다음 관련 파일을 찾아서...' (text only)");
sb.AppendLine("GOOD: [folder_map depth=2] + [grep pattern='TargetClass'] simultaneously → analyze results → next step");
sb.AppendLine("BAD: '수정이 완료되었습니다.' (no verification)");
sb.AppendLine("GOOD: [file_read path='...'] to confirm edit → [build_run action='build'] to verify → report pass/fail");
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
@@ -6939,16 +6957,15 @@ public partial class ChatWindow : Window
var sb = new System.Windows.Media.Animation.Storyboard();
// 심장 박동 스케일 펄스 (두 번 박동 후 휴지)
// 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감)
var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames
{
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(2.6)),
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)),
};
var beatTimes = new (double t, double v)[]
{
(0.00, 1.00), (0.12, 1.28), (0.27, 0.96),
(0.40, 1.14), (0.55, 1.00), (2.60, 1.00),
(0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00),
};
foreach (var (t, v) in beatTimes)
{
@@ -6968,19 +6985,19 @@ public partial class ChatWindow : Window
System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY"));
sb.Children.Add(scaleAnimY);
// 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남)
// 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게)
var pixels = new System.Windows.Shapes.Rectangle?[]
{ StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed };
for (int i = 0; i < pixels.Length; i++)
{
if (pixels[i] == null) continue;
var fade = new System.Windows.Media.Animation.DoubleAnimation(
fromValue: 1.0, toValue: 0.45,
duration: new System.Windows.Duration(TimeSpan.FromSeconds(0.85)))
fromValue: 1.0, toValue: 0.55,
duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2)))
{
AutoReverse = true,
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
BeginTime = TimeSpan.FromSeconds(i * 0.22),
BeginTime = TimeSpan.FromSeconds(i * 0.30),
EasingFunction = new System.Windows.Media.Animation.SineEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut },
};
@@ -8925,6 +8942,12 @@ public partial class ChatWindow : Window
activeCts.Cancel();
else
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
// 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음
StopLiveAgentProgressHints();
RemoveAgentLiveCard();
HideStickyProgress();
StopRainbowGlow();
}
// ─── 대화 내보내기 ──────────────────────────────────────────────────

View File

@@ -5575,6 +5575,67 @@
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource RowLabel}" Text="상세 워크플로우 로그"/>
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border.ToolTip>
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18">
LLM 요청/응답, 도구 호출/결과 등 에이전트 실행의 전체 이력을 상세하게 기록합니다.
<LineBreak/>워크플로우 분석기와 함께 사용하면 디버깅에 유용합니다.
<LineBreak/>보관 기간이 지난 로그는 자동으로 삭제됩니다.
</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Style="{StaticResource RowHint}" Text="에이전트 워크플로우 상세 이력을 기록합니다. 디버깅 및 도구 미호출 문제 분석에 활용됩니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableDetailedLog, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Style="{StaticResource RowLabel}" Text="상세 로그 보관 기간"/>
<TextBlock Style="{StaticResource RowHint}" Text="이 기간이 지난 상세 로그는 자동으로 삭제됩니다. (1~30일)"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBox Width="45" Height="28" TextAlignment="Center" FontSize="12"
VerticalContentAlignment="Center"
BorderBrush="{DynamicResource BorderColor}"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource ComposerBackground}"
Text="{Binding DetailedLogRetentionDays, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="일" VerticalAlignment="Center" Margin="6,0,0,0"
Foreground="{DynamicResource SecondaryText}" FontSize="12"/>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="상세 로그 보기"/>
<TextBlock Style="{StaticResource RowHint}" Text="워크플로우 상세 로그가 저장된 폴더를 엽니다."/>
</StackPanel>
<Button HorizontalAlignment="Right" VerticalAlignment="Center"
Click="BtnOpenWorkflowLog_Click" Padding="12,6" Cursor="Hand"
Background="Transparent" BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1" Foreground="{DynamicResource PrimaryText}">
<TextBlock Text="폴더 열기" FontSize="12"/>
</Button>
</Grid>
</Border>
<!-- ── 에이전트 고급 ── -->
<TextBlock Style="{StaticResource SectionHeader}" Text="에이전트 고급"/>
<Border Style="{StaticResource SettingsRow}">

View File

@@ -3694,6 +3694,12 @@ public partial class SettingsWindow : Window
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
}
// ─── 워크플로우 상세 로그 폴더 열기 ───────────────────────────────────
private void BtnOpenWorkflowLog_Click(object sender, RoutedEventArgs e)
{
try { System.Diagnostics.Process.Start("explorer.exe", Services.WorkflowLogService.GetLogFolder()); } catch { }
}
// ─── 폴백/MCP 텍스트 박스 로드/저장 ───────────────────────────────────
private void BuildFallbackModelsPanel()
{