코드 탭 컨텍스트 격차를 해소하고 대화 영속 작업 세트를 도입함

- ChatConversation에 CodeWorkingSetSnapshot/진단/시맨틱 요약 상태를 추가하고 AgentLoop가 실행 중 갱신한 작업 세트를 대화에 다시 저장하도록 연결함
- CodeTaskWorkingSetService를 스냅샷 복원/시맨틱 연속성 요약/활성 진단 보호 구조로 확장하고 query assembly에서 working set·semantic summary·workspace bootstrap을 protected evidence로 주입하도록 보강함
- ContextCondenser가 compact 중 MsgId, preview, 토큰 메타데이터를 유지하도록 수정하고 신규 compact marker와 요약 문자열을 영어 안정형으로 정리함
- ChatSessionStateService의 분기 대화가 MsgId와 CodeWorkingSet을 유지하도록 보강하고 관련 회귀 테스트(ChatStorage/ContextCondenser/PreLlmStage/QueryAssembly/WorkingSet)를 추가 및 갱신함
- 검증: dotnet build 경고 0 오류 0, CodeTaskWorkingSetServiceTests|AgentLoopQueryAssemblyServiceTests|AgentQueryContextBuilderTests|ContextCondenserTests|AxAgentExecutionEngineTests|AgentLoopLlmDispatchStageServiceTests|ChatStorageServiceTests 26개 통과, AgentLoopE2ETests 포함 관련 컨텍스트 회귀 56개 통과
This commit is contained in:
2026-04-16 09:01:00 +09:00
parent d5dbaa6e4a
commit 998f0c9fd5
20 changed files with 725 additions and 74 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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");
}
}

View File

@@ -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]");
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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<ChatMessage> 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" },
];
}
}

View File

@@ -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<string> CreatedDirectories { get; set; } = new();
[JsonPropertyName("recentReads")]
public List<string> RecentReads { get; set; } = new();
[JsonPropertyName("recentWrites")]
public List<string> RecentWrites { get; set; } = new();
[JsonPropertyName("activeDiagnostics")]
public List<CodeWorkingSetDiagnosticSnapshot> 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<string> Paths { get; set; } = new();
[JsonPropertyName("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
public class ChatAgentRunRecord

View File

@@ -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));
}
}

View File

@@ -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}";
}
}

View File

@@ -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);
/// <summary>
/// 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<ChatMessage> sendMessages, IEnumerable<ChatMessage>? supplementalMessages)
private static List<ChatMessage> PrepareSupplementalMessages(IEnumerable<ChatMessage>? supplementalMessages)
{
if (supplementalMessages == null)
return [];
return supplementalMessages
.Where(message => message != null && !string.IsNullOrWhiteSpace(message.Content))
.Select(CloneMessage)
.ToList();
}
private static int AppendSupplementalMessages(List<ChatMessage> sendMessages, IReadOnlyList<ChatMessage> 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++;
}

View File

@@ -79,7 +79,41 @@ internal static class AgentLoopQueryAssemblyService
private static IEnumerable<ChatMessage>? BuildSupplementalMessages(CodeTaskWorkingSetService? codeWorkingSet)
{
var workingSetMessage = codeWorkingSet?.BuildChatMessage();
return workingSetMessage is null ? null : [workingSetMessage];
if (codeWorkingSet == null)
return null;
var messages = new List<ChatMessage>();
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,
};
}
}

View File

@@ -113,6 +113,10 @@ public partial class AgentLoopService
/// </summary>
public string? RuntimeWorkFolderOverride { get; set; }
public CodeWorkingSetSnapshot? InitialCodeWorkingSetSnapshot { get; set; }
public Action<CodeWorkingSetSnapshot?>? CodeWorkingSetSnapshotUpdated { get; set; }
/// <summary>현재 대화 ID (감사 로그 기록용).</summary>
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)
{

View File

@@ -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; }
}
/// <summary>
@@ -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<ChatMessage> sourceMessages, out bool boundaryApplied)
private static int FindWindowStartIndex(IReadOnlyList<ChatMessage> 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();

View File

@@ -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<string> { "[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<string> { "[code-working-set]" };
@@ -150,14 +223,32 @@ internal sealed class CodeTaskWorkingSetService
return;
}
var createdDirectories = new List<string>();
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<string>? 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<string>(),
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,

View File

@@ -15,7 +15,7 @@ public sealed class ContextCompactionResult
public int CollapsedBoundaryCount { get; set; }
public List<string> 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);
}
/// <summary>
@@ -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<string>
{
$"[이전 실행 묶음 압축 — {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<string>
{
$"[이전 대화 요약 — {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)));
}

View File

@@ -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

View File

@@ -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;
}