diff --git a/README.md b/README.md index 03c4314..40a24a9 100644 --- a/README.md +++ b/README.md @@ -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 도구는 조립이 끝나는 즉시 조기 실행을 시작하고, 최종 루프에서는 그 결과를 재사용하도록 바꿔 도구 착수 속도를 끌어올렸습니다. diff --git a/dist/AxCopilot/AxCopilot.exe b/dist/AxCopilot/AxCopilot.exe index bd143e9..a1b1a68 100644 Binary files a/dist/AxCopilot/AxCopilot.exe and b/dist/AxCopilot/AxCopilot.exe differ diff --git a/dist/AxCopilot_Setup.exe b/dist/AxCopilot_Setup.exe index bc87538..72ecd86 100644 Binary files a/dist/AxCopilot_Setup.exe and b/dist/AxCopilot_Setup.exe differ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cf27a15..26167b0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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에는 `조기 실행 결과 재사용` 힌트를 남기도록 정리했다. diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index f53effb..dac3c84 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -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 { diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 7d90573..7cfb940 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -995,6 +995,14 @@ public class LlmSettings [JsonPropertyName("enableAuditLog")] public bool EnableAuditLog { get; set; } = true; + /// 워크플로우 상세 로그 기록 활성화. 워크플로우 분석기와 함께 사용하면 LLM 요청/응답, 도구 호출 전체 이력을 기록합니다. + [JsonPropertyName("enableDetailedLog")] + public bool EnableDetailedLog { get; set; } = false; + + /// 상세 로그 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. 기본 3일. + [JsonPropertyName("detailedLogRetentionDays")] + public int DetailedLogRetentionDays { get; set; } = 3; + /// 에이전트 메모리 (지속적 학습) 활성화. 기본 true. [JsonPropertyName("enableAgentMemory")] public bool EnableAgentMemory { get; set; } = true; diff --git a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs index 27d8a09..d45c073 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs @@ -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 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); } } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 71c7481..3056103 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -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 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 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; diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 3ebaf96..173f95a 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -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 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 TryPrefetchReadOnlyToolAsync( + LlmService.ContentBlock block, + IReadOnlyCollection 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>? 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); } } diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs index 317ae9d..be331a0 100644 --- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -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(); diff --git a/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs b/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs index 58f7e0b..242b557 100644 --- a/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs +++ b/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs @@ -26,7 +26,12 @@ public static class ModelExecutionProfileCatalog int RecentExecutionGateMaxRetries, int ExecutionSuccessGateMaxRetries, int DocumentVerificationGateMaxRetries, - int TerminalEvidenceGateMaxRetries); + int TerminalEvidenceGateMaxRetries, + /// + /// true이면 첫 번째 LLM 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder를 주입합니다. + /// IBM Qwen 등 system prompt instruction을 약하게 따르는 chatty 모델용. + /// + 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", "추론 우선", diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index 0e3bcdc..068fedd 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -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? PrefetchedExecutionTask { get; set; } } + public sealed record ToolPrefetchResult( + Agent.ToolResult Result, + long ElapsedMilliseconds, + string? ResolvedToolName = null); + /// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다. /// /// true이면 tool_choice: "required"를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. @@ -34,7 +42,8 @@ public partial class LlmService List messages, IReadOnlyCollection tools, CancellationToken ct = default, - bool forceToolCall = false) + bool forceToolCall = false, + Func>? 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> SendOpenAiWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct, - bool forceToolCall = false) + bool forceToolCall = false, + Func>? 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); + /// + /// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다. + /// 지원 패턴: + /// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call> + /// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call> + /// 3. 여러 건의 연속 tool_call 태그 + /// + private static List TryExtractToolCallsFromText(string text) + { + var results = new List(); + if (string.IsNullOrWhiteSpace(text)) return results; - // 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거 + // 패턴 1: ... 태그 (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(); - - // 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; + } + + /// {"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환. + 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; } + } + + /// 이름과 arguments JSON이 별도로 주어진 경우. + 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 messages, IReadOnlyCollection 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 } /// IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음. - private object BuildIbmToolBody(List messages, IReadOnlyCollection tools, bool forceToolCall = false) + private object BuildIbmToolBody( + List messages, + IReadOnlyCollection 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(); // 시스템 프롬프트 @@ -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> ReadOpenAiToolBlocksFromStreamAsync( + HttpResponseMessage resp, + bool usesIbmDeploymentApi, + Func>? 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(); + var emittedTools = new List(); + 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(); + 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 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 emittedTools, + Func>? 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; + } + // ─── 공통 헬퍼 ───────────────────────────────────────────────────── /// ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함. diff --git a/src/AxCopilot/Services/WorkflowLogService.cs b/src/AxCopilot/Services/WorkflowLogService.cs new file mode 100644 index 0000000..7c383d4 --- /dev/null +++ b/src/AxCopilot/Services/WorkflowLogService.cs @@ -0,0 +1,268 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AxCopilot.Services; + +/// +/// 에이전트 워크플로우 상세 로그 서비스. +/// 설정에서 EnableDetailedLog가 true이고 WorkflowVisualizer가 활성화된 경우 +/// LLM 요청/응답, 도구 호출/결과, 에이전트 판단 등 상세 이력을 JSON 파일로 기록합니다. +/// 파일 위치: %APPDATA%\AxCopilot\audit\workflow\{yyyy-MM-dd}\{conversationId}.jsonl +/// 보관 기간: DetailedLogRetentionDays 설정에 따라 자동 삭제 (기본 3일). +/// +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 { } + } + + /// 상세 로그가 활성 상태인지 확인합니다. + public static bool IsEnabled { get; set; } + + /// 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. + public static int RetentionDays { get; set; } = 3; + + /// 상세 워크플로우 이벤트를 기록합니다. + 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 { /* 로깅 실패는 무시 */ } + } + + /// LLM 요청을 기록합니다. + 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 + { + ["model"] = model, + ["messageCount"] = messageCount, + ["toolCount"] = toolCount, + ["forceToolCall"] = forceToolCall, + } + }); + } + + /// LLM 응답을 기록합니다. + 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 + { + ["textLength"] = textResponse?.Length ?? 0, + ["textPreview"] = Truncate(textResponse, 500), + ["toolCallCount"] = toolCallCount, + ["inputTokens"] = inputTokens, + ["outputTokens"] = outputTokens, + } + }); + } + + /// 도구 호출을 기록합니다. + 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 + { + ["parameters"] = Truncate(parameters, 2000), + ["isParallel"] = isParallel, + } + }); + } + + /// 도구 실행 결과를 기록합니다. + 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 + { + ["result"] = Truncate(result, 2000), + ["success"] = success, + } + }); + } + + /// 에이전트 상태 전환을 기록합니다. + 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 + { + ["type"] = transitionType, + ["detail"] = detail, + } + }); + } + + /// 에이전트 루프 시작/종료를 기록합니다. + 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 + { + ["prompt"] = Truncate(userPrompt, 1000), + ["summary"] = Truncate(summary, 1000), + } + }); + } + + /// 보관 기간을 초과한 로그를 삭제합니다. + 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 { } + } + + /// 특정 대화의 워크플로우 로그를 읽습니다. + public static List LoadConversation(string conversationId, int daysBack = 3) + { + var entries = new List(); + 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(line, _jsonOpts); + if (entry != null) entries.Add(entry); + } + } + catch { } + } + + return entries.OrderBy(e => e.Timestamp).ToList(); + } + + /// 워크플로우 로그 폴더 경로. + 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())); +} + +/// 워크플로우 상세 로그 항목. +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? Details { get; init; } +} diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 4661e47..f5da364 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -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); } diff --git a/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs b/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs index d7ad7cc..5f943dd 100644 --- a/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs @@ -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; } diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs index adb3fad..4a7b217 100644 --- a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs @@ -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; diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 4b3192a..6590551 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -1750,41 +1750,45 @@ - - 0.5,0.5 - - - - - - - - - - - - - - - + + + + 0.5,0.5 + + + + + + + + + + + + + + + + { _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(); } // ─── 대화 내보내기 ────────────────────────────────────────────────── diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 5818f9c..f6e2264 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -5575,6 +5575,67 @@ + + + + + + + + + + + LLM 요청/응답, 도구 호출/결과 등 에이전트 실행의 전체 이력을 상세하게 기록합니다. + 워크플로우 분석기와 함께 사용하면 디버깅에 유용합니다. + 보관 기간이 지난 로그는 자동으로 삭제됩니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs index 94ef20b..3c3d0c2 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs @@ -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() {