diff --git a/README.md b/README.md index cfe3ed5..8afc7ca 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ # AX Commander +- Update: 2026-04-16 09:12 (KST) +- Closed the remaining Code-tab context gap by persisting a durable conversation-owned `CodeWorkingSetSnapshot` and rehydrating it on the next Code run. +- `src/AxCopilot/Models/ChatModels.cs`, `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs`, `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs`, and `src/AxCopilot/Views/ChatWindow.xaml.cs` now keep Code continuity across follow-up turns, tab switches, resume, and post-build recovery loops. +- `src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs`, `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs`, `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`, and `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs` now inject and log protected Code evidence layers for the working set, semantic summary, active diagnostics, and workspace bootstrap context. +- `src/AxCopilot/Services/Agent/ContextCondenser.cs` now preserves `MsgId`, preview metadata, and token metadata when compacting messages, and newly written compact markers are emitted with stable English strings instead of mixed legacy markers. +- `src/AxCopilot/Services/ChatSessionStateService.cs` now preserves `CodeWorkingSet` and `MsgId` when branching conversations so forked Code threads do not regress to flat-root scaffolding. +- Verification: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_context_gap_closure_final\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_final\\` warning 0 / error 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeTaskWorkingSetServiceTests|AgentLoopQueryAssemblyServiceTests|AgentQueryContextBuilderTests|ContextCondenserTests|AxAgentExecutionEngineTests|AgentLoopLlmDispatchStageServiceTests|ChatStorageServiceTests" -p:OutputPath=bin\\verify_code_context_gap_closure_tests\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_tests\\` passed 26 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopE2ETests|AgentMessageInvariantHelperTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopQueryAssemblyServiceTests|CodeTaskWorkingSetServiceTests|ContextCondenserTests|AxAgentExecutionEngineTests|ChatStorageServiceTests|AgentQueryContextBuilderTests" -p:OutputPath=bin\\verify_code_context_gap_closure_tests2\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_tests2\\` passed 56 + - Update: 2026-04-16 08:10 (KST) - Merged the external work-log changes recorded in `E:\AX Copilot - Claude\.claude\worktrees\vibrant-mendeleev\docs\WORK_LOG_2026-04-15_22h_to_2026-04-16.md` into the local Code tab implementation. - The current branch already superseded the external context-persistence track in `AxAgentExecutionEngine` and `AgentQueryContextBuilder`, so the merge focused on the missing Code stats surface instead of overwriting the newer local context pipeline. diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index 7256a3e..01172fa 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -1,5 +1,31 @@ # Code Context Reliability Plan +Update: 2026-04-16 09:12 (KST) + +- Implemented the remaining continuity gap-closure items that were still open after the earlier staged loop refactor: + - durable conversation-owned `CodeWorkingSetSnapshot` + - semantic Code tool-batch continuity summaries + - compact-safe `MsgId` and preview metadata preservation + - query-context diagnostics for working-set injection, semantic-summary injection, protected diagnostics, compact-boundary use, and legacy-boundary fallback detection + - workspace-context bootstrap injection for Code requests when `.ax-context.md` is already available +- The active implementation now spans: + - `src/AxCopilot/Models/ChatModels.cs` + - `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs` + - `src/AxCopilot/Services/Agent/AgentLoopService.cs` + - `src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs` + - `src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs` + - `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs` + - `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` + - `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs` + - `src/AxCopilot/Services/Agent/ContextCondenser.cs` + - `src/AxCopilot/Services/ChatSessionStateService.cs` + - `src/AxCopilot/Views/ChatWindow.xaml.cs` +- Remaining follow-up after this wave is now smaller and mainly optional: + - broaden the workspace bootstrap path from `LoadContext(...)` reuse into a fuller stale-check refresh only if Code runs show it is still needed + - continue cleaning low-traffic legacy mojibake comments and dormant compatibility strings as those files are touched again + - evaluate whether the durable Code working set should surface in future debug tooling or exports + - keep measuring tool-trace repair frequency in longer real Code sessions to confirm the new durable snapshot and semantic summary blocks reduce repair churn + Update: 2026-04-16 07:40 (KST) - Closed the main gaps that were still open versus the comparison checklist: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index de87dfc..4a3ebb1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1908,3 +1908,22 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 테스트: `src/AxCopilot.Tests/Services/CodeStatsAggregatorTests.cs` - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_stats_merge\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeStatsAggregatorTests|AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests" -p:OutputPath=bin\\verify_code_stats_merge_tests\\ -p:IntermediateOutputPath=obj\\verify_code_stats_merge_tests\\` 통과 56 +업데이트: 2026-04-16 09:12 (KST) +- Code context continuity was hardened with a durable `CodeWorkingSetSnapshot` that is now owned by `ChatConversation` and refreshed throughout Code runs. +- `src/AxCopilot/Models/ChatModels.cs` added `CodeWorkingSetSnapshot`, `CodeWorkingSetDiagnosticSnapshot`, and `CodeWorkingSetSemanticSummary`, and `ChatConversation` now persists the snapshot through the normal encrypted conversation save path. +- `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs` now restores snapshots, emits a dedicated semantic summary block, tracks active diagnostics and recent writes as protected evidence, and publishes only materially changed snapshots. +- `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs`, and `src/AxCopilot/Views/ChatWindow.xaml.cs` now hydrate the Code working set at loop start, publish updates during tool execution, and persist the latest Code snapshot back into the owning conversation. +- `src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs`, `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs`, `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`, and `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs` now inject and log Code-specific protected evidence for the working set, semantic continuity, active diagnostics, compact-boundary state, legacy marker fallback use, and workspace bootstrap context. +- `src/AxCopilot/Services/Agent/ContextCondenser.cs` now preserves `MsgId`, preview metadata, response timing, and token metadata during compaction, while newly written compaction markers and summaries use stable English strings. +- `src/AxCopilot/Services/ChatSessionStateService.cs` now preserves `CodeWorkingSet` and `MsgId` when branching conversations so forked Code threads keep the same repair chain. +- Tests: + - `src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs` + - `src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs` + - `src/AxCopilot.Tests/Services/ContextCondenserTests.cs` + - `src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs` + - `src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs` + - `src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs` +- Verification: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_context_gap_closure_final\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_final\\` warning 0 / error 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeTaskWorkingSetServiceTests|AgentLoopQueryAssemblyServiceTests|AgentQueryContextBuilderTests|ContextCondenserTests|AxAgentExecutionEngineTests|AgentLoopLlmDispatchStageServiceTests|ChatStorageServiceTests" -p:OutputPath=bin\\verify_code_context_gap_closure_tests\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_tests\\` passed 26 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopE2ETests|AgentMessageInvariantHelperTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopQueryAssemblyServiceTests|CodeTaskWorkingSetServiceTests|ContextCondenserTests|AxAgentExecutionEngineTests|ChatStorageServiceTests|AgentQueryContextBuilderTests" -p:OutputPath=bin\\verify_code_context_gap_closure_tests2\\ -p:IntermediateOutputPath=obj\\verify_code_context_gap_closure_tests2\\` passed 56 diff --git a/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs index 3738260..6f1be82 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs @@ -95,7 +95,11 @@ public class AgentLoopLlmDispatchStageServiceTests InjectedToolReminder: false, SupplementalMessageCount: 0, FlattenedStructuredAssistantCount: 0, - ConvertedOrphanToolResultCount: 0); + ConvertedOrphanToolResultCount: 0, + InjectedCodeWorkingSet: false, + InjectedSemanticSummary: false, + ProtectedActiveDiagnostics: false, + InjectedWorkspaceContext: false); var result = await service.ExecuteAsync( new AgentLoopLlmDispatchStageInput( @@ -172,7 +176,11 @@ public class AgentLoopLlmDispatchStageServiceTests InjectedToolReminder: false, SupplementalMessageCount: 0, FlattenedStructuredAssistantCount: 0, - ConvertedOrphanToolResultCount: 0); + ConvertedOrphanToolResultCount: 0, + InjectedCodeWorkingSet: false, + InjectedSemanticSummary: false, + ProtectedActiveDiagnostics: false, + InjectedWorkspaceContext: false); var context = new AgentContext { WorkFolder = @"E:\code", ActiveTab = "Code" }; await service.ExecuteAsync( diff --git a/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs index 326e252..a647526 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs @@ -161,7 +161,8 @@ public class AgentLoopPreLlmStageServiceTests RuntimeAllowedToolCount: 0)); result.LlmRequest.Should().NotBeNull(); - result.LlmRequest!.SupplementalMessageCount.Should().Be(1); + result.LlmRequest!.SupplementalMessageCount.Should().Be(2); result.LlmRequest.SendMessages.Should().Contain(message => message.MetaKind == "code_working_set"); + result.LlmRequest.SendMessages.Should().Contain(message => message.MetaKind == "code_semantic_summary"); } } diff --git a/src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs index 45aa739..2758154 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs @@ -93,8 +93,11 @@ public class AgentLoopQueryAssemblyServiceTests injectPreCallToolReminder: true, noToolCallLoopRetry: 0); - result.SupplementalMessageCount.Should().Be(1); + result.SupplementalMessageCount.Should().Be(2); result.SendMessages.Should().Contain(message => message.MetaKind == "code_working_set"); + result.SendMessages.Should().Contain(message => message.MetaKind == "code_semantic_summary"); + result.InjectedCodeWorkingSet.Should().BeTrue(); + result.InjectedSemanticSummary.Should().BeTrue(); result.SendMessages.Last().Content.Should().Contain("[TOOL_REQUIRED]"); } } diff --git a/src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs b/src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs index 281b246..4daad08 100644 --- a/src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs @@ -173,4 +173,62 @@ public class ChatStorageServiceTests DeleteConversationFile(conversationId); } } + + [Fact] + public void SaveAndLoad_ShouldRoundTripCodeWorkingSetSnapshot() + { + var conversationId = "code-working-set-" + Guid.NewGuid().ToString("N"); + try + { + var storage = new ChatStorageService(); + var conversation = new ChatConversation + { + Id = conversationId, + Title = "Code working set persistence", + Tab = "Code", + CodeWorkingSet = new CodeWorkingSetSnapshot + { + Goal = "Fix the WPF build", + WorkFolder = @"E:\code", + ScaffoldProfileLabel = "WPF MVVM", + StartedFromEmptyWorkspace = true, + CreatedDirectories = ["Views", "ViewModels"], + RecentWrites = ["Views/MainWindow.xaml"], + ActiveDiagnostics = + [ + new CodeWorkingSetDiagnosticSnapshot + { + ToolName = "build_run", + FilePath = "Views/MainWindow.xaml", + LineNumber = 10, + Code = "MC3089", + Message = "Duplicate property." + } + ], + LatestSemanticSummary = new CodeWorkingSetSemanticSummary + { + Kind = "verification_failed", + ToolName = "build_run", + Summary = "Verification failed with MC3089.", + Paths = ["Views/MainWindow.xaml"] + } + } + }; + + storage.Save(conversation); + var loaded = storage.Load(conversationId); + + loaded.Should().NotBeNull(); + loaded!.CodeWorkingSet.Should().NotBeNull(); + loaded.CodeWorkingSet!.Goal.Should().Be("Fix the WPF build"); + loaded.CodeWorkingSet.ActiveDiagnostics.Should().ContainSingle(); + loaded.CodeWorkingSet.ActiveDiagnostics[0].Code.Should().Be("MC3089"); + loaded.CodeWorkingSet.LatestSemanticSummary.Should().NotBeNull(); + loaded.CodeWorkingSet.LatestSemanticSummary!.Summary.Should().Contain("MC3089"); + } + finally + { + DeleteConversationFile(conversationId); + } + } } diff --git a/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs b/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs index e11d093..7bb6281 100644 --- a/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs +++ b/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs @@ -89,4 +89,41 @@ public class CodeTaskWorkingSetServiceTests message.Should().NotBeNull(); message!.Content.Should().NotContain("CS0017"); } + + [Fact] + public void SnapshotRoundTrip_ShouldPreserveSemanticSummaryAndDiagnostics() + { + var original = new CodeTaskWorkingSetService( + "Create a WPF app", + @"E:\code", + "WPF MVVM", + startedFromEmptyWorkspace: true); + + using var mkdirDoc = JsonDocument.Parse("""{"action":"mkdir","paths":["Views","ViewModels"]}"""); + original.RecordToolResult("file_manage", mkdirDoc.RootElement, ToolResult.Ok("created structure")); + original.RecordToolResult("build_run", null, new ToolResult + { + Success = false, + Output = @"E:\code\Views\MainWindow.xaml(10,5): error MC3089: Duplicate property" + }); + + original.TryBuildUpdatedSnapshot(out var snapshot).Should().BeTrue(); + snapshot.Should().NotBeNull(); + + var restored = new CodeTaskWorkingSetService( + "Create a WPF app", + @"E:\code", + "WPF MVVM", + startedFromEmptyWorkspace: true, + snapshot); + + var message = restored.BuildChatMessage(); + var semanticMessage = restored.BuildSemanticSummaryMessage(); + + message.Should().NotBeNull(); + message!.Content.Should().Contain("Views"); + message.Content.Should().Contain("MC3089"); + semanticMessage.Should().NotBeNull(); + semanticMessage!.Content.Should().Contain("verification_failed"); + } } diff --git a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs index 732fdae..a84a3b0 100644 --- a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs +++ b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs @@ -54,10 +54,12 @@ public class ContextCondenserTests changed.Should().BeTrue(); messages.Any(m => { - var c = m.Content ?? ""; - return c.Contains("[축약됨", StringComparison.Ordinal) - || c.Contains("[time-based", StringComparison.Ordinal) - || c.Contains("이전 내용 축약됨", StringComparison.Ordinal); + var content = m.Content ?? ""; + return content.Contains("[truncated]", StringComparison.Ordinal) + || content.Contains("[time-based", StringComparison.Ordinal) + || content.Contains("[previous content truncated", StringComparison.Ordinal) + || content.Contains("[previous tool result compaction", StringComparison.Ordinal) + || content.Contains("[previous execution bundle compaction", StringComparison.Ordinal); }).Should().BeTrue(); } @@ -69,6 +71,30 @@ public class ContextCondenserTests resolved.Should().Be(900_000); } + [Fact] + public async Task CondenseIfNeededAsync_ShouldPreserveMessageIdentityForCompactedMessages() + { + var settings = new SettingsService(); + settings.Settings.Llm.Service = "ollama"; + settings.Settings.Llm.Model = "test-model"; + + using var llm = new LlmService(settings); + var messages = BuildLargeConversation(); + var originalId = messages[2].MsgId; + + var changed = await ContextCondenser.CondenseIfNeededAsync( + messages, + llm, + maxOutputTokens: 2_000, + proactiveEnabled: true, + triggerPercent: 80, + force: false, + CancellationToken.None); + + changed.Should().BeTrue(); + messages.Should().Contain(message => message.MsgId == originalId); + } + private static List BuildLargeConversation() { var largeOutput = new string('A', 30_000); @@ -76,18 +102,18 @@ public class ContextCondenserTests return [ - new ChatMessage { Role = "system", Content = "system prompt" }, - new ChatMessage { Role = "user", Content = "첫 질문" }, - new ChatMessage { Role = "assistant", Content = toolJson }, // Keep the large payload in the old segment. - new ChatMessage { Role = "assistant", Content = "첫 답변" }, - new ChatMessage { Role = "user", Content = "둘째 질문" }, - new ChatMessage { Role = "assistant", Content = "둘째 답변" }, - new ChatMessage { Role = "user", Content = "셋째 질문" }, - new ChatMessage { Role = "assistant", Content = "셋째 답변" }, - new ChatMessage { Role = "user", Content = "넷째 질문" }, - new ChatMessage { Role = "assistant", Content = "넷째 답변" }, - new ChatMessage { Role = "user", Content = "다섯째 질문" }, - new ChatMessage { Role = "assistant", Content = "다섯째 답변" }, + new ChatMessage { MsgId = "system-1", Role = "system", Content = "system prompt" }, + new ChatMessage { MsgId = "user-1", Role = "user", Content = "first request" }, + new ChatMessage { MsgId = "assistant-large", Role = "assistant", Content = toolJson }, + new ChatMessage { MsgId = "assistant-2", Role = "assistant", Content = "first reply" }, + new ChatMessage { MsgId = "user-2", Role = "user", Content = "follow-up request" }, + new ChatMessage { MsgId = "assistant-3", Role = "assistant", Content = "follow-up reply" }, + new ChatMessage { MsgId = "user-3", Role = "user", Content = "third request" }, + new ChatMessage { MsgId = "assistant-4", Role = "assistant", Content = "third reply" }, + new ChatMessage { MsgId = "user-4", Role = "user", Content = "fourth request" }, + new ChatMessage { MsgId = "assistant-5", Role = "assistant", Content = "fourth reply" }, + new ChatMessage { MsgId = "user-5", Role = "user", Content = "fifth request" }, + new ChatMessage { MsgId = "assistant-6", Role = "assistant", Content = "fifth reply" }, ]; } } diff --git a/src/AxCopilot/Models/ChatModels.cs b/src/AxCopilot/Models/ChatModels.cs index 5d87dc9..b45e766 100644 --- a/src/AxCopilot/Models/ChatModels.cs +++ b/src/AxCopilot/Models/ChatModels.cs @@ -191,6 +191,87 @@ public class ChatConversation [JsonPropertyName("postCompactionCompletionTokens")] public int PostCompactionCompletionTokens { get; set; } + + [JsonPropertyName("codeWorkingSet")] + public CodeWorkingSetSnapshot? CodeWorkingSet { get; set; } +} + +public class CodeWorkingSetSnapshot +{ + [JsonPropertyName("goal")] + public string Goal { get; set; } = ""; + + [JsonPropertyName("workFolder")] + public string WorkFolder { get; set; } = ""; + + [JsonPropertyName("scaffoldProfileLabel")] + public string? ScaffoldProfileLabel { get; set; } + + [JsonPropertyName("startedFromEmptyWorkspace")] + public bool StartedFromEmptyWorkspace { get; set; } + + [JsonPropertyName("createdDirectories")] + public List CreatedDirectories { get; set; } = new(); + + [JsonPropertyName("recentReads")] + public List RecentReads { get; set; } = new(); + + [JsonPropertyName("recentWrites")] + public List RecentWrites { get; set; } = new(); + + [JsonPropertyName("activeDiagnostics")] + public List ActiveDiagnostics { get; set; } = new(); + + [JsonPropertyName("environmentSummary")] + public string? EnvironmentSummary { get; set; } + + [JsonPropertyName("nextFocus")] + public string? NextFocus { get; set; } + + [JsonPropertyName("lastMutationSummary")] + public string? LastMutationSummary { get; set; } + + [JsonPropertyName("latestSemanticSummary")] + public CodeWorkingSetSemanticSummary? LatestSemanticSummary { get; set; } + + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.Now; +} + +public class CodeWorkingSetDiagnosticSnapshot +{ + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + [JsonPropertyName("filePath")] + public string FilePath { get; set; } = ""; + + [JsonPropertyName("lineNumber")] + public int? LineNumber { get; set; } + + [JsonPropertyName("code")] + public string Code { get; set; } = ""; + + [JsonPropertyName("message")] + public string Message { get; set; } = ""; +} + +public class CodeWorkingSetSemanticSummary +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = ""; + + [JsonPropertyName("summary")] + public string Summary { get; set; } = ""; + + [JsonPropertyName("paths")] + public List Paths { get; set; } = new(); + + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.Now; } public class ChatAgentRunRecord diff --git a/src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs b/src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs new file mode 100644 index 0000000..8b48301 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopCodeWorkingSetPersistence.cs @@ -0,0 +1,17 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + private void PublishCodeWorkingSetSnapshot(CodeTaskWorkingSetService? codeWorkingSet) + { + if (codeWorkingSet == null || CodeWorkingSetSnapshotUpdated == null) + return; + + if (!codeWorkingSet.TryBuildUpdatedSnapshot(out var snapshot)) + return; + + CodeWorkingSetSnapshotUpdated(CodeTaskWorkingSetService.CloneSnapshot(snapshot)); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs index 2fc5f4f..20a51f9 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs @@ -17,6 +17,6 @@ public partial class AgentLoopService var toolTraceRepair = llmRequest.FlattenedStructuredAssistantCount > 0 || llmRequest.ConvertedOrphanToolResultCount > 0 ? $"tool_trace_repair=assistants:{llmRequest.FlattenedStructuredAssistantCount}/orphan_results:{llmRequest.ConvertedOrphanToolResultCount}, " : ""; - return $"{AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)}, profile={queryView.ProfileName}, protected_recent={queryView.ProtectedRecentNonSystemMessages}, send={llmRequest.SendMessages.Count}, send_tokens={estimatedSendTokens}, supplemental={llmRequest.SupplementalMessageCount}, {toolTraceRepair}tool_reminder={llmRequest.InjectedToolReminder}, working_set={workingSetSummary}"; + return $"{AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)}, profile={queryView.ProfileName}, protected_recent={queryView.ProtectedRecentNonSystemMessages}, send={llmRequest.SendMessages.Count}, send_tokens={estimatedSendTokens}, supplemental={llmRequest.SupplementalMessageCount}, {toolTraceRepair}tool_reminder={llmRequest.InjectedToolReminder}, working_set={workingSetSummary}, injected_working_set={llmRequest.InjectedCodeWorkingSet}, injected_semantic_summary={llmRequest.InjectedSemanticSummary}, protected_diagnostics={llmRequest.ProtectedActiveDiagnostics}, compact_boundary={queryView.BoundaryApplied}, legacy_boundary_fallback={queryView.LegacyBoundaryFallbackUsed}, workspace_bootstrap={llmRequest.InjectedWorkspaceContext}"; } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs index 183ec15..4013909 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs @@ -8,7 +8,11 @@ internal sealed record AgentLoopLlmRequestPreparationResult( bool InjectedToolReminder, int SupplementalMessageCount, int FlattenedStructuredAssistantCount, - int ConvertedOrphanToolResultCount); + int ConvertedOrphanToolResultCount, + bool InjectedCodeWorkingSet, + bool InjectedSemanticSummary, + bool ProtectedActiveDiagnostics, + bool InjectedWorkspaceContext); /// /// Builds the final LLM request array from the query window and optional @@ -26,7 +30,15 @@ internal static class AgentLoopLlmRequestPreparationService { var sendMessages = queryMessages.Select(CloneMessage).ToList(); var normalization = AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(sendMessages); - var supplementalCount = AppendSupplementalMessages(sendMessages, supplementalMessages); + var supplementalList = PrepareSupplementalMessages(supplementalMessages); + var supplementalCount = AppendSupplementalMessages(sendMessages, supplementalList); + var injectedCodeWorkingSet = supplementalList.Any(message => string.Equals(message.MetaKind, "code_working_set", StringComparison.OrdinalIgnoreCase)); + var injectedSemanticSummary = supplementalList.Any(message => string.Equals(message.MetaKind, "code_semantic_summary", StringComparison.OrdinalIgnoreCase)); + var protectedActiveDiagnostics = supplementalList.Any(message => + string.Equals(message.MetaKind, "code_working_set", StringComparison.OrdinalIgnoreCase) + && (message.Content ?? string.Empty).Contains("Active diagnostic:", StringComparison.Ordinal)); + var injectedWorkspaceContext = supplementalList.Any(message => + string.Equals(message.MetaKind, "workspace_context_bootstrap", StringComparison.OrdinalIgnoreCase)); var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled; if (!forceInitialToolCall || !injectPreCallToolReminder @@ -38,7 +50,11 @@ internal static class AgentLoopLlmRequestPreparationService false, supplementalCount, normalization.FlattenedAssistantCount, - normalization.ConvertedOrphanToolResultCount); + normalization.ConvertedOrphanToolResultCount, + injectedCodeWorkingSet, + injectedSemanticSummary, + protectedActiveDiagnostics, + injectedWorkspaceContext); } sendMessages.Add(BuildToolReminderMessage()); @@ -48,7 +64,11 @@ internal static class AgentLoopLlmRequestPreparationService true, supplementalCount, normalization.FlattenedAssistantCount, - normalization.ConvertedOrphanToolResultCount); + normalization.ConvertedOrphanToolResultCount, + injectedCodeWorkingSet, + injectedSemanticSummary, + protectedActiveDiagnostics, + injectedWorkspaceContext); } internal static ChatMessage BuildToolReminderMessage() @@ -61,17 +81,25 @@ internal static class AgentLoopLlmRequestPreparationService }; } - private static int AppendSupplementalMessages(List sendMessages, IEnumerable? supplementalMessages) + private static List PrepareSupplementalMessages(IEnumerable? supplementalMessages) { if (supplementalMessages == null) + return []; + + return supplementalMessages + .Where(message => message != null && !string.IsNullOrWhiteSpace(message.Content)) + .Select(CloneMessage) + .ToList(); + } + + private static int AppendSupplementalMessages(List sendMessages, IReadOnlyList supplementalMessages) + { + if (supplementalMessages.Count == 0) return 0; var added = 0; foreach (var message in supplementalMessages) { - if (message == null || string.IsNullOrWhiteSpace(message.Content)) - continue; - sendMessages.Add(CloneMessage(message)); added++; } diff --git a/src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs b/src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs index 7dbaf8b..6249fb3 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs @@ -79,7 +79,41 @@ internal static class AgentLoopQueryAssemblyService private static IEnumerable? BuildSupplementalMessages(CodeTaskWorkingSetService? codeWorkingSet) { - var workingSetMessage = codeWorkingSet?.BuildChatMessage(); - return workingSetMessage is null ? null : [workingSetMessage]; + if (codeWorkingSet == null) + return null; + + var messages = new List(); + var workingSetMessage = codeWorkingSet.BuildChatMessage(); + if (workingSetMessage != null) + messages.Add(workingSetMessage); + + var semanticSummaryMessage = codeWorkingSet.BuildSemanticSummaryMessage(); + if (semanticSummaryMessage != null) + messages.Add(semanticSummaryMessage); + + var workspaceContextMessage = BuildWorkspaceContextMessage(codeWorkingSet.WorkFolder); + if (workspaceContextMessage != null) + messages.Add(workspaceContextMessage); + + return messages.Count == 0 ? null : messages; + } + + private static ChatMessage? BuildWorkspaceContextMessage(string? workFolder) + { + var workspaceContext = WorkspaceContextGenerator.LoadContext(workFolder); + if (string.IsNullOrWhiteSpace(workspaceContext)) + return null; + + var normalized = workspaceContext.Length <= 1200 + ? workspaceContext + : workspaceContext[..1200] + "\n...(truncated)"; + + return new ChatMessage + { + Role = "system", + MetaKind = "workspace_context_bootstrap", + Content = "[workspace-context-bootstrap]\n" + normalized, + Timestamp = DateTime.Now, + }; } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index a07a617..1414f36 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -113,6 +113,10 @@ public partial class AgentLoopService /// public string? RuntimeWorkFolderOverride { get; set; } + public CodeWorkingSetSnapshot? InitialCodeWorkingSetSnapshot { get; set; } + + public Action? CodeWorkingSetSnapshotUpdated { get; set; } + /// 현재 대화 ID (감사 로그 기록용). private string _conversationId = ""; @@ -311,7 +315,8 @@ public partial class AgentLoopService userQuery, context.WorkFolder, explorationState.ScaffoldProfile?.Label, - workspaceWasInitiallyEmpty) + workspaceWasInitiallyEmpty, + InitialCodeWorkingSetSnapshot) : null; var preferredInitialToolSequence = BuildPreferredInitialToolSequence( @@ -1525,6 +1530,7 @@ public partial class AgentLoopService if (!isCodeTab) sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success); codeWorkingSet?.RecordToolResult(effectiveCall.ToolName, effectiveCall.ToolInput, result); + PublishCodeWorkingSetSnapshot(codeWorkingSet); if (!result.Success) { diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs index 87975b1..9b87acc 100644 --- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -19,6 +19,7 @@ public sealed class AgentQueryContextWindowResult public int ProtectedRecentNonSystemMessages { get; init; } public int ToolResultSoftCharLimit { get; init; } public int ToolResultAggregateBudgetChars { get; init; } + public bool LegacyBoundaryFallbackUsed { get; init; } } /// @@ -79,10 +80,11 @@ public static class AgentQueryContextBuilder ProtectedRecentNonSystemMessages = options.ToolResultBudget.ProtectedRecentNonSystemMessages, ToolResultSoftCharLimit = options.ToolResultBudget.SoftCharLimit, ToolResultAggregateBudgetChars = options.ToolResultBudget.AggregateBudgetChars, + LegacyBoundaryFallbackUsed = false, }; } - var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied); + var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied, out var legacyBoundaryFallbackUsed); var adjustedStartIndex = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs( sourceMessages, startIndex, @@ -124,24 +126,43 @@ public static class AgentQueryContextBuilder ProtectedRecentNonSystemMessages = options.ToolResultBudget.ProtectedRecentNonSystemMessages, ToolResultSoftCharLimit = options.ToolResultBudget.SoftCharLimit, ToolResultAggregateBudgetChars = options.ToolResultBudget.AggregateBudgetChars, + LegacyBoundaryFallbackUsed = legacyBoundaryFallbackUsed, }; } - private static int FindWindowStartIndex(IReadOnlyList sourceMessages, out bool boundaryApplied) + private static int FindWindowStartIndex(IReadOnlyList sourceMessages, out bool boundaryApplied, out bool legacyBoundaryFallbackUsed) { for (var i = sourceMessages.Count - 1; i >= 0; i--) { - if (IsQueryBoundaryMarker(sourceMessages[i])) + if (TryIsQueryBoundaryMarker(sourceMessages[i], out var usedLegacyFallback)) { boundaryApplied = true; + legacyBoundaryFallbackUsed = usedLegacyFallback; return i; } } boundaryApplied = false; + legacyBoundaryFallbackUsed = false; return 0; } + private static bool TryIsQueryBoundaryMarker(ChatMessage message, out bool usedLegacyFallback) + { + usedLegacyFallback = false; + if (IsQueryBoundaryMarker(message)) + { + var content = message.Content ?? ""; + usedLegacyFallback = content.StartsWith("[?댁쟾 ?€???붿빟", StringComparison.Ordinal) + || content.StartsWith("[?몄뀡 硫붾え由??뺤텞", StringComparison.Ordinal) + || content.StartsWith("[?댁쟾 ?ㅽ뻾 臾띠쓬 ?뺤텞", StringComparison.Ordinal) + || content.StartsWith("[?댁쟾 ?뺤텞 寃쎄퀎 蹂묓빀", StringComparison.Ordinal); + return true; + } + + return false; + } + private static bool IsQueryBoundaryMarker(ChatMessage message) { var metaKind = message.MetaKind ?? ""; @@ -286,7 +307,7 @@ public static class AgentQueryContextBuilder } else if (string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase) && (message.Content ?? string.Empty).StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) - && TryBuildToolUseSnippet(message, out var toolUseSnippet)) + && TryBuildCleanToolUseSnippet(message, out var toolUseSnippet)) { snippet = toolUseSnippet; } @@ -384,6 +405,16 @@ public static class AgentQueryContextBuilder } } + private static bool TryBuildCleanToolUseSnippet(ChatMessage message, out string snippet) + { + snippet = ""; + if (!TryBuildToolUseSnippet(message, out var legacySnippet)) + return false; + + snippet = legacySnippet.Replace(" ??", " - ", StringComparison.Ordinal); + return true; + } + private static string TruncateSnippet(string text) { var normalized = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); diff --git a/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs b/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs index df58744..fda6607 100644 --- a/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs +++ b/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs @@ -28,17 +28,22 @@ internal sealed class CodeTaskWorkingSetService private string _environmentSummary = ""; private string _nextFocus = ""; private string _lastMutationSummary = ""; + private CodeWorkingSetSemanticSummary? _latestSemanticSummary; + private string _lastPublishedFingerprint = ""; public CodeTaskWorkingSetService( string goal, string? workFolder, string? scaffoldProfileLabel, - bool startedFromEmptyWorkspace) + bool startedFromEmptyWorkspace, + CodeWorkingSetSnapshot? snapshot = null) { _goal = CollapseWhitespace(goal); _workFolder = workFolder?.Trim() ?? ""; _scaffoldProfileLabel = scaffoldProfileLabel?.Trim() ?? ""; _startedFromEmptyWorkspace = startedFromEmptyWorkspace; + RestoreSnapshot(snapshot); + _lastPublishedFingerprint = CreateSnapshotFingerprint(BuildSnapshotCore()); } public void RecordToolResult(string toolName, JsonElement? toolInput, ToolResult result) @@ -77,14 +82,82 @@ internal sealed class CodeTaskWorkingSetService }; } + public ChatMessage? BuildSemanticSummaryMessage() + { + CodeWorkingSetSemanticSummary? semanticSummary; + lock (_lock) + { + semanticSummary = CloneSemanticSummary(_latestSemanticSummary); + } + + if (semanticSummary == null || string.IsNullOrWhiteSpace(semanticSummary.Summary)) + return null; + + var lines = new List { "[code-semantic-summary]" }; + lines.Add($"- Kind: {semanticSummary.Kind}"); + lines.Add($"- Summary: {semanticSummary.Summary}"); + if (semanticSummary.Paths.Count > 0) + lines.Add("- Paths: " + string.Join(", ", semanticSummary.Paths)); + + return new ChatMessage + { + Role = "system", + MetaKind = "code_semantic_summary", + Content = string.Join("\n", lines), + Timestamp = semanticSummary.UpdatedAt, + }; + } + + public bool TryBuildUpdatedSnapshot(out CodeWorkingSetSnapshot? snapshot) + { + lock (_lock) + { + snapshot = BuildSnapshotCore(); + var fingerprint = CreateSnapshotFingerprint(snapshot); + if (string.Equals(fingerprint, _lastPublishedFingerprint, StringComparison.Ordinal)) + { + snapshot = null; + return false; + } + + _lastPublishedFingerprint = fingerprint; + return true; + } + } + + public static CodeWorkingSetSnapshot? CloneSnapshot(CodeWorkingSetSnapshot? snapshot) + { + if (snapshot == null) + return null; + + return new CodeWorkingSetSnapshot + { + Goal = snapshot.Goal, + WorkFolder = snapshot.WorkFolder, + ScaffoldProfileLabel = snapshot.ScaffoldProfileLabel, + StartedFromEmptyWorkspace = snapshot.StartedFromEmptyWorkspace, + CreatedDirectories = snapshot.CreatedDirectories.ToList(), + RecentReads = snapshot.RecentReads.ToList(), + RecentWrites = snapshot.RecentWrites.ToList(), + ActiveDiagnostics = snapshot.ActiveDiagnostics.Select(CloneDiagnosticSnapshot).ToList(), + EnvironmentSummary = snapshot.EnvironmentSummary, + NextFocus = snapshot.NextFocus, + LastMutationSummary = snapshot.LastMutationSummary, + LatestSemanticSummary = CloneSemanticSummary(snapshot.LatestSemanticSummary), + UpdatedAt = snapshot.UpdatedAt, + }; + } + public string DescribeForLog() { lock (_lock) { - return $"writes={_recentWrites.Count}, reads={_recentReads.Count}, dirs={_createdDirectories.Count}, diagnostics={_activeDiagnostics.Count}, next={TruncateSingleLine(_nextFocus, 80)}"; + return $"writes={_recentWrites.Count}, reads={_recentReads.Count}, dirs={_createdDirectories.Count}, diagnostics={_activeDiagnostics.Count}, semantic={_latestSemanticSummary?.Kind ?? "none"}, next={TruncateSingleLine(_nextFocus, 80)}"; } } + public string WorkFolder => _workFolder; + private string BuildContentCore() { var lines = new List { "[code-working-set]" }; @@ -150,14 +223,32 @@ internal sealed class CodeTaskWorkingSetService return; } + var createdDirectories = new List(); foreach (var candidate in ExtractPathCandidates(toolInput)) { if (LooksLikeDirectory(candidate)) - AddRecentUnique(_createdDirectories, NormalizePath(candidate), MaxListItems); + { + var normalized = NormalizePath(candidate); + AddRecentUnique(_createdDirectories, normalized, MaxListItems); + createdDirectories.Add(normalized); + } } if (!string.IsNullOrWhiteSpace(result.FilePath) && LooksLikeDirectory(result.FilePath)) - AddRecentUnique(_createdDirectories, NormalizePath(result.FilePath), MaxListItems); + { + var normalized = NormalizePath(result.FilePath); + AddRecentUnique(_createdDirectories, normalized, MaxListItems); + createdDirectories.Add(normalized); + } + + if (result.Success && createdDirectories.Count > 0) + { + RecordSemanticSummary( + "structure", + toolName, + "Created project structure: " + string.Join(", ", createdDirectories.Distinct(StringComparer.OrdinalIgnoreCase).Take(4)), + createdDirectories); + } } private void TrackReads(string toolName, JsonElement? toolInput, ToolResult result) @@ -195,6 +286,15 @@ internal sealed class CodeTaskWorkingSetService if (touchedFiles.Count > 0) _lastMutationSummary = "Updated " + string.Join(", ", touchedFiles.Distinct(StringComparer.OrdinalIgnoreCase).Take(3)); + + if (result.Success && touchedFiles.Count > 0) + { + RecordSemanticSummary( + "mutation", + toolName, + "Applied code changes to " + string.Join(", ", touchedFiles.Distinct(StringComparer.OrdinalIgnoreCase).Take(4)), + touchedFiles); + } } private void TrackDiagnostics(string toolName, ToolResult result) @@ -202,9 +302,18 @@ internal sealed class CodeTaskWorkingSetService if (!IsVerificationTool(toolName)) return; + var hadDiagnostics = _activeDiagnostics.Count > 0; if (result.Success) { _activeDiagnostics.Clear(); + if (hadDiagnostics) + { + RecordSemanticSummary( + "verification_resolved", + toolName, + "Verification passed after resolving the latest diagnostics.", + _recentWrites); + } return; } @@ -219,12 +328,22 @@ internal sealed class CodeTaskWorkingSetService { _activeDiagnostics.Clear(); _activeDiagnostics.Add(new CodeDiagnostic(toolName, "", null, "", TruncateSingleLine(fallbackSummary, 180))); + RecordSemanticSummary( + "verification_failed", + toolName, + "Verification failed and introduced a new active diagnostic: " + TruncateSingleLine(fallbackSummary, 140), + _recentWrites); } return; } _activeDiagnostics.Clear(); _activeDiagnostics.AddRange(diagnostics.Take(3)); + RecordSemanticSummary( + "verification_failed", + toolName, + "Verification failed with " + _activeDiagnostics[0].ToSummary(), + _activeDiagnostics.Select(diagnostic => diagnostic.FilePath).Where(path => !string.IsNullOrWhiteSpace(path))); } private void UpdateNextFocus(string toolName, ToolResult result) @@ -454,6 +573,113 @@ internal sealed class CodeTaskWorkingSetService return collapsed[..maxLength].TrimEnd() + "..."; } + private void RestoreSnapshot(CodeWorkingSetSnapshot? snapshot) + { + if (snapshot == null) + return; + + foreach (var directory in snapshot.CreatedDirectories) + AddRecentUnique(_createdDirectories, directory, MaxListItems); + foreach (var file in snapshot.RecentReads) + AddRecentUnique(_recentReads, file, MaxListItems); + foreach (var file in snapshot.RecentWrites) + AddRecentUnique(_recentWrites, file, MaxListItems); + + _activeDiagnostics.Clear(); + _activeDiagnostics.AddRange(snapshot.ActiveDiagnostics.Select(diagnostic => + new CodeDiagnostic( + diagnostic.ToolName, + diagnostic.FilePath, + diagnostic.LineNumber, + diagnostic.Code, + diagnostic.Message))); + + _environmentSummary = CollapseWhitespace(snapshot.EnvironmentSummary); + _nextFocus = CollapseWhitespace(snapshot.NextFocus); + _lastMutationSummary = CollapseWhitespace(snapshot.LastMutationSummary); + _latestSemanticSummary = CloneSemanticSummary(snapshot.LatestSemanticSummary); + } + + private CodeWorkingSetSnapshot BuildSnapshotCore() + { + return new CodeWorkingSetSnapshot + { + Goal = _goal, + WorkFolder = _workFolder, + ScaffoldProfileLabel = _scaffoldProfileLabel, + StartedFromEmptyWorkspace = _startedFromEmptyWorkspace, + CreatedDirectories = _createdDirectories.ToList(), + RecentReads = _recentReads.ToList(), + RecentWrites = _recentWrites.ToList(), + ActiveDiagnostics = _activeDiagnostics.Select(diagnostic => new CodeWorkingSetDiagnosticSnapshot + { + ToolName = diagnostic.ToolName, + FilePath = diagnostic.FilePath, + LineNumber = diagnostic.LineNumber, + Code = diagnostic.Code, + Message = diagnostic.Message, + }).ToList(), + EnvironmentSummary = _environmentSummary, + NextFocus = _nextFocus, + LastMutationSummary = _lastMutationSummary, + LatestSemanticSummary = CloneSemanticSummary(_latestSemanticSummary), + UpdatedAt = DateTime.Now, + }; + } + + private static string CreateSnapshotFingerprint(CodeWorkingSetSnapshot snapshot) + { + return JsonSerializer.Serialize(snapshot); + } + + private void RecordSemanticSummary(string kind, string toolName, string summary, IEnumerable? paths) + { + var normalizedSummary = TruncateSingleLine(summary, 220); + if (string.IsNullOrWhiteSpace(normalizedSummary)) + return; + + _latestSemanticSummary = new CodeWorkingSetSemanticSummary + { + Kind = kind, + ToolName = toolName, + Summary = normalizedSummary, + Paths = paths? + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(4) + .ToList() + ?? new List(), + UpdatedAt = DateTime.Now, + }; + } + + private static CodeWorkingSetDiagnosticSnapshot CloneDiagnosticSnapshot(CodeWorkingSetDiagnosticSnapshot snapshot) + { + return new CodeWorkingSetDiagnosticSnapshot + { + ToolName = snapshot.ToolName, + FilePath = snapshot.FilePath, + LineNumber = snapshot.LineNumber, + Code = snapshot.Code, + Message = snapshot.Message, + }; + } + + private static CodeWorkingSetSemanticSummary? CloneSemanticSummary(CodeWorkingSetSemanticSummary? summary) + { + if (summary == null) + return null; + + return new CodeWorkingSetSemanticSummary + { + Kind = summary.Kind, + ToolName = summary.ToolName, + Summary = summary.Summary, + Paths = summary.Paths.ToList(), + UpdatedAt = summary.UpdatedAt, + }; + } + private sealed record CodeDiagnostic( string ToolName, string FilePath, diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 720f119..d302465 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -15,7 +15,7 @@ public sealed class ContextCompactionResult public int CollapsedBoundaryCount { get; set; } public List AppliedStages { get; } = new(); public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens); - public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages); + public string StageSummary => AppliedStages.Count == 0 ? "none" : string.Join(" -> ", AppliedStages); } /// @@ -43,7 +43,7 @@ public static class ContextCondenser private const int RecentKeepCount = 12; private const int AutoCompactBufferTokens = 13_000; private const int SummaryReserveTokens = 20_000; - private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다."; + private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] Previous tool_result content was cleared."; private readonly record struct TimeBasedToolResultCompactionConfig(bool Enabled, int GapThresholdMinutes, int KeepRecentToolResults); @@ -237,7 +237,7 @@ public static class ContextCondenser if (didCompress) { var afterTokens = TokenEstimator.EstimateMessages(messages); - LogService.Info($"Context Condenser: {originalTokens} → {afterTokens} 토큰 (절감 {originalTokens - afterTokens})"); + LogService.Info($"Context Condenser: {originalTokens} -> {afterTokens} tokens (saved {originalTokens - afterTokens})"); result.Changed = true; result.AfterTokens = afterTokens; } @@ -366,7 +366,7 @@ public static class ContextCondenser { if (msg.Content.Length > MaxToolResultChars * 3) { - messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}"); + messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[truncated]\"]}"); truncated = true; } } @@ -374,8 +374,8 @@ public static class ContextCondenser { messages[i] = CloneWithContent( msg, - msg.Content[..MaxToolResultChars] + "\n\n...[이전 내용 축약됨 — 원본 " + - $"{msg.Content.Length:N0}자 중 {MaxToolResultChars:N0}자 유지]"); + msg.Content[..MaxToolResultChars] + "\n\n...[previous content truncated - kept " + + $"{MaxToolResultChars:N0} of {msg.Content.Length:N0} chars]"); truncated = true; } } @@ -387,13 +387,18 @@ public static class ContextCondenser { return new ChatMessage { + MsgId = source.MsgId, Role = source.Role, Content = content, Timestamp = source.Timestamp, MetaKind = source.MetaKind, MetaRunId = source.MetaRunId, Feedback = source.Feedback, + ResponseElapsedMs = source.ResponseElapsedMs, + PromptTokens = source.PromptTokens, + CompletionTokens = source.CompletionTokens, AttachedFiles = source.AttachedFiles?.ToList(), + QueryPreviewContent = source.QueryPreviewContent, Images = source.Images?.Select(x => new ImageAttachment { Base64 = x.Base64, @@ -508,9 +513,9 @@ public static class ContextCondenser AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null, Content = string.Join("\n", new[] { - $"[세션 메모리 압축 - {candidates.Count}개 이전 경계 통합]", - "- 이전 요약, 실행 경계, 오래된 메타 로그를 하나의 세션 메모로 합쳤습니다.", - attachedFiles.Count > 0 ? $"- 관련 파일: {string.Join(", ", attachedFiles)}" : "- 관련 파일: 없음", + $"[session memory compaction - merged {candidates.Count} earlier boundaries]", + "- Earlier summaries, execution boundaries, and stale meta logs were merged into one session memory block.", + attachedFiles.Count > 0 ? $"- Related files: {string.Join(", ", attachedFiles)}" : "- Related files: none", }) }; @@ -545,6 +550,11 @@ public static class ContextCondenser return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) || string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous conversation summary", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous execution bundle compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous tool result compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous tool call bundle compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous execution meta compaction", StringComparison.OrdinalIgnoreCase) || content.StartsWith("[이전 대화 요약", StringComparison.Ordinal) || content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal) || content.StartsWith("[이전 도구 결과 축약", StringComparison.Ordinal) @@ -584,7 +594,7 @@ public static class ContextCondenser Timestamp = group.Last().Timestamp, MetaKind = "collapsed_boundary", MetaRunId = group.Last().MetaRunId, - Content = $"[이전 압축 경계 병합 - {group.Count}개]\n- 이전 compact/snippet 경계를 하나로 합쳤습니다." + Content = $"[previous compaction boundary merge - {group.Count} boundaries]\n- Earlier compact/snippet boundaries were merged into one marker." }); collapsedCount += group.Count; i = j; @@ -619,6 +629,9 @@ public static class ContextCondenser var content = message.Content ?? ""; return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) || string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous execution bundle compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[session memory compaction", StringComparison.OrdinalIgnoreCase) + || content.StartsWith("[previous compaction boundary merge", StringComparison.OrdinalIgnoreCase) || content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal) || content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal) || content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal); @@ -646,7 +659,7 @@ public static class ContextCondenser snipped = CloneWithContent( source, string.Join("\n", headLines) + - "\n...[snip: 중간 실행 로그/출력 축약]...\n" + + "\n...[snip: middle execution log/output removed]...\n" + string.Join("\n", tailLines)); return true; } @@ -655,7 +668,7 @@ public static class ContextCondenser var tail = normalized.Length > SnipKeepTailChars ? normalized[^SnipKeepTailChars..] : ""; snipped = CloneWithContent( source, - head + "\n...[snip: 중간 내용 축약]...\n" + tail); + head + "\n...[snip: middle content removed]...\n" + tail); return true; } @@ -685,7 +698,7 @@ public static class ContextCondenser { compacted = CloneWithContent( source, - $"[이전 도구 결과 축약]\n- 상세 출력은 압축되었습니다.\n- 원본 길이: {content.Length:N0}자"); + $"[previous tool result compaction]\n- Detailed output was compacted.\n- Original length: {content.Length:N0} chars"); return true; } @@ -693,7 +706,7 @@ public static class ContextCondenser { compacted = CloneWithContent( source, - $"[이전 도구 호출 묶음 축약]\n- 도구 호출 구조는 유지하지 않고 요약만 남겼습니다.\n- 원본 길이: {content.Length:N0}자"); + $"[previous tool call bundle compaction]\n- The detailed tool call structure was compacted into a short marker.\n- Original length: {content.Length:N0} chars"); return true; } @@ -701,7 +714,7 @@ public static class ContextCondenser { compacted = CloneWithContent( source, - $"[이전 실행 메타 축약]\n- 종류: {source.MetaKind}\n- 자세한 실행 이벤트는 압축되었습니다."); + $"[previous execution meta compaction]\n- Kind: {source.MetaKind}\n- Detailed execution events were compacted."); return true; } @@ -710,7 +723,7 @@ public static class ContextCondenser var head = content[..Math.Min(MicrocompactSingleKeepChars, content.Length)]; compacted = CloneWithContent( source, - head + $"\n\n...[이전 메시지 microcompact 적용 — 원본 {content.Length:N0}자]"); + head + $"\n\n...[previous message microcompact applied - original {content.Length:N0} chars]"); return true; } @@ -728,15 +741,15 @@ public static class ContextCondenser var lines = new List { - $"[이전 실행 묶음 압축 — {group.Count}개 메시지]" + $"[previous execution bundle compaction - {group.Count} messages]" }; - if (toolResults > 0) lines.Add($"- 도구 결과 {toolResults}건 정리"); - if (toolCalls > 0) lines.Add($"- 도구 호출 {toolCalls}건 정리"); - if (metaEvents > 0) lines.Add($"- 실행 메타/로그 {metaEvents}건 정리"); - if (longTexts > 0) lines.Add($"- 긴 설명/출력 {longTexts}건 축약"); - if (attachmentSummary.AttachedFiles.Count > 0) lines.Add($"- 관련 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}"); - if (attachmentSummary.ImageCount > 0) lines.Add($"- 관련 이미지: {attachmentSummary.ImageCount}개"); + if (toolResults > 0) lines.Add($"- Compacted tool results: {toolResults}"); + if (toolCalls > 0) lines.Add($"- Compacted tool calls: {toolCalls}"); + if (metaEvents > 0) lines.Add($"- Compacted execution meta/logs: {metaEvents}"); + if (longTexts > 0) lines.Add($"- Compacted long explanation/output blocks: {longTexts}"); + if (attachmentSummary.AttachedFiles.Count > 0) lines.Add($"- Related files: {string.Join(", ", attachmentSummary.AttachedFiles)}"); + if (attachmentSummary.ImageCount > 0) lines.Add($"- Related images: {attachmentSummary.ImageCount}"); return new ChatMessage { @@ -766,18 +779,18 @@ public static class ContextCondenser // 요약 대상 텍스트 구성 var sb = new System.Text.StringBuilder(); - sb.AppendLine("다음 대화 기록을 간결하게 요약하세요."); - sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과."); - sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정."); - sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---"); + sb.AppendLine("Summarize the following conversation history."); + sb.AppendLine("Preserve the user request, key decisions, created or modified file paths, current progress, and important outcomes."); + sb.AppendLine("Remove detailed tool output, repetition, and intermediate reasoning."); + sb.AppendLine("Write concise bullet points using the same language as the conversation.\n---"); foreach (var m in oldMessages) { var content = m.Content ?? ""; if (content.StartsWith("{\"_tool_use_blocks\"")) - content = "[도구 호출]"; + content = "[tool call]"; else if (content.StartsWith("{\"type\":\"tool_result\"")) - content = "[도구 결과]"; + content = "[tool result]"; else if (content.Length > 300) content = content[..300] + "..."; @@ -808,7 +821,7 @@ public static class ContextCondenser messages.Add(new ChatMessage { Role = "assistant", - Content = "이전 대화 내용을 확인했습니다. 이어서 작업하겠습니다.", + Content = "I reviewed the previous conversation summary and will continue from it.", Timestamp = DateTime.Now, }); @@ -817,7 +830,7 @@ public static class ContextCondenser } catch (Exception ex) { - LogService.Warn($"Context Condenser 요약 실패: {ex.Message}"); + LogService.Warn($"Context Condenser summary failed: {ex.Message}"); return false; } } @@ -848,14 +861,14 @@ public static class ContextCondenser { var lines = new List { - $"[이전 대화 요약 — {oldMessageCount}개 메시지 압축]", + $"[previous conversation summary - {oldMessageCount} messages compacted]", summary.Trim() }; if (attachmentSummary.AttachedFiles.Count > 0) - lines.Add($"참고 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}"); + lines.Add($"Reference files: {string.Join(", ", attachmentSummary.AttachedFiles)}"); if (attachmentSummary.ImageCount > 0) - lines.Add($"참고 이미지: {attachmentSummary.ImageCount}개"); + lines.Add($"Reference images: {attachmentSummary.ImageCount}"); return string.Join("\n", lines.Where(x => !string.IsNullOrWhiteSpace(x))); } diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index b30948e..4b0bedb 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -257,6 +257,7 @@ public sealed class ChatSessionStateService Category = source.Category, WorkFolder = source.WorkFolder, SystemCommand = source.SystemCommand, + CodeWorkingSet = AxCopilot.Services.Agent.CodeTaskWorkingSetService.CloneSnapshot(source.CodeWorkingSet), ParentId = source.Id, BranchLabel = branchLabel, BranchAtIndex = atIndex, @@ -281,12 +282,16 @@ public sealed class ChatSessionStateService var m = source.Messages[i]; fork.Messages.Add(new ChatMessage { + MsgId = m.MsgId, Role = m.Role, Content = m.Content, Timestamp = m.Timestamp, MetaKind = m.MetaKind, MetaRunId = m.MetaRunId, Feedback = m.Feedback, + ResponseElapsedMs = m.ResponseElapsedMs, + PromptTokens = m.PromptTokens, + CompletionTokens = m.CompletionTokens, AttachedFiles = m.AttachedFiles?.ToList(), QueryPreviewContent = m.QueryPreviewContent, Images = m.Images?.Select(img => new ImageAttachment diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index ab7c552..40e8164 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -6280,6 +6280,20 @@ public partial class ChatWindow : Window { loop.ActiveTab = runTab; loop.RuntimeWorkFolderOverride = conversation.WorkFolder; + loop.InitialCodeWorkingSetSnapshot = CodeTaskWorkingSetService.CloneSnapshot(conversation.CodeWorkingSet); + loop.CodeWorkingSetSnapshotUpdated = snapshot => + { + var clonedSnapshot = CodeTaskWorkingSetService.CloneSnapshot(snapshot); + lock (_convLock) + { + conversation.CodeWorkingSet = clonedSnapshot; + if (_currentConversation != null + && string.Equals(_currentConversation.Id, conversation.Id, StringComparison.Ordinal)) + { + _currentConversation.CodeWorkingSet = CodeTaskWorkingSetService.CloneSnapshot(clonedSnapshot); + } + } + }; // 에이전트 루프를 백그라운드 스레드에서 실행 — UI 스레드 블록 방지 // RunAsync 내부의 _llm.SendAsync가 SynchronizationContext로 UI 스레드에 // 복귀할 때 WPF 메시지 펌프를 독점하여 창 드래그/최소화가 안 되는 문제 해결 @@ -6328,8 +6342,15 @@ public partial class ChatWindow : Window finally { StoreAgentLoopMessagesSnapshot(runTab, msgList); + if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase) + && conversation.CodeWorkingSet != null) + { + PersistConversationSnapshot(originTab, conversation, "코드 컨텍스트 저장 실패"); + } ResetPermissionRulesForRun(runTab); loop.RuntimeWorkFolderOverride = null; + loop.InitialCodeWorkingSetSnapshot = null; + loop.CodeWorkingSetSnapshotUpdated = null; loop.EventOccurred -= agentEventHandler; loop.UserDecisionCallback = null; }