diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index de7fc77..d72bb87 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -1864,6 +1864,42 @@ public class AgentLoopCodeQualityTests delay2.Should().BeLessThan(1850); } + [Fact] + public void IsContextOverflowError_DetectsMaxOutputTokenPatterns() + { + var maxOutput = InvokePrivateStatic( + "IsContextOverflowError", + "Request failed: maximum output tokens exceeded"); + var truncated = InvokePrivateStatic( + "IsContextOverflowError", + "response was truncated due to token limit"); + var unrelated = InvokePrivateStatic( + "IsContextOverflowError", + "invalid api key"); + + maxOutput.Should().BeTrue(); + truncated.Should().BeTrue(); + unrelated.Should().BeFalse(); + } + + [Fact] + public void IsLikelyWithheldOrOverflowResponse_DetectsWithheldAndLimitHints() + { + var withheld = InvokePrivateStatic( + "IsLikelyWithheldOrOverflowResponse", + "output withheld because prompt too long"); + var maxOutput = InvokePrivateStatic( + "IsLikelyWithheldOrOverflowResponse", + "max_output_tokens reached"); + var normal = InvokePrivateStatic( + "IsLikelyWithheldOrOverflowResponse", + "completed successfully"); + + withheld.Should().BeTrue(); + maxOutput.Should().BeTrue(); + normal.Should().BeFalse(); + } + [Fact] public void GetToolExecutionTimeoutMs_UsesDefaultWhenEnvMissing() { diff --git a/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs b/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs index be3e50d..5243196 100644 --- a/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs +++ b/src/AxCopilot.Tests/Services/TaskRunServiceTests.cs @@ -207,6 +207,22 @@ public class TaskRunServiceTests service.ActiveTasks.Should().Contain(t => t.Kind == "permission" && t.Status == "waiting"); } + [Fact] + [Trait("Suite", "ReplayStability")] + public void RestoreRecentFromExecutionEvents_RebuildsSkillCallAsActiveTask() + { + var service = new TaskRunService(); + var now = DateTime.Now; + + service.RestoreRecentFromExecutionEvents( + [ + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-s1", Type = "SkillCall", ToolName = "review_skill", Summary = "skill running", Iteration = 1 }, + new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-s1", Type = "Thinking", Summary = "thinking", Iteration = 2 }, + ]); + + service.ActiveTasks.Should().Contain(t => t.Kind == "skill" && t.Title == "review_skill" && t.Status == "running"); + } + [Fact] public void RestoreRecentFromExecutionEvents_RemovesActiveTaskWhenTerminalEventAppears() { diff --git a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs index 802790c..cfb5861 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs @@ -154,7 +154,7 @@ public partial class AgentLoopService try { var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement; - var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, ct); + var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, messages, ct); sw.Stop(); return (call, result, sw.ElapsedMilliseconds); } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 99618fe..e5c3c06 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -178,6 +178,7 @@ public partial class AgentLoopService var runtimeOverrides = ResolveSkillRuntimeOverrides(messages); var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides); var runtimeOverrideApplied = false; + string? lastUserPromptHookFingerprint = null; var enforceForkExecution = runtimeOverrides?.RequireForkExecution == true && llm.EnableForkSkillDelegationEnforcement; @@ -203,8 +204,54 @@ public partial class AgentLoopService : "")); } + async Task RunRuntimeHooksAsync( + string hookToolName, + string timing, + string? inputJson = null, + string? output = null, + bool success = true) + { + if (!llm.EnableToolHooks || runtimeHooks.Count == 0) + return; + try + { + var selected = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, hookToolName, timing); + var results = await AgentHookRunner.RunAsync( + selected, + hookToolName, + timing, + toolInput: inputJson, + toolOutput: output, + success: success, + workFolder: context.WorkFolder, + timeoutMs: llm.ToolHookTimeoutMs, + ct: ct); + foreach (var pr in results) + { + EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); + ApplyHookAdditionalContext(messages, pr); + ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); + } + } + catch + { + // lifecycle hook failure is non-blocking + } + } + try { + EmitEvent(AgentEventType.SessionStart, "", "에이전트 세션 시작"); + await RunRuntimeHooksAsync( + "__session_start__", + "pre", + JsonSerializer.Serialize(new + { + runId = _currentRunId, + tab = ActiveTab, + workFolder = context.WorkFolder + })); + // ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ── if (planMode == "always") { @@ -382,6 +429,26 @@ public partial class AgentLoopService await Task.Delay(delaySec * 1000, ct); } + var latestUserPrompt = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content; + if (!string.IsNullOrWhiteSpace(latestUserPrompt)) + { + var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}"; + if (!string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal)) + { + lastUserPromptHookFingerprint = fingerprint; + EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출"); + await RunRuntimeHooksAsync( + "__user_prompt_submit__", + "pre", + JsonSerializer.Serialize(new + { + prompt = TruncateOutput(latestUserPrompt, 4000), + runId = _currentRunId, + tab = ActiveTab + })); + } + } + // LLM에 도구 정의와 함께 요청 List blocks; try @@ -453,7 +520,7 @@ public partial class AgentLoopService mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); - var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context) + var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages) ?? await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { @@ -602,6 +669,9 @@ public partial class AgentLoopService continue; } + if (TryHandleWithheldResponseTransition(textResponse, messages, runState)) + continue; + // 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것 // "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회) if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < planExecutionRetryMax) @@ -672,7 +742,7 @@ public partial class AgentLoopService mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); - var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context) + var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages) ?? await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { @@ -1091,10 +1161,17 @@ public partial class AgentLoopService try { var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement; - result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, ct); + result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct); } catch (OperationCanceledException) { + EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다"); + await RunRuntimeHooksAsync( + "__stop_requested__", + "post", + JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }), + "cancelled", + success: false); EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다."); return "사용자가 작업을 취소했습니다."; } @@ -1313,6 +1390,16 @@ public partial class AgentLoopService messages); } + if (ct.IsCancellationRequested) + { + EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다"); + await RunRuntimeHooksAsync( + "__stop_requested__", + "post", + JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }), + "cancelled", + success: false); + } return "(취소됨)"; } finally @@ -3688,7 +3775,7 @@ public partial class AgentLoopService try { var input = tc.ToolInput ?? System.Text.Json.JsonDocument.Parse("{}").RootElement; - var verifyResult = await ExecuteToolWithTimeoutAsync(tool, tc.ToolName, input, context, ct); + var verifyResult = await ExecuteToolWithTimeoutAsync(tool, tc.ToolName, input, context, messages, ct); var toolMsg = LlmService.CreateToolResultMessage( tc.ToolId, tc.ToolName, TruncateOutput(verifyResult.Output, 4000)); messages.Add(toolMsg); @@ -3914,9 +4001,64 @@ public partial class AgentLoopService return trimmed; } - private async Task EnforceToolPermissionAsync(string toolName, JsonElement input, AgentContext context) + private async Task RunPermissionLifecycleHooksAsync( + string hookToolName, + string timing, + string payload, + AgentContext context, + List? messages, + bool success = true) + { + var llm = _settings.Settings.Llm; + if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) + return; + + try + { + var hookResults = await AgentHookRunner.RunAsync( + llm.AgentHooks, + hookToolName, + timing, + toolInput: payload, + success: success, + workFolder: context.WorkFolder, + timeoutMs: llm.ToolHookTimeoutMs); + foreach (var pr in hookResults) + { + EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); + if (messages != null) + ApplyHookAdditionalContext(messages, pr); + ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); + } + } + catch + { + // permission lifecycle hook failure must not block tool permission flow + } + } + + private async Task EnforceToolPermissionAsync( + string toolName, + JsonElement input, + AgentContext context, + List? messages = null) { var target = DescribeToolTarget(toolName, input, context); + var requestPayload = JsonSerializer.Serialize(new + { + runId = _currentRunId, + tool = toolName, + target, + permission = context.GetEffectiveToolPermission(toolName) + }); + await RunPermissionLifecycleHooksAsync( + "__permission_request__", + "pre", + requestPayload, + context, + messages, + success: true); + var effectivePerm = context.GetEffectiveToolPermission(toolName); if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase)) @@ -3927,6 +4069,20 @@ public partial class AgentLoopService { if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase)) EmitEvent(AgentEventType.PermissionGranted, toolName, $"권한 승인됨 · 대상: {target}"); + await RunPermissionLifecycleHooksAsync( + "__permission_granted__", + "post", + JsonSerializer.Serialize(new + { + runId = _currentRunId, + tool = toolName, + target, + permission = effectivePerm, + granted = true + }), + context, + messages, + success: true); return null; } @@ -3934,6 +4090,21 @@ public partial class AgentLoopService ? $"도구 권한이 Deny로 설정되어 차단되었습니다: {toolName}" : $"사용자 승인 없이 실행할 수 없는 도구입니다: {toolName}"; EmitEvent(AgentEventType.PermissionDenied, toolName, $"{reason}\n대상: {target}"); + await RunPermissionLifecycleHooksAsync( + "__permission_denied__", + "post", + JsonSerializer.Serialize(new + { + runId = _currentRunId, + tool = toolName, + target, + permission = effectivePerm, + granted = false, + reason + }), + context, + messages, + success: false); return ToolResult.Fail($"{reason}\n대상: {target}"); } diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 7065982..a9d739d 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -1,4 +1,4 @@ -using AxCopilot.Models; +using AxCopilot.Models; using AxCopilot.Services; using System.Text.Json; @@ -21,10 +21,40 @@ public partial class AgentLoopService "", recovered ? $"컨텍스트 초과를 감지해 자동 복구 후 재시도합니다 ({runState.ContextRecoveryAttempts}/2)" - : $"컨텍스트 초과를 감지했지만 복구 가능한 이전 맥락이 부족합니다 ({runState.ContextRecoveryAttempts}/2)"); + : $"컨텍스트 초과를 감지했지만 복구할 충분한 이력이 없어 재시도하지 못했습니다 ({runState.ContextRecoveryAttempts}/2)"); return recovered; } + private bool TryHandleWithheldResponseTransition( + string? textResponse, + List messages, + RunState runState) + { + if (string.IsNullOrWhiteSpace(textResponse)) + return false; + if (!IsLikelyWithheldOrOverflowResponse(textResponse)) + return false; + if (runState.WithheldRecoveryAttempts >= 2) + return false; + + runState.WithheldRecoveryAttempts++; + var recovered = ForceContextRecovery(messages); + messages.Add(new ChatMessage + { + Role = "user", + Content = "[System:WithheldRecovery] 직전 응답이 길이/토큰 한계로 중단된 것으로 보입니다. " + + "컨텍스트를 정리했으니 직전 작업을 이어서 진행하세요. " + + "필요하면 먼저 핵심만 요약하고 다음 도구 호출로 진행하세요." + }); + EmitEvent( + AgentEventType.Thinking, + "", + recovered + ? $"withheld/길이 제한 응답을 감지해 컨텍스트를 복구하고 재시도합니다 ({runState.WithheldRecoveryAttempts}/2)" + : $"withheld/길이 제한 응답을 감지해 최소 지시로 재시도합니다 ({runState.WithheldRecoveryAttempts}/2)"); + return true; + } + private bool TryApplyCodeCompletionGateTransition( List messages, string? textResponse, @@ -48,12 +78,12 @@ public partial class AgentLoopService { Role = "user", Content = requireHighImpactCodeVerification - ? "[System:CodeQualityGate] 공용/핵심 코드 수정 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확보한 뒤에만 마무리하세요." - : "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 확보한 뒤에만 마무리하세요." + ? "[System:CodeQualityGate] 怨듭슜/?듭떖 肄붾뱶 ?섏젙 ?댄썑 寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 file_read, grep/glob, git diff, build/test源뚯? ?뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂." + : "[System:CodeQualityGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test/file_read/diff 洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 寃€利?洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂." }); EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification - ? "고영향 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..." - : "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다..."); + ? "怨좎쁺??肄붾뱶 蹂€寃쎌쓽 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.." + : "肄붾뱶 寃곌낵 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.."); return true; } @@ -67,10 +97,10 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = "[System:HighImpactBuildTestGate] 고영향 코드 변경입니다. " + - "종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요." + Content = "[System:HighImpactBuildTestGate] 怨좎쁺??肄붾뱶 蹂€寃쎌엯?덈떎. " + + "醫낅즺?섏? 留먭퀬 build_run怨?test_loop瑜?紐⑤몢 ?ㅽ뻾???깃났 洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂." }); - EmitEvent(AgentEventType.Thinking, "", "고영향 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다..."); + EmitEvent(AgentEventType.Thinking, "", "怨좎쁺??蹂€寃쎌씠??build+test ?깃났 洹쇨굅瑜?紐⑤몢 ?뺣낫???뚭퉴吏€ 吏꾪뻾?⑸땲??.."); return true; } @@ -85,7 +115,7 @@ public partial class AgentLoopService Role = "user", Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification) }); - EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경/검증/리스크 요약이 부족해 한 번 더 정리합니다..."); + EmitEvent(AgentEventType.Thinking, "", "理쒖쥌 蹂닿퀬??蹂€寃?寃€利?由ъ뒪???붿빟??遺€議깊빐 ??踰????뺣━?⑸땲??.."); return true; } @@ -112,11 +142,11 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. " + - "git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. " + - "지금 즉시 git_tool 도구를 호출하세요." + Content = "[System:CodeDiffGate] 肄붾뱶 蹂€寃??댄썑 diff 洹쇨굅媛€ 遺€議깊빀?덈떎. " + + "git_tool ?꾧뎄濡?蹂€寃??뚯씪怨??듭떖 diff瑜?癒쇱? ?뺤씤?섍퀬 ?붿빟?섏꽭?? " + + "吏€湲?利됱떆 git_tool ?꾧뎄瑜??몄텧?섏꽭??" }); - EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다..."); + EmitEvent(AgentEventType.Thinking, "", "肄붾뱶 diff 洹쇨굅媛€ 遺€議깊빐 git diff 寃€利앹쓣 異붽??⑸땲??.."); return true; } @@ -146,7 +176,7 @@ public partial class AgentLoopService Role = "user", Content = BuildRecentExecutionEvidencePrompt(taskPolicy) }); - EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다..."); + EmitEvent(AgentEventType.Thinking, "", "理쒓렐 ?섏젙 ?댄썑 ?ㅽ뻾 洹쇨굅媛€ 遺€議깊빐 build/test ?ш?利앹쓣 ?섑뻾?⑸땲??.."); return true; } @@ -176,7 +206,7 @@ public partial class AgentLoopService Role = "user", Content = BuildExecutionSuccessGatePrompt(taskPolicy) }); - EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test를 다시 성공시켜 검증합니다..."); + EmitEvent(AgentEventType.Thinking, "", "?ㅽ뙣???ㅽ뻾 洹쇨굅留??덉뼱 build/test瑜??ㅼ떆 ?깃났?쒖폒 寃€利앺빀?덈떎..."); return true; } @@ -213,7 +243,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})"); + $"醫낅즺 ???ㅽ뻾 利앷굅媛€ 遺€議깊빐 蹂닿컯 ?④퀎瑜?吏꾪뻾?⑸땲??({runState.TerminalEvidenceGateRetry}/{retryMax})"); return true; } @@ -275,27 +305,27 @@ public partial class AgentLoopService { if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)) { - var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "결과 문서 파일" : $"'{lastArtifactFilePath}'"; - return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " + - $"{fileHint}을 실제로 생성/갱신하고 file_read 또는 document_read로 검증한 근거를 남긴 뒤 종료하세요."; + var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{lastArtifactFilePath}'"; + return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛€ 遺€議깊빀?덈떎. " + + $"{fileHint}???ㅼ젣濡??앹꽦/媛깆떊?섍퀬 file_read ?먮뒗 document_read濡?寃€利앺븳 洹쇨굅瑜??④릿 ??醫낅즺?섏꽭??"; } - return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " + - "최소 1개 이상의 진행 도구(수정/실행/생성)를 성공시키고, 그 결과를 근거로 최종 응답을 다시 작성하세요."; + return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛€ 遺€議깊빀?덈떎. " + + "理쒖냼 1媛??댁긽??吏꾪뻾 ?꾧뎄(?섏젙/?ㅽ뻾/?앹꽦)瑜??깃났?쒗궎怨? 洹?寃곌낵瑜?洹쇨굅濡?理쒖쥌 ?묐떟???ㅼ떆 ?묒꽦?섏꽭??"; } private static string BuildRecentExecutionEvidencePrompt(TaskTypePolicy taskPolicy) { var taskHint = taskPolicy.TaskType switch { - "bugfix" => "재현 경로 기준으로 수정 지점이 실제로 해결됐는지 검증하세요.", - "feature" => "신규 동작 경로의 정상/오류 케이스를 최소 1개 이상 확인하세요.", - "refactor" => "기존 동작 보존 여부를 회귀 관점으로 확인하세요.", - _ => "수정 영향 범위를 기준으로 실행 검증을 진행하세요." + "bugfix" => "?ы쁽 寃쎈줈 湲곗??쇰줈 ?섏젙 吏€?먯씠 ?ㅼ젣濡??닿껐?먮뒗吏€ 寃€利앺븯?몄슂.", + "feature" => "?좉퇋 ?숈옉 寃쎈줈???뺤긽/?ㅻ쪟 耳€?댁뒪瑜?理쒖냼 1媛??댁긽 ?뺤씤?섏꽭??", + "refactor" => "湲곗〈 ?숈옉 蹂댁〈 ?щ?瑜??뚭? 愿€?먯쑝濡??뺤씤?섏꽭??", + _ => "?섏젙 ?곹뼢 踰붿쐞瑜?湲곗??쇰줈 ?ㅽ뻾 寃€利앹쓣 吏꾪뻾?섏꽭??" }; - return "[System:RecentExecutionGate] 마지막 코드 수정 이후 build/test 실행 근거가 없습니다. " + - "지금 즉시 build_run 또는 test_loop를 호출해 최신 상태를 검증하고 결과를 남기세요. " + + return "[System:RecentExecutionGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test ?ㅽ뻾 洹쇨굅媛€ ?놁뒿?덈떎. " + + "吏€湲?利됱떆 build_run ?먮뒗 test_loop瑜??몄텧??理쒖떊 ?곹깭瑜?寃€利앺븯怨?寃곌낵瑜??④린?몄슂. " + taskHint; } @@ -303,14 +333,14 @@ public partial class AgentLoopService { var taskHint = taskPolicy.TaskType switch { - "bugfix" => "실패 원인을 먼저 수정한 뒤 동일 재현 경로 기준으로 테스트를 다시 통과시키세요.", - "feature" => "핵심 신규 동작 경로를 포함해 build/test를 통과시키세요.", - "refactor" => "회귀 여부를 확인할 수 있는 테스트를 통과시키세요.", - _ => "수정 영향 범위를 확인할 수 있는 실행 검증을 통과시키세요." + "bugfix" => "?ㅽ뙣 ?먯씤??癒쇱? ?섏젙?????숈씪 ?ы쁽 寃쎈줈 湲곗??쇰줈 ?뚯뒪?몃? ?ㅼ떆 ?듦낵?쒗궎?몄슂.", + "feature" => "?듭떖 ?좉퇋 ?숈옉 寃쎈줈瑜??ы븿??build/test瑜??듦낵?쒗궎?몄슂.", + "refactor" => "?뚭? ?щ?瑜??뺤씤?????덈뒗 ?뚯뒪?몃? ?듦낵?쒗궎?몄슂.", + _ => "?섏젙 ?곹뼢 踰붿쐞瑜??뺤씤?????덈뒗 ?ㅽ뻾 寃€利앹쓣 ?듦낵?쒗궎?몄슂." }; - return "[System:ExecutionSuccessGate] 현재 실행 근거가 실패 결과뿐입니다. " + - "종료하지 말고 build_run 또는 test_loop를 호출해 성공 결과를 확보하세요. " + + return "[System:ExecutionSuccessGate] ?꾩옱 ?ㅽ뻾 洹쇨굅媛€ ?ㅽ뙣 寃곌낵肉먯엯?덈떎. " + + "醫낅즺?섏? 留먭퀬 build_run ?먮뒗 test_loop瑜??몄텧???깃났 寃곌낵瑜??뺣낫?섏꽭?? " + taskHint; } @@ -338,7 +368,7 @@ public partial class AgentLoopService return sawDiff; } - // 변경 도구 자체가 없으면 diff 게이트를 요구하지 않음 + // 蹂€寃??꾧뎄 ?먯껜媛€ ?놁쑝硫?diff 寃뚯씠?몃? ?붽뎄?섏? ?딆쓬 return true; } @@ -356,9 +386,9 @@ public partial class AgentLoopService "changed files", "insertions", "deletions", - "파일 변경", - "추가", - "삭제"); + "file changed", + "異붽?", + "??젣"); } private bool TryApplyDocumentArtifactGateTransition( @@ -376,18 +406,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; } @@ -407,13 +437,13 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = "[System:DocumentVerificationGate] 문서 파일은 생성되었지만 검증 근거가 부족합니다. " + - "file_read 또는 document_read로 방금 생성된 파일을 다시 읽고, 문제 없음/수정 필요를 명시해 보고하세요." + Content = "[System:DocumentVerificationGate] 臾몄꽌 ?뚯씪?€ ?앹꽦?섏뿀吏€留?寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. " + + "file_read ?먮뒗 document_read濡?諛⑷툑 ?앹꽦???뚯씪???ㅼ떆 ?쎄퀬, 臾몄젣 ?놁쓬/?섏젙 ?꾩슂瑜?紐낆떆??蹂닿퀬?섏꽭??" }); EmitEvent( AgentEventType.Thinking, "", - $"문서 검증 근거가 부족해 재검증을 요청합니다 ({runState.DocumentVerificationGateRetry}/1)"); + $"臾몄꽌 寃€利?洹쇨굅媛€ 遺€議깊빐 ?ш?利앹쓣 ?붿껌?⑸땲??({runState.DocumentVerificationGateRetry}/1)"); return true; } @@ -497,11 +527,27 @@ public partial class AgentLoopService || lower.Contains("context window") || lower.Contains("too many tokens") || lower.Contains("max tokens") + || lower.Contains("max_output_tokens") + || lower.Contains("maximum output tokens") || lower.Contains("token limit") || lower.Contains("input is too long") + || lower.Contains("response was truncated") || lower.Contains("maximum context"); } + private static bool IsLikelyWithheldOrOverflowResponse(string response) + { + var lower = response.ToLowerInvariant(); + return lower.Contains("withheld") + || lower.Contains("prompt too long") + || lower.Contains("context window") + || lower.Contains("max_output_tokens") + || lower.Contains("maximum output tokens") + || lower.Contains("token limit") + || lower.Contains("response was truncated") + || lower.Contains("input is too long"); + } + private static bool ForceContextRecovery(List messages) { if (messages.Count < 10) @@ -524,9 +570,9 @@ public partial class AgentLoopService { var content = m.Content ?? ""; if (content.StartsWith("{\"_tool_use_blocks\"")) - content = "[도구 호출 요약]"; + content = "[?꾧뎄 ?몄텧 ?붿빟]"; else if (content.StartsWith("{\"type\":\"tool_result\"")) - content = "[도구 실행 결과 요약]"; + content = "[?꾧뎄 ?ㅽ뻾 寃곌낵 ?붿빟]"; else if (content.Length > 180) content = content[..180] + "..."; return $"- [{m.Role}] {content}"; @@ -535,7 +581,7 @@ public partial class AgentLoopService var summary = summaryLines.Count > 0 ? string.Join("\n", summaryLines) - : "- 이전 대화 맥락이 자동 축약되었습니다."; + : "- ?댁쟾 ?€??留λ씫???먮룞 異뺤빟?섏뿀?듬땲??"; var tail = nonSystem.Skip(Math.Max(0, nonSystem.Count - keepTailCount)).ToList(); @@ -546,7 +592,7 @@ public partial class AgentLoopService { Role = "user", Timestamp = DateTime.Now, - Content = $"[시스템 자동 맥락 축약]\n아래는 이전 대화의 핵심 요약입니다.\n{summary}" + Content = $"[?쒖뒪???먮룞 留λ씫 異뺤빟]\n?꾨옒???댁쟾 ?€?붿쓽 ?듭떖 ?붿빟?낅땲??\n{summary}" }); messages.AddRange(tail); return true; @@ -590,7 +636,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, call.ToolName, - $"동일 도구/파라미터 반복 실패 패턴 감지 - 다른 접근으로 전환합니다 ({repeatedFailedToolSignatureCount}/{maxRetry})"); + $"?숈씪 ?꾧뎄/?뚮씪誘명꽣 諛섎났 ?ㅽ뙣 ?⑦꽩 媛먯? - ?ㅻⅨ ?묎렐?쇰줈 ?꾪솚?⑸땲??({repeatedFailedToolSignatureCount}/{maxRetry})"); return true; } @@ -611,7 +657,7 @@ public partial class AgentLoopService messages.Add(LlmService.CreateToolResultMessage( call.ToolId, call.ToolName, - $"[NO_PROGRESS_LOOP_GUARD] 동일한 읽기 도구 호출이 {repeatedSameSignatureCount}회 반복되었습니다: {toolCallSignature}\n" + + $"[NO_PROGRESS_LOOP_GUARD] ?숈씪???쎄린 ?꾧뎄 ?몄텧??{repeatedSameSignatureCount}??諛섎났?섏뿀?듬땲?? {toolCallSignature}\n" + "Stop repeating the same read-only call and switch to a concrete next action.")); messages.Add(new ChatMessage { @@ -625,7 +671,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, call.ToolName, - $"무의미한 읽기 도구 반복 루프를 감지해 다른 전략으로 전환합니다 ({repeatedSameSignatureCount}회)"); + $"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽瑜?媛먯????ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??"); return true; } @@ -652,7 +698,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"읽기 전용 도구만 연속 {consecutiveReadOnlySuccessTools}회 실행되어 정체를 감지했습니다. 실행 단계로 전환합니다. (threshold={threshold})"); + $"?쎄린 ?꾩슜 ?꾧뎄留??곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜瑜?媛먯??덉뒿?덈떎. ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲?? (threshold={threshold})"); return true; } @@ -686,7 +732,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"실행 진전이 없어 강제 복구 단계를 시작합니다 ({runState.NoProgressRecoveryRetry}/2)"); + $"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎瑜??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)"); return true; } @@ -712,9 +758,9 @@ public partial class AgentLoopService { if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)) { - var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "결과 문서 파일" : $"'{documentPlanPath}'"; - return "[System:NoProgressExecutionRecovery] 읽기/분석만 반복되고 있습니다. " + - $"이제 설명을 멈추고 {fileHint}을 실제로 생성하는 도구(html_create/markdown_create/document_assemble/file_write)를 즉시 호출하세요."; + var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{documentPlanPath}'"; + return "[System:NoProgressExecutionRecovery] ?쎄린/遺꾩꽍留?諛섎났?섍퀬 ?덉뒿?덈떎. " + + $"?댁젣 ?ㅻ챸??硫덉텛怨?{fileHint}???ㅼ젣濡??앹꽦?섎뒗 ?꾧뎄(html_create/markdown_create/document_assemble/file_write)瑜?利됱떆 ?몄텧?섏꽭??"; } return BuildFailureNextToolPriorityPrompt( @@ -745,8 +791,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( @@ -761,13 +807,13 @@ public partial class AgentLoopService "forbidden", "not found", "invalid", - "실패", - "오류", - "예외", - "시간 초과", - "권한", - "차단", - "찾을 수 없"); + "?ㅽ뙣", + "?ㅻ쪟", + "?덉쇅", + "?쒓컙 珥덇낵", + "沅뚰븳", + "李⑤떒", + "찾을 수"); } private static int UpdateConsecutiveReadOnlySuccessTools( @@ -915,9 +961,9 @@ public partial class AgentLoopService { if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)) { - return "[System:NoProgressRecovery] 동일한 읽기 호출이 반복되었습니다. " + - "이제 문서 생성/수정 도구(html_create/markdown_create/document_assemble/file_write) 중 하나를 실제로 호출해 진행하세요. " + - "설명만 하지 말고 즉시 도구를 호출하세요."; + return "[System:NoProgressRecovery] ?숈씪???쎄린 ?몄텧??諛섎났?섏뿀?듬땲?? " + + "?댁젣 臾몄꽌 ?앹꽦/?섏젙 ?꾧뎄(html_create/markdown_create/document_assemble/file_write) 以??섎굹瑜??ㅼ젣濡??몄텧??吏꾪뻾?섏꽭?? " + + "?ㅻ챸留??섏? 留먭퀬 利됱떆 ?꾧뎄瑜??몄텧?섏꽭??"; } return BuildFailureNextToolPriorityPrompt( @@ -940,7 +986,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"일시적 LLM 오류로 재시도합니다 ({runState.TransientLlmErrorRetries}/3, {delayMs}ms 대기)"); + $"?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({runState.TransientLlmErrorRetries}/3, {delayMs}ms ?€湲?"); await Task.Delay(delayMs, ct); return true; } @@ -1023,6 +1069,7 @@ public partial class AgentLoopService string toolName, JsonElement input, AgentContext context, + List? messages, CancellationToken ct) { var timeoutMs = GetToolExecutionTimeoutMs(); @@ -1031,12 +1078,12 @@ public partial class AgentLoopService try { - return await EnforceToolPermissionAsync(toolName, input, context) + return await EnforceToolPermissionAsync(toolName, input, context, messages) ?? await tool.ExecuteAsync(input, context, timeoutCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - return ToolResult.Fail($"도구 실행 타임아웃 ({timeoutMs}ms): {toolName}"); + return ToolResult.Fail($"?꾧뎄 ?ㅽ뻾 ?€?꾩븘??({timeoutMs}ms): {toolName}"); } } @@ -1058,7 +1105,7 @@ public partial class AgentLoopService catch (Exception ex) { if (IsContextOverflowError(ex.Message) - && contextRecoveryRetries < 1 + && contextRecoveryRetries < 2 && ForceContextRecovery(messages)) { contextRecoveryRetries++; @@ -1067,7 +1114,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"{phaseLabel}: 컨텍스트 초과를 감지해 복구 후 재시도합니다 ({contextRecoveryRetries}/1)"); + $"{phaseLabel}: 而⑦뀓?ㅽ듃 珥덇낵瑜?媛먯???蹂듦뎄 ???ъ떆?꾪빀?덈떎 ({contextRecoveryRetries}/2)"); continue; } @@ -1080,7 +1127,7 @@ public partial class AgentLoopService EmitEvent( AgentEventType.Thinking, "", - $"{phaseLabel}: 일시적 LLM 오류로 재시도합니다 ({transientRetries}/3, {delayMs}ms 대기)"); + $"{phaseLabel}: ?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({transientRetries}/3, {delayMs}ms ?€湲?"); await Task.Delay(delayMs, ct); continue; } @@ -1200,7 +1247,7 @@ public partial class AgentLoopService result.Output) }); } - EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({consecutiveErrors}/{maxRetry})"); + EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: ?ㅽ뙣 遺꾩꽍 ???ъ떆??({consecutiveErrors}/{maxRetry})"); return true; } @@ -1223,7 +1270,7 @@ public partial class AgentLoopService result.Output) }); } - EmitEvent(AgentEventType.Thinking, "", "비재시도 실패로 분류되어 동일 호출 반복을 중단하고 우회 전략으로 전환합니다."); + EmitEvent(AgentEventType.Thinking, "", "鍮꾩옱?쒕룄 ?ㅽ뙣濡?遺꾨쪟?섏뼱 ?숈씪 ?몄텧 諛섎났??以묐떒?섍퀬 ?고쉶 ?꾨왂?쇰줈 ?꾪솚?⑸땲??"); return true; } @@ -1239,7 +1286,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); } } @@ -1285,12 +1332,12 @@ public partial class AgentLoopService if (pm.Success) documentPlanPath = pm.Groups[1].Value; var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)"""); if (tm.Success) documentPlanTitle = tm.Groups[1].Value; - var bs = po.IndexOf("--- body 시작 ---", StringComparison.Ordinal); - var be = po.IndexOf("--- body 끝 ---", StringComparison.Ordinal); + var bs = po.IndexOf("--- body ?쒖옉 ---", StringComparison.Ordinal); + var be = po.IndexOf("--- body ??---", StringComparison.Ordinal); if (bs >= 0 && be > bs) - documentPlanScaffold = po[(bs + "--- body 시작 ---".Length)..be].Trim(); + documentPlanScaffold = po[(bs + "--- body ?쒖옉 ---".Length)..be].Trim(); - if (!result.Output.Contains("즉시 실행:", StringComparison.Ordinal)) + if (!result.Output.Contains("利됱떆 ?ㅽ뻾:", StringComparison.Ordinal)) return; var toolHint = result.Output.Contains("html_create", StringComparison.OrdinalIgnoreCase) ? "html_create" : @@ -1299,12 +1346,12 @@ public partial class AgentLoopService messages.Add(new ChatMessage { Role = "user", - Content = $"document_plan이 완료되었습니다. " + - $"위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 " + - $"{toolHint} 도구를 지금 즉시 호출하세요. " + - $"각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요." + Content = $"document_plan???꾨즺?섏뿀?듬땲?? " + + $"??寃곌낵??body/sections??[?댁슜...] 遺€遺꾩쓣 ?ㅼ젣 ?곸꽭 ?댁슜?쇰줈 紐⑤몢 梨꾩썙??" + + $"{toolHint} ?꾧뎄瑜?吏€湲?利됱떆 ?몄텧?섏꽭?? " + + $"媛??뱀뀡留덈떎 諛섎뱶??異⑸텇???댁슜???묒꽦?섍퀬, ?ㅻ챸 ?놁씠 ?꾧뎄瑜?諛붾줈 ?몄텧?섏꽭??" }); - EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완성 — {toolHint} 호출 중..."); + EmitEvent(AgentEventType.Thinking, "", $"臾몄꽌 媛쒖슂 ?꾩꽦 ??{toolHint} ?몄텧 以?.."); } private void ApplyCodeQualityFollowUpTransition( @@ -1331,8 +1378,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)) { @@ -1365,7 +1412,7 @@ public partial class AgentLoopService consumedExtraIteration = true; } - EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); + EmitEvent(AgentEventType.Complete, "", "?먯씠?꾪듃 ?묒뾽 ?꾨즺"); return (true, consumedExtraIteration); } @@ -1401,19 +1448,19 @@ public partial class AgentLoopService if (context.DevModeStepApproval && UserDecisionCallback != null) { var decision = await UserDecisionCallback( - $"[DEV] 도구 '{call.ToolName}' 실행을 승인하시겠습니까?\n{FormatToolCallSummary(call)}", - new List { "승인", "건너뛰기", "중단" }); + $"[DEV] ?꾧뎄 '{call.ToolName}' ?ㅽ뻾???뱀씤?섏떆寃좎뒿?덇퉴?\n{FormatToolCallSummary(call)}", + new List { "?뱀씤", "嫄대꼫?곌린", "以묐떒" }); var (devShouldContinue, devTerminalResponse, devToolResultMessage) = EvaluateDevStepDecision(decision); if (!string.IsNullOrEmpty(devTerminalResponse)) { - EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다"); + EmitEvent(AgentEventType.Complete, "", "[DEV] ?ъ슜?먭? ?ㅽ뻾??以묐떒?덉뒿?덈떎"); return (false, devTerminalResponse); } 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); } } @@ -1423,18 +1470,18 @@ public partial class AgentLoopService { var decision = await UserDecisionCallback( decisionRequired, - new List { "승인", "건너뛰기", "취소" }); + new List { "?뱀씤", "嫄대꼫?곌린", "痍⑥냼" }); var (scopeShouldContinue, scopeTerminalResponse, scopeToolResultMessage) = EvaluateScopeDecision(decision); if (!string.IsNullOrEmpty(scopeTerminalResponse)) { - EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); + EmitEvent(AgentEventType.Complete, "", "?ъ슜?먭? ?묒뾽??痍⑥냼?덉뒿?덈떎"); return (false, scopeTerminalResponse); } if (scopeShouldContinue) { messages.Add(LlmService.CreateToolResultMessage( - call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.")); + call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] ?ъ슜?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??")); return (true, null); } } @@ -1445,6 +1492,7 @@ public partial class AgentLoopService private sealed class RunState { public int ContextRecoveryAttempts; + public int WithheldRecoveryAttempts; public int NoToolCallLoopRetry; public int CodeDiffGateRetry; public int RecentExecutionGateRetry; @@ -1459,3 +1507,6 @@ public partial class AgentLoopService public int TerminalEvidenceGateRetry; } } + + + diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index cdd2c94..cd42d28 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -281,6 +281,9 @@ public enum AgentEventType Error, // 오류 Complete, // 완료 Decision, // 사용자 의사결정 대기 + SessionStart, // 세션 시작 + UserPromptSubmit, // 사용자 프롬프트 제출 + StopRequested, // 중단 요청 Paused, // 에이전트 일시정지 Resumed, // 에이전트 재개 } diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index bc6f4eb..55744bf 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -580,12 +580,10 @@ public sealed class ChatSessionStateService conversation.AgentRunHistory ??= new List(); conversation.DraftQueueItems ??= new List(); - var orderedEvents = conversation.ExecutionEvents - .OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp) - .ToList(); - if (!conversation.ExecutionEvents.SequenceEqual(orderedEvents)) + var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents); + if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents)) { - conversation.ExecutionEvents = orderedEvents; + conversation.ExecutionEvents = normalizedEvents; changed = true; } @@ -640,6 +638,55 @@ public sealed class ChatSessionStateService return delta <= TimeSpan.FromSeconds(2); } + private static List NormalizeExecutionEventsForResume(IEnumerable? source) + { + if (source == null) + return new List(); + + var ordered = source + .Where(evt => evt != null) + .OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp) + .ThenBy(evt => evt.Iteration) + .ThenBy(evt => evt.ElapsedMs) + .ToList(); + + var result = new List(ordered.Count); + foreach (var evt in ordered) + { + if (result.Count == 0) + { + result.Add(evt); + continue; + } + + var last = result[^1]; + if (!IsNearDuplicateExecutionEvent(last, evt)) + { + result.Add(evt); + continue; + } + + last.Timestamp = evt.Timestamp; + last.ElapsedMs = Math.Max(last.ElapsedMs, evt.ElapsedMs); + last.InputTokens = Math.Max(last.InputTokens, evt.InputTokens); + last.OutputTokens = Math.Max(last.OutputTokens, evt.OutputTokens); + last.StepCurrent = Math.Max(last.StepCurrent, evt.StepCurrent); + last.StepTotal = Math.Max(last.StepTotal, evt.StepTotal); + last.Iteration = Math.Max(last.Iteration, evt.Iteration); + if (string.IsNullOrWhiteSpace(last.FilePath)) + last.FilePath = evt.FilePath; + if (!string.IsNullOrWhiteSpace(evt.Summary) && evt.Summary.Length >= (last.Summary?.Length ?? 0)) + last.Summary = evt.Summary; + if (evt.Steps is { Count: > 0 }) + last.Steps = evt.Steps.ToList(); + if (!string.IsNullOrWhiteSpace(evt.ToolInput)) + last.ToolInput = evt.ToolInput; + last.Success = last.Success && evt.Success; + } + + return result; + } + private void TouchConversation(ChatStorageService? storage, string tab) { var conv = EnsureCurrentConversation(tab); diff --git a/src/AxCopilot/Services/TaskRunService.cs b/src/AxCopilot/Services/TaskRunService.cs index dd70682..b334cf4 100644 --- a/src/AxCopilot/Services/TaskRunService.cs +++ b/src/AxCopilot/Services/TaskRunService.cs @@ -168,13 +168,11 @@ public sealed class TaskRunService var restored = new List(); var active = new Dictionary(StringComparer.OrdinalIgnoreCase); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var normalizedHistory = NormalizeReplayHistory(history); - if (history != null) + if (normalizedHistory.Count > 0) { - foreach (var item in history - .OrderBy(x => x.Timestamp) - .ThenBy(x => x.Iteration) - .ThenBy(x => x.ElapsedMs)) + foreach (var item in normalizedHistory) { var scopedId = TryGetScopedId(item); if (string.IsNullOrWhiteSpace(scopedId)) @@ -193,7 +191,7 @@ public sealed class TaskRunService } } - foreach (var item in history + foreach (var item in normalizedHistory .OrderByDescending(x => x.Timestamp) .ThenByDescending(GetReplayPriority) .ThenByDescending(x => x.Iteration) @@ -511,6 +509,22 @@ public sealed class TaskRunService return true; } + if (string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase)) + { + task = new TaskRunStore.TaskRun + { + Id = BuildScopedId("tool", item), + Kind = "skill", + Title = string.IsNullOrWhiteSpace(item.ToolName) ? "skill" : item.ToolName, + Summary = item.Summary, + Status = "running", + StartedAt = item.Timestamp, + UpdatedAt = item.Timestamp, + FilePath = item.FilePath, + }; + return true; + } + if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase)) { @@ -588,6 +602,7 @@ public sealed class TaskRunService return BuildScopedId("permission", item); if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase) || string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase)) return BuildScopedId("tool", item); @@ -613,4 +628,73 @@ public sealed class TaskRunService ? $"{prefix}:{suffix}" : $"{prefix}:{evt.RunId}:{suffix}"; } + + private static IReadOnlyList NormalizeReplayHistory( + IEnumerable? history) + { + if (history == null) + return Array.Empty(); + + var ordered = history + .Where(x => x != null) + .OrderBy(x => x.Timestamp == default ? DateTime.MinValue : x.Timestamp) + .ThenBy(x => x.Iteration) + .ThenBy(x => x.ElapsedMs) + .ToList(); + + var result = new List(ordered.Count); + foreach (var item in ordered) + { + if (result.Count == 0) + { + result.Add(item); + continue; + } + + var last = result[^1]; + if (!IsNearDuplicateReplayEvent(last, item)) + { + result.Add(item); + continue; + } + + last.Timestamp = item.Timestamp; + last.ElapsedMs = Math.Max(last.ElapsedMs, item.ElapsedMs); + last.InputTokens = Math.Max(last.InputTokens, item.InputTokens); + last.OutputTokens = Math.Max(last.OutputTokens, item.OutputTokens); + last.StepCurrent = Math.Max(last.StepCurrent, item.StepCurrent); + last.StepTotal = Math.Max(last.StepTotal, item.StepTotal); + last.Iteration = Math.Max(last.Iteration, item.Iteration); + if (string.IsNullOrWhiteSpace(last.FilePath)) + last.FilePath = item.FilePath; + if (!string.IsNullOrWhiteSpace(item.Summary) && item.Summary.Length >= (last.Summary?.Length ?? 0)) + last.Summary = item.Summary; + if (item.Steps is { Count: > 0 }) + last.Steps = item.Steps.ToList(); + if (!string.IsNullOrWhiteSpace(item.ToolInput)) + last.ToolInput = item.ToolInput; + last.Success = last.Success && item.Success; + } + + return result; + } + + private static bool IsNearDuplicateReplayEvent(Models.ChatExecutionEvent left, Models.ChatExecutionEvent right) + { + if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal)) + return false; + if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase)) + return false; + if (left.Success != right.Success) + return false; + + var delta = (right.Timestamp - left.Timestamp).Duration(); + return delta <= TimeSpan.FromSeconds(2); + } }