Code 탭 컨텍스트 누적 신뢰성과 작업 연속성을 전면 보강한다

이번 커밋은 Code 탭 장기 실행에서 build/file 근거가 너무 빨리 축약되고, 이전 수정 맥락이 다음 LLM 요청에 안정적으로 누적되지 않던 문제를 해결하기 위한 전면 보강을 담는다.

핵심 수정사항:
- CodeTaskWorkingSetService를 추가해 최근 생성 디렉터리, 최근 읽기/쓰기 파일, 최신 build/test 진단, 다음 복구 초점을 구조화된 working set으로 유지하고 각 반복 요청에 보조 system context로 주입한다.
- AgentQueryContextBuilder와 AgentToolResultBudget에 code profile을 도입해 protected recent window와 tool_result budget을 확장하고 build_run, test_loop, file_read, multi_read, lsp_code_intel, git_tool 같은 고가치 evidence가 기본 탭보다 덜 잘리도록 조정한다.
- AgentLoopIterationPreparationService와 AgentLoopLlmRequestPreparationService를 확장해 query-context options와 supplemental messages를 함께 전달하고, AgentLoopService에서는 Code 탭에서 generic session learnings 대신 working set 중심으로 요청을 구성하도록 변경한다.
- ChatWindow.UtilityPresentation에서 workspace context 첫 부트스트랩을 강화해 .ax-context.md가 아직 없더라도 첫 요청 시점부터 background generation과 language workflow bootstrap hints가 반영되도록 수정한다.
- LlmService.ToolUse에서 historical tool trace sanitization 결과를 assistant flatten/orphan conversion 건수로 요약 로그에 남겨 tool-trace 불변식 문제를 추적 가능하게 만든다.
- 관련 테스트를 추가·갱신해 working set 누적, code profile budget, supplemental message 주입, query-context option 전달을 회귀 고정한다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\ : 통과 150
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopE2ETests|AgentMessageInvariantHelperTests" -p:OutputPath=bin\\verify_context_reliability_e2e\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_e2e\\ : 통과 21
This commit is contained in:
2026-04-16 01:45:28 +09:00
parent eb884e9263
commit 0f64bf3f84
17 changed files with 1074 additions and 129 deletions

View File

@@ -2380,3 +2380,12 @@ MIT License
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183
업데이트: 2026-04-16 01:41 (KST)
- Code 탭 컨텍스트 신뢰성 보강 1차 구현을 반영했습니다. `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs`를 추가해 최근 생성 디렉터리, 최근 읽기/쓰기 파일, 최신 build/test 진단, 다음 복구 초점을 하나의 working set 블록으로 유지하고, 각 반복의 실제 LLM 요청에 보조 system context로 주입합니다.
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`, `AgentToolResultBudget.cs`, `AgentLoopIterationPreparationService.cs`, `AgentLoopLlmRequestPreparationService.cs`를 함께 조정해 Code 탭에서는 더 넓은 protected recent window와 더 큰 tool_result budget을 사용하도록 바꿨습니다. `build_run`, `test_loop`, `file_read`, `multi_read`, `lsp_code_intel`, `git_tool` 같은 고가치 evidence는 기본 탭보다 덜 잘리도록 보호합니다.
- `src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs``.ax-context.md`가 아직 없더라도 첫 요청 시점에 workspace context 생성을 바로 시작하고, 생성이 완료되기 전에는 언어 워크플로우 힌트를 bootstrap context로 넣도록 바꿨습니다. 덕분에 완전한 빈 작업 폴더에서도 첫 루프부터 최소한의 프로젝트 힌트가 LLM에 전달됩니다.
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 Code 탭에서 generic `session_learnings` 주입을 줄이고, 대신 working set과 query-context diagnostics를 반복마다 `WorkflowLogService.LogTransition(..., \"query_context\", ...)`로 남깁니다. 이제 로그에서 query-view 범위, protected recent 값, supplemental context 수, estimated send token, working-set 요약을 함께 확인할 수 있습니다.
- `src/AxCopilot/Services/LlmService.ToolUse.cs`는 historical tool-call sanitization 결과를 assistant flatten / orphan conversion 건수로 요약 로그에 남깁니다. 사후 보정은 유지하되, 보정 빈도를 추적할 수 있게 만들어 이후 invariant hardening 후속 작업의 기준선을 확보했습니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\` 통과 150

View File

@@ -247,3 +247,28 @@ AX already has similar mechanisms, but the Code flow still lacks stronger workin
- better structural consistency for project generation and large edits
- less drift in long-running Code tasks
- fewer quality losses caused by broken strings and low-signal context replacements
## Latest Delivery
Updated: 2026-04-16 01:41 (KST)
- Delivered in this pass:
- Phase 1 foundation:
- `ChatWindow.UtilityPresentation.cs` now bootstraps workspace context generation on first access and returns language-workflow fallback hints while `.ax-context.md` is still being generated.
- `AgentLoopService.cs` now records `query_context` workflow transitions with query-window, budget, supplemental-context, and working-set summaries.
- Phase 2 foundation:
- `CodeTaskWorkingSetService.cs` adds a Code-only structured ledger for:
- goal
- selected scaffold/profile
- created directories
- recent reads/writes
- latest diagnostics
- next repair focus
- the working set is injected into each Code request as a supplemental `code_working_set` system message.
- Phase 3 foundation:
- `AgentToolResultBudget.cs` and `AgentQueryContextBuilder.cs` now expose a `code` query profile with a larger protected-recent window and larger retained budgets for `build_run`, `test_loop`, `process`, `file_read`, `multi_read`, `lsp_code_intel`, and `git_tool`.
- Phase 4 observability step:
- `LlmService.ToolUse.cs` now logs sanitization counts for flattened assistant tool traces and converted orphan tool messages, so tool-trace repair frequency can be measured per run.
- Remaining follow-up:
- extend pre-request tool-trace validation so the flattening/orphan repair count trends toward zero rather than being logged after repair
- replace more mojibake prompt/status strings in active Code execution paths with English equivalents

View File

@@ -1761,3 +1761,46 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- Encoding hygiene and prompt quality cleanup
- 계획 문서는 `claude-code` 참조 지점(`claw-code/.../src/query.ts`, `history.ts`, `memory-context.md`), AX 적용 위치, 완료 조건, 품질 판정 시나리오를 함께 기록했습니다.
- 외부 근거로는 Anthropic Claude Code memory docs, OpenAI practical guide to building agents, `SWE-Pruner: Self-Adaptive Context Pruning for Coding Agents`를 반영해 "자동 메모리 계층", "관측 가능성/eval 우선", "task-aware pruning" 원칙을 계획에 녹였습니다.
업데이트: 2026-04-16 01:41 (KST)
- Code 탭 컨텍스트 신뢰성 보강 1차 구현을 적용했다.
- `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs`
- Code 전용 working-set 메모리 레이어를 추가했다.
- 최근 생성 디렉터리, 최근 읽기/쓰기 파일, 최신 build/test 진단, 다음 복구 초점을 구조화해 유지한다.
- `build_run`, `test_loop`, `process`, `file_manage`, `file_write`, `file_edit`, `multi_read` 결과를 바탕으로 현재 작업 연속성을 요약한 `code_working_set` system 메시지를 만든다.
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`
- Code 탭 실행에서 `CodeTaskWorkingSetService`를 생성하고, 각 도구 실행 뒤 결과를 working set에 기록한다.
- Code 탭에서는 generic `session_learnings` 주입을 줄이고, 대신 working set 보조 context를 LLM 요청 직전에 삽입한다.
- 각 반복마다 `query_context` 전이 로그를 남겨 query-view 범위, profile, protected recent 값, supplemental context 수, estimated send token, working-set 요약을 관찰 가능하게 만들었다.
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`
- `AgentQueryContextBuildOptions`를 추가해 `default``code` profile을 분리했다.
- 결과 객체에 profile, protected recent, tool-result budget 메타를 함께 남긴다.
- `src/AxCopilot/Services/Agent/AgentToolResultBudget.cs`
- `AgentToolResultBudgetOptions`를 도입했다.
- Code profile에서 `build_run`, `test_loop`, `process`, `file_read`, `multi_read`, `lsp_code_intel`, `git_tool` 같은 고가치 evidence의 truncation 한도를 더 크게 잡아 최신 오류와 읽은 파일 근거가 너무 빨리 preview로 축약되지 않게 했다.
- truncation marker 문자열은 영어 기준으로 정리했다.
- `src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs`
- iteration 준비 단계에서 query-context build options를 주입하도록 확장했다.
- `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs`
- query view 외에 working set 같은 supplemental messages를 요청 배열에 추가할 수 있게 확장했다.
- tool reminder 메시지 문자열을 영어 기준으로 정리했다.
- `src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs`
- `.ax-context.md`가 아직 없는 첫 요청에서도 workspace context 생성을 즉시 시작한다.
- 생성이 완료되기 전에는 `DetectLanguageWorkflowHints(...)` 기반 bootstrap context를 반환해 완전 빈 작업 폴더에서도 첫 루프에 최소 힌트가 포함되도록 보강했다.
- `src/AxCopilot/Services/LlmService.ToolUse.cs`
- historical tool-call sanitization 결과를 `flattened_assistant`, `converted_orphans` 건수로 요약 로그에 남긴다.
- 사후 보정은 유지하면서도 빈도를 추적해 후속 invariant hardening 작업의 기준선을 확보했다.
- 테스트:
- `src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs`
- 구조/쓰기 working set 누적, build diagnostic 유지, 성공 build 후 diagnostic clearing을 검증한다.
- `src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs`
- Code profile 메타데이터 노출을 검증한다.
- `src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs`
- Code mode에서 긴 `build_run` 결과를 더 오래 보존하는지 검증한다.
- `src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs`
- iteration 준비 단계가 Code profile query options를 반영하는지 검증한다.
- `src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs`
- supplemental messages가 tool reminder 앞에 추가되는지 검증한다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\` 통과 150

View File

@@ -46,6 +46,38 @@ public class AgentLoopIterationPreparationServiceTests
message.Role == "user");
}
[Fact]
public void Prepare_ShouldForwardCodeQueryOptions()
{
var longContent = new string('Z', 2_200);
var messages = new List<ChatMessage>
{
new()
{
MsgId = "tool-1",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-build","tool_name":"build_run","content":"{{longContent}}"}"""
},
new()
{
MsgId = "assistant-1",
Role = "assistant",
Content = "recent assistant message"
}
};
var result = AgentLoopIterationPreparationService.Prepare(
messages,
new AgentCommandQueue(),
lastToolResultAtUtc: null,
lastToolResultToolName: null,
utcNow: DateTime.UtcNow,
queryOptions: AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault());
result.QueryView.ProfileName.Should().Be("code");
result.QueryView.ToolResultSoftCharLimit.Should().BeGreaterThan(AgentToolResultBudget.DefaultSoftCharLimit);
}
[Fact]
public void BuildToolResultWaitSummary_ShouldFormatToolNameAndElapsedMilliseconds()
{

View File

@@ -55,4 +55,35 @@ public class AgentLoopLlmRequestPreparationServiceTests
result.InjectedToolReminder.Should().BeFalse();
result.SendMessages.Should().HaveCount(1);
}
[Fact]
public void Prepare_ShouldAppendSupplementalMessagesBeforeReminder()
{
var queryMessages = new List<ChatMessage>
{
new()
{
Role = "user",
Content = "fix the latest build failure"
}
};
var supplemental = new ChatMessage
{
Role = "system",
MetaKind = "code_working_set",
Content = "[code-working-set]\n- Active diagnostic: MC4005 - Themes/ControlStyles.xaml"
};
var result = AgentLoopLlmRequestPreparationService.Prepare(
queryMessages,
totalToolCalls: 0,
forceInitialToolCallEnabled: true,
injectPreCallToolReminder: true,
noToolCallLoopRetry: 0,
supplementalMessages: [supplemental]);
result.SupplementalMessageCount.Should().Be(1);
result.SendMessages[1].MetaKind.Should().Be("code_working_set");
result.SendMessages.Last().Content.Should().Contain("[TOOL_REQUIRED]");
}
}

View File

@@ -68,4 +68,32 @@ public class AgentQueryContextBuilderTests
message.QueryPreviewContent != null &&
message.QueryPreviewContent.Contains("call-synth-view", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Build_ShouldExposeCodeProfileMetadata()
{
var sourceMessages = new List<ChatMessage>
{
new()
{
MsgId = "tool-source-1",
Role = "user",
Content = """{"type":"tool_result","tool_use_id":"call-code","tool_name":"build_run","content":"short"}"""
},
new()
{
MsgId = "tail-1",
Role = "assistant",
Content = "recent tail"
}
};
var result = AgentQueryContextBuilder.Build(
sourceMessages,
AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault());
result.ProfileName.Should().Be("code");
result.ProtectedRecentNonSystemMessages.Should().BeGreaterThan(8);
result.ToolResultAggregateBudgetChars.Should().BeGreaterThan(AgentToolResultBudget.DefaultAggregateBudgetChars);
}
}

View File

@@ -36,7 +36,10 @@ public class AgentToolResultBudgetTests
Timestamp = message.Timestamp
}).ToList();
var first = AgentToolResultBudget.Apply(firstWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
var first = AgentToolResultBudget.Apply(
firstWindow,
protectedRecentNonSystemMessages: 1,
sourceMessages: sourceMessages);
sourceMessages[0].QueryPreviewContent.Should().NotBeNullOrWhiteSpace();
first.TruncatedCount.Should().Be(1);
@@ -50,24 +53,26 @@ public class AgentToolResultBudgetTests
Timestamp = message.Timestamp
}).ToList();
var second = AgentToolResultBudget.Apply(secondWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
var second = AgentToolResultBudget.Apply(
secondWindow,
protectedRecentNonSystemMessages: 1,
sourceMessages: sourceMessages);
second.ReusedPreviewCount.Should().Be(1);
secondWindow[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
}
[Fact]
public void Apply_ShouldReusePreviewByToolUseIdAcrossClonedMessages()
public void Apply_ShouldPreserveLargerBuildResultsInCodeMode()
{
var longContent = new string('B', 1500);
var sourceMessages = new List<ChatMessage>
var longContent = new string('B', 2_600);
var queryView = new List<ChatMessage>
{
new()
{
MsgId = "source-tool",
MsgId = "tool-build",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"{{longContent}}"}""",
QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"preview"}"""
Content = $$"""{"type":"tool_result","tool_use_id":"call-build","tool_name":"build_run","content":"{{longContent}}"}"""
},
new()
{
@@ -77,65 +82,19 @@ public class AgentToolResultBudgetTests
}
};
var queryView = new List<ChatMessage>
{
new()
{
MsgId = "rebuilt-tool",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"{{longContent}}"}"""
},
new()
{
MsgId = "tail-2",
Role = "assistant",
Content = "recent tail"
}
};
var result = AgentToolResultBudget.Apply(
queryView,
AgentToolResultBudget.CreateCodeOptions(),
sourceMessages: queryView);
var result = AgentToolResultBudget.Apply(queryView, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
result.ReusedPreviewCount.Should().Be(1);
queryView[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
result.TruncatedCount.Should().Be(0);
queryView[0].Content.Should().Contain(longContent[..256]);
}
[Fact]
public void Apply_ShouldReusePreviewWithinSameWindow_WhenSourceMessagesAreOmitted()
public void Apply_ShouldReusePreviewFingerprintAcrossSessions()
{
var longContent = new string('C', 1600);
var queryView = new List<ChatMessage>
{
new()
{
MsgId = "tool-1",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-inline","tool_name":"file_read","content":"{{longContent}}"}""",
QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-inline","tool_name":"file_read","content":"preview-inline"}"""
},
new()
{
MsgId = "tool-2",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-inline","tool_name":"file_read","content":"{{longContent}}"}"""
},
new()
{
MsgId = "tail-1",
Role = "assistant",
Content = "recent tail"
}
};
var result = AgentToolResultBudget.Apply(queryView, protectedRecentNonSystemMessages: 1);
result.ReusedPreviewCount.Should().BeGreaterThan(0);
queryView[1].QueryPreviewContent.Should().Be(queryView[0].QueryPreviewContent);
}
[Fact]
public void Apply_ShouldReuseFingerprintPreviewFromSourceMessages_WhenToolUseIdChangesAcrossSessions()
{
var longContent = new string('D', 1700);
var longContent = new string('C', 1700);
var sourceMessages = new List<ChatMessage>
{
new()
@@ -169,7 +128,10 @@ public class AgentToolResultBudgetTests
}
};
var result = AgentToolResultBudget.Apply(queryView, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages);
var result = AgentToolResultBudget.Apply(
queryView,
protectedRecentNonSystemMessages: 1,
sourceMessages: sourceMessages);
result.ReusedPreviewCount.Should().Be(1);
queryView[0].Content.Should().Contain("call-replayed");

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class CodeTaskWorkingSetServiceTests
{
[Fact]
public void BuildChatMessage_ShouldCaptureStructureAndRecentWrites()
{
var service = new CodeTaskWorkingSetService(
"Create a WPF app",
@"E:\code",
"WPF MVVM",
startedFromEmptyWorkspace: true);
using var mkdirDoc = JsonDocument.Parse("""{"action":"mkdir","path":"Views"}""");
service.RecordToolResult("file_manage", mkdirDoc.RootElement, new ToolResult
{
Success = true,
Output = "created Views"
});
using var writeDoc = JsonDocument.Parse("""{"path":"Views/MainWindow.xaml"}""");
service.RecordToolResult("file_write", writeDoc.RootElement, new ToolResult
{
Success = true,
FilePath = @"E:\code\Views\MainWindow.xaml",
Output = "wrote MainWindow.xaml"
});
var message = service.BuildChatMessage();
message.Should().NotBeNull();
message!.MetaKind.Should().Be("code_working_set");
message.Content.Should().Contain("Structure chosen: Views");
message.Content.Should().Contain("Recent writes: Views/MainWindow.xaml");
}
[Fact]
public void BuildChatMessage_ShouldCaptureLatestBuildDiagnostics()
{
var service = new CodeTaskWorkingSetService(
"Fix the build",
@"E:\code",
"WPF MVVM",
startedFromEmptyWorkspace: false);
const string buildOutput = @"E:\code\Themes\ControlStyles.xaml(14,17): error MC4005: 'SelectionTextColor' property does not exist";
service.RecordToolResult("build_run", null, new ToolResult
{
Success = false,
Output = buildOutput
});
var message = service.BuildChatMessage();
message.Should().NotBeNull();
message!.Content.Should().Contain("MC4005");
message.Content.Should().Contain("Themes/ControlStyles.xaml");
message.Content.Should().Contain("Resolve the latest build/test diagnostics");
}
[Fact]
public void SuccessfulBuild_ShouldClearActiveDiagnostics()
{
var service = new CodeTaskWorkingSetService(
"Fix the build",
@"E:\code",
"WPF MVVM",
startedFromEmptyWorkspace: false);
service.RecordToolResult("build_run", null, new ToolResult
{
Success = false,
Output = @"E:\code\App.xaml.cs(10,5): error CS0017: Program has more than one entry point"
});
service.RecordToolResult("build_run", null, new ToolResult
{
Success = true,
Output = "Build succeeded."
});
var message = service.BuildChatMessage();
message.Should().NotBeNull();
message!.Content.Should().NotContain("CS0017");
}
}

View File

@@ -0,0 +1,19 @@
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static AgentQueryContextBuilder.AgentQueryContextBuildOptions CreateQueryContextBuildOptions(bool isCodeTab)
=> isCodeTab
? AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault()
: AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateDefault();
private static string BuildQueryContextDetail(
AgentQueryContextWindowResult queryView,
AgentLoopLlmRequestPreparationResult llmRequest,
CodeTaskWorkingSetService? codeWorkingSet)
{
var estimatedSendTokens = TokenEstimator.EstimateMessages(llmRequest.SendMessages);
var workingSetSummary = codeWorkingSet?.DescribeForLog() ?? "none";
return $"{AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)}, profile={queryView.ProfileName}, protected_recent={queryView.ProtectedRecentNonSystemMessages}, send={llmRequest.SendMessages.Count}, send_tokens={estimatedSendTokens}, supplemental={llmRequest.SupplementalMessageCount}, tool_reminder={llmRequest.InjectedToolReminder}, working_set={workingSetSummary}";
}
}

View File

@@ -8,9 +8,8 @@ internal sealed record AgentLoopIterationPreparationResult(
string? ToolResultWaitSummary);
/// <summary>
/// RunAsync 반복 시작 시점의 공통 준비 작업을 묶습니다.
/// queued command 투영, tool_result 대기 진단, query view 생성 책임을 분리해
/// AgentLoopService 본체를 orchestration 중심으로 유지합니다.
/// Performs the common per-iteration preparation work before each LLM request.
/// This keeps AgentLoopService focused on orchestration.
/// </summary>
internal static class AgentLoopIterationPreparationService
{
@@ -19,10 +18,11 @@ internal static class AgentLoopIterationPreparationService
AgentCommandQueue pendingCommands,
DateTime? lastToolResultAtUtc,
string? lastToolResultToolName,
DateTime utcNow)
DateTime utcNow,
AgentQueryContextBuilder.AgentQueryContextBuildOptions? queryOptions = null)
{
var queueProjection = ProjectQueuedCommands(messages, pendingCommands);
var queryView = AgentQueryContextBuilder.Build(messages);
var queryView = AgentQueryContextBuilder.Build(messages, queryOptions);
var waitSummary = BuildToolResultWaitSummary(lastToolResultAtUtc, lastToolResultToolName, utcNow);
return new AgentLoopIterationPreparationResult(queueProjection, queryView, waitSummary);

View File

@@ -5,12 +5,12 @@ namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopLlmRequestPreparationResult(
List<ChatMessage> SendMessages,
bool ForceInitialToolCall,
bool InjectedToolReminder);
bool InjectedToolReminder,
int SupplementalMessageCount);
/// <summary>
/// query view가 만들어진 뒤 실제 LLM 요청 배열을 조립합니다.
/// 초기 tool call 강제 여부와 사전 reminder 주입을 한곳에서 결정해
/// AgentLoopService 본체가 orchestration에 더 집중하도록 분리합니다.
/// Builds the final LLM request array from the query window and optional
/// supplemental context blocks.
/// </summary>
internal static class AgentLoopLlmRequestPreparationService
{
@@ -19,25 +19,29 @@ internal static class AgentLoopLlmRequestPreparationService
int totalToolCalls,
bool forceInitialToolCallEnabled,
bool injectPreCallToolReminder,
int noToolCallLoopRetry)
int noToolCallLoopRetry,
IEnumerable<ChatMessage>? supplementalMessages = null)
{
var sendMessages = queryMessages.ToList();
var supplementalCount = AppendSupplementalMessages(sendMessages, supplementalMessages);
var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled;
if (!forceInitialToolCall
|| !injectPreCallToolReminder
|| noToolCallLoopRetry > 0)
{
return new AgentLoopLlmRequestPreparationResult(
queryMessages.ToList(),
sendMessages,
forceInitialToolCall,
false);
false,
supplementalCount);
}
var sendMessages = queryMessages.ToList();
sendMessages.Add(BuildToolReminderMessage());
return new AgentLoopLlmRequestPreparationResult(
sendMessages,
forceInitialToolCall,
true);
true,
supplementalCount);
}
internal static ChatMessage BuildToolReminderMessage()
@@ -45,8 +49,26 @@ internal static class AgentLoopLlmRequestPreparationService
return new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
Content = "[TOOL_REQUIRED] Emit a valid <tool_call> block right away. A plain-text reply will be rejected.\n" +
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
};
}
private static int AppendSupplementalMessages(List<ChatMessage> sendMessages, IEnumerable<ChatMessage>? supplementalMessages)
{
if (supplementalMessages == null)
return 0;
var added = 0;
foreach (var message in supplementalMessages)
{
if (message == null || string.IsNullOrWhiteSpace(message.Content))
continue;
sendMessages.Add(message);
added++;
}
return added;
}
}

View File

@@ -239,6 +239,7 @@ public partial class AgentLoopService
var explorationState = runBootstrap.ExplorationState;
var pathAccessState = runBootstrap.PathAccessState;
var sessionLearnings = runBootstrap.SessionLearnings;
var isCodeTab = string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase);
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
@@ -299,6 +300,14 @@ public partial class AgentLoopService
context.InitialUserQuery = userQuery;
runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder);
var workspaceWasInitiallyEmpty = runState.WorkspaceAppearsEmpty;
var queryContextOptions = CreateQueryContextBuildOptions(isCodeTab);
var codeWorkingSet = isCodeTab
? new CodeTaskWorkingSetService(
userQuery,
context.WorkFolder,
explorationState.ScaffoldProfile?.Label,
workspaceWasInitiallyEmpty)
: null;
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
@@ -454,7 +463,8 @@ public partial class AgentLoopService
_pendingCommands,
lastToolResultAtUtc,
lastToolResultToolName,
DateTime.UtcNow);
DateTime.UtcNow,
queryContextOptions);
if (!string.IsNullOrWhiteSpace(iterationPreparation.ToolResultWaitSummary))
{
WorkflowLogService.LogTransition(
@@ -520,8 +530,8 @@ public partial class AgentLoopService
}
}
// P3: 누적 학습 메시지 주입 (매 반복 갱신)
if (sessionLearnings is { Count: > 0 })
// Refresh the session learnings block only for non-Code tabs.
if (!isCodeTab && sessionLearnings is { Count: > 0 })
{
var learningMsg = sessionLearnings.BuildInjectionMessage();
if (learningMsg != null)
@@ -562,12 +572,14 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", $"{tabLabel} 탭에서 사용 가능한 도구가 없습니다.");
return $"⚠ 현재 {tabLabel} 탭에서 사용 가능한 도구가 없어 작업을 진행할 수 없습니다. 탭별 도구 노출 정책을 확인하세요.";
}
var workingSetMessage = codeWorkingSet?.BuildChatMessage();
var llmRequest = AgentLoopLlmRequestPreparationService.Prepare(
queryMessages,
totalToolCalls,
executionPolicy.ForceInitialToolCall,
executionPolicy.InjectPreCallToolReminder,
runState.NoToolCallLoopRetry);
runState.NoToolCallLoopRetry,
workingSetMessage is null ? null : [workingSetMessage]);
var forceFirst = llmRequest.ForceInitialToolCall;
var sendMessages = llmRequest.SendMessages;
@@ -575,6 +587,12 @@ public partial class AgentLoopService
llmCallSw.Restart();
var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
WorkflowLogService.LogTransition(
_conversationId,
_currentRunId,
iteration,
"query_context",
BuildQueryContextDetail(queryView, llmRequest, codeWorkingSet));
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
var streamedTextPreview = new StringBuilder();
@@ -1578,8 +1596,10 @@ public partial class AgentLoopService
lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName;
// P3: 누적 학습 — 도구 결과에서 학습 포인트 추출
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
// Keep Code runs focused on the current working set instead of generic session learnings.
if (!isCodeTab)
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
codeWorkingSet?.RecordToolResult(effectiveCall.ToolName, effectiveCall.ToolInput, result);
if (!result.Success)
{

View File

@@ -15,18 +15,46 @@ public sealed class AgentQueryContextWindowResult
public int ReusedToolResultPreviewCount { get; init; }
public int TokensBeforeBudget { get; init; }
public int TokensAfterBudget { get; init; }
public string ProfileName { get; init; } = "default";
public int ProtectedRecentNonSystemMessages { get; init; }
public int ToolResultSoftCharLimit { get; init; }
public int ToolResultAggregateBudgetChars { get; init; }
}
/// <summary>
/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다.
/// Builds the query window that is actually sent to the LLM, similar to
/// claude-code's messagesForQuery pipeline.
/// </summary>
public static class AgentQueryContextBuilder
{
private const int ProtectedRecentNonSystemMessages = 8;
public sealed class AgentQueryContextBuildOptions
{
public string ProfileName { get; init; } = "default";
public AgentToolResultBudget.AgentToolResultBudgetOptions ToolResultBudget { get; init; }
= AgentToolResultBudget.CreateDefaultOptions();
public static AgentQueryContextBuildOptions CreateDefault()
=> new()
{
ProfileName = "default",
ToolResultBudget = AgentToolResultBudget.CreateDefaultOptions(),
};
public static AgentQueryContextBuildOptions CreateCodeDefault()
=> new()
{
ProfileName = "code",
ToolResultBudget = AgentToolResultBudget.CreateCodeOptions(),
};
}
private const string PostCompactContextMetaKind = "post_compact_context";
public static AgentQueryContextWindowResult Build(IReadOnlyList<ChatMessage> sourceMessages)
public static AgentQueryContextWindowResult Build(
IReadOnlyList<ChatMessage> sourceMessages,
AgentQueryContextBuildOptions? options = null)
{
options ??= AgentQueryContextBuildOptions.CreateDefault();
if (sourceMessages is IList<ChatMessage> mutableSourceMessages)
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(mutableSourceMessages);
@@ -45,6 +73,10 @@ public static class AgentQueryContextBuilder
ReusedToolResultPreviewCount = 0,
TokensBeforeBudget = 0,
TokensAfterBudget = 0,
ProfileName = options.ProfileName,
ProtectedRecentNonSystemMessages = options.ToolResultBudget.ProtectedRecentNonSystemMessages,
ToolResultSoftCharLimit = options.ToolResultBudget.SoftCharLimit,
ToolResultAggregateBudgetChars = options.ToolResultBudget.AggregateBudgetChars,
};
}
@@ -70,7 +102,7 @@ public static class AgentQueryContextBuilder
InjectPostCompactContextMessage(windowMessages);
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages, sourceMessages: sourceMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, options.ToolResultBudget, sourceMessages: sourceMessages);
var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
return new AgentQueryContextWindowResult
@@ -86,6 +118,10 @@ public static class AgentQueryContextBuilder
ReusedToolResultPreviewCount = budgetResult.ReusedPreviewCount,
TokensBeforeBudget = tokensBeforeBudget,
TokensAfterBudget = tokensAfterBudget,
ProfileName = options.ProfileName,
ProtectedRecentNonSystemMessages = options.ToolResultBudget.ProtectedRecentNonSystemMessages,
ToolResultSoftCharLimit = options.ToolResultBudget.SoftCharLimit,
ToolResultAggregateBudgetChars = options.ToolResultBudget.AggregateBudgetChars,
};
}
@@ -115,7 +151,11 @@ public static class AgentQueryContextBuilder
}
var content = message.Content ?? "";
return content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
return content.StartsWith("[previous conversation summary", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[session memory compaction", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[previous execution bundle compaction", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[previous compaction boundary merge", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
@@ -219,24 +259,22 @@ public static class AgentQueryContextBuilder
private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
{
toolName = "";
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(message.Content)
|| !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
var content = message.Content ?? "";
if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
{
toolName = toolNameEl.GetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
using var doc = System.Text.Json.JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
return false;
toolName = toolNameEl.GetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
catch
{
return false;
}
return false;
}
}

View File

@@ -12,20 +12,61 @@ public sealed class AgentToolResultBudgetResult
}
/// <summary>
/// 오래된 tool_result를 query view와 압축 단계에서 같은 기준으로 줄이기 위한 공용 helper.
/// Applies a query-time budget to historical tool_result messages without
/// shrinking the recent active working set more than necessary.
/// </summary>
public static class AgentToolResultBudget
{
public const int DefaultSoftCharLimit = 900;
public const int DefaultAggregateBudgetChars = 7_500;
private static readonly HashSet<string> s_highValueCodeTools = new(StringComparer.OrdinalIgnoreCase)
{
"build_run",
"test_loop",
"process",
"shell",
"file_read",
"multi_read",
"lsp_code_intel",
"git_tool",
};
public sealed class AgentToolResultBudgetOptions
{
public string ProfileName { get; init; } = "default";
public int ProtectedRecentNonSystemMessages { get; init; } = 8;
public int SoftCharLimit { get; init; } = DefaultSoftCharLimit;
public int AggregateBudgetChars { get; init; } = DefaultAggregateBudgetChars;
public bool PreferDetailedCodeToolResults { get; init; }
}
public static AgentToolResultBudgetOptions CreateDefaultOptions()
=> new()
{
ProfileName = "default",
ProtectedRecentNonSystemMessages = 8,
SoftCharLimit = DefaultSoftCharLimit,
AggregateBudgetChars = DefaultAggregateBudgetChars,
PreferDetailedCodeToolResults = false,
};
public static AgentToolResultBudgetOptions CreateCodeOptions()
=> new()
{
ProfileName = "code",
ProtectedRecentNonSystemMessages = 14,
SoftCharLimit = 1_400,
AggregateBudgetChars = 16_000,
PreferDetailedCodeToolResults = true,
};
public static AgentToolResultBudgetResult Apply(
List<ChatMessage> messages,
int protectedRecentNonSystemMessages,
int softCharLimit = DefaultSoftCharLimit,
int aggregateBudgetChars = DefaultAggregateBudgetChars,
AgentToolResultBudgetOptions? options = null,
IReadOnlyList<ChatMessage>? sourceMessages = null)
{
options ??= CreateDefaultOptions();
var result = new AgentToolResultBudgetResult();
var previewSourceMessages = sourceMessages ?? messages;
if (ReferenceEquals(previewSourceMessages, messages))
@@ -47,10 +88,10 @@ public static class AgentToolResultBudget
.Select(x => x.index)
.ToList();
if (nonSystemIndexes.Count <= protectedRecentNonSystemMessages)
if (nonSystemIndexes.Count <= options.ProtectedRecentNonSystemMessages)
return result;
var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - protectedRecentNonSystemMessages)];
var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - options.ProtectedRecentNonSystemMessages)];
var spentChars = 0;
for (var i = 0; i < protectedStart; i++)
@@ -87,10 +128,12 @@ public static class AgentToolResultBudget
}
spentChars += content.Length;
if (content.Length <= softCharLimit && spentChars <= aggregateBudgetChars)
var effectiveSoftLimit = GetEffectiveSoftLimit(content, options);
var effectiveAggregateLimit = GetEffectiveAggregateLimit(content, options);
if (content.Length <= effectiveSoftLimit && spentChars <= effectiveAggregateLimit)
continue;
var truncated = TruncateToolResultJson(content, softCharLimit);
var truncated = TruncateToolResultJson(content, effectiveSoftLimit);
if (string.Equals(truncated, content, StringComparison.Ordinal))
continue;
@@ -109,6 +152,22 @@ public static class AgentToolResultBudget
return result;
}
public static AgentToolResultBudgetResult Apply(
List<ChatMessage> messages,
int protectedRecentNonSystemMessages,
int softCharLimit = DefaultSoftCharLimit,
int aggregateBudgetChars = DefaultAggregateBudgetChars,
IReadOnlyList<ChatMessage>? sourceMessages = null)
=> Apply(
messages,
new AgentToolResultBudgetOptions
{
ProtectedRecentNonSystemMessages = protectedRecentNonSystemMessages,
SoftCharLimit = softCharLimit,
AggregateBudgetChars = aggregateBudgetChars,
},
sourceMessages);
public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit)
{
try
@@ -130,7 +189,7 @@ public static class AgentToolResultBudget
var head = content[..keepHead];
var tail = keepTail > 0 ? content[^keepTail..] : "";
var compacted = head +
$"\n...[tool_result 축약: {content.Length:N0}]...\n" +
$"\n...[tool_result truncated: {content.Length:N0} chars]...\n" +
tail;
return JsonSerializer.Serialize(new
@@ -147,7 +206,7 @@ public static class AgentToolResultBudget
return json;
var head = json[..Math.Min(softCharLimit, json.Length)];
return head + "...[tool_result 축약]";
return head + "...[tool_result truncated]";
}
}
@@ -177,6 +236,62 @@ public static class AgentToolResultBudget
}
}
private static int GetEffectiveSoftLimit(string content, AgentToolResultBudgetOptions options)
{
if (!options.PreferDetailedCodeToolResults)
return options.SoftCharLimit;
var toolName = ExtractToolName(content);
if (string.IsNullOrWhiteSpace(toolName))
return options.SoftCharLimit;
if (string.Equals(toolName, "build_run", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "test_loop", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "process", StringComparison.OrdinalIgnoreCase))
{
return Math.Max(options.SoftCharLimit, 3_200);
}
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "multi_read", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "lsp_code_intel", StringComparison.OrdinalIgnoreCase))
{
return Math.Max(options.SoftCharLimit, 2_400);
}
if (s_highValueCodeTools.Contains(toolName))
return Math.Max(options.SoftCharLimit, 1_800);
return options.SoftCharLimit;
}
private static int GetEffectiveAggregateLimit(string content, AgentToolResultBudgetOptions options)
{
if (!options.PreferDetailedCodeToolResults)
return options.AggregateBudgetChars;
var toolName = ExtractToolName(content);
return s_highValueCodeTools.Contains(toolName)
? Math.Max(options.AggregateBudgetChars, 22_000)
: options.AggregateBudgetChars;
}
private static string ExtractToolName(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("tool_name", out var toolName))
return toolName.GetString() ?? "";
}
catch
{
// Ignore malformed payloads and fall back to the default budget.
}
return "";
}
private static ChatMessage CloneMessage(ChatMessage source, string content)
{
return new ChatMessage

View File

@@ -0,0 +1,479 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed class CodeTaskWorkingSetService
{
private const int MaxListItems = 6;
private static readonly Regex BuildDiagnosticWithFileRegex = new(
@"(?<file>(?:[A-Za-z]:)?[^:\r\n]+?\.[A-Za-z0-9]+)\((?<line>\d+)(?:,\d+)?\)\s*:\s*error\s*(?<code>[A-Z]{1,4}\d{3,5})\s*:\s*(?<message>.+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex BuildDiagnosticRegex = new(
@"error\s*(?<code>[A-Z]{1,4}\d{3,5})\s*:\s*(?<message>.+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly object _lock = new();
private readonly string _goal;
private readonly string _workFolder;
private readonly string _scaffoldProfileLabel;
private readonly bool _startedFromEmptyWorkspace;
private readonly List<string> _createdDirectories = new();
private readonly List<string> _recentReads = new();
private readonly List<string> _recentWrites = new();
private readonly List<CodeDiagnostic> _activeDiagnostics = new();
private string _environmentSummary = "";
private string _nextFocus = "";
private string _lastMutationSummary = "";
public CodeTaskWorkingSetService(
string goal,
string? workFolder,
string? scaffoldProfileLabel,
bool startedFromEmptyWorkspace)
{
_goal = CollapseWhitespace(goal);
_workFolder = workFolder?.Trim() ?? "";
_scaffoldProfileLabel = scaffoldProfileLabel?.Trim() ?? "";
_startedFromEmptyWorkspace = startedFromEmptyWorkspace;
}
public void RecordToolResult(string toolName, JsonElement? toolInput, ToolResult result)
{
if (string.IsNullOrWhiteSpace(toolName))
return;
lock (_lock)
{
TrackEnvironment(toolName, result);
TrackDirectories(toolName, toolInput, result);
TrackReads(toolName, toolInput, result);
TrackWrites(toolName, toolInput, result);
TrackDiagnostics(toolName, result);
UpdateNextFocus(toolName, result);
}
}
public ChatMessage? BuildChatMessage()
{
string content;
lock (_lock)
{
content = BuildContentCore();
}
if (string.IsNullOrWhiteSpace(content))
return null;
return new ChatMessage
{
Role = "system",
MetaKind = "code_working_set",
Content = content,
Timestamp = DateTime.Now,
};
}
public string DescribeForLog()
{
lock (_lock)
{
return $"writes={_recentWrites.Count}, reads={_recentReads.Count}, dirs={_createdDirectories.Count}, diagnostics={_activeDiagnostics.Count}, next={TruncateSingleLine(_nextFocus, 80)}";
}
}
private string BuildContentCore()
{
var lines = new List<string> { "[code-working-set]" };
if (!string.IsNullOrWhiteSpace(_goal))
lines.Add($"- Goal: {TruncateSingleLine(_goal, 220)}");
if (!string.IsNullOrWhiteSpace(_workFolder))
lines.Add($"- Workspace: {_workFolder}");
if (!string.IsNullOrWhiteSpace(_scaffoldProfileLabel))
lines.Add($"- Scaffold profile: {_scaffoldProfileLabel}");
if (_startedFromEmptyWorkspace)
lines.Add("- Initial workspace: empty");
if (_createdDirectories.Count > 0)
lines.Add("- Structure chosen: " + string.Join(", ", _createdDirectories));
if (_recentWrites.Count > 0)
lines.Add("- Recent writes: " + string.Join(", ", _recentWrites));
if (_recentReads.Count > 0)
lines.Add("- Recent reads: " + string.Join(", ", _recentReads));
if (!string.IsNullOrWhiteSpace(_environmentSummary))
lines.Add("- Environment: " + _environmentSummary);
foreach (var diagnostic in _activeDiagnostics.Take(2))
lines.Add("- Active diagnostic: " + diagnostic.ToSummary());
if (!string.IsNullOrWhiteSpace(_lastMutationSummary))
lines.Add("- Last mutation: " + _lastMutationSummary);
if (!string.IsNullOrWhiteSpace(_nextFocus))
lines.Add("- Next focus: " + _nextFocus);
if (lines.Count <= 1)
return "";
lines.Add("- Keep continuity with the listed structure and files unless the latest diagnostics clearly contradict them.");
return string.Join("\n", lines);
}
private void TrackEnvironment(string toolName, ToolResult result)
{
if (!string.Equals(toolName, "dev_env_detect", StringComparison.OrdinalIgnoreCase))
return;
var firstMeaningfulLine = (result.Output ?? "")
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(CollapseWhitespace)
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
if (!string.IsNullOrWhiteSpace(firstMeaningfulLine))
_environmentSummary = TruncateSingleLine(firstMeaningfulLine, 160);
}
private void TrackDirectories(string toolName, JsonElement? toolInput, ToolResult result)
{
if (!string.Equals(toolName, "file_manage", StringComparison.OrdinalIgnoreCase))
return;
var action = TryGetString(toolInput, "action", "mode", "operation");
if (!string.IsNullOrWhiteSpace(action)
&& !action.Contains("dir", StringComparison.OrdinalIgnoreCase)
&& !action.Contains("mkdir", StringComparison.OrdinalIgnoreCase)
&& !action.Contains("folder", StringComparison.OrdinalIgnoreCase))
{
return;
}
foreach (var candidate in ExtractPathCandidates(toolInput))
{
if (LooksLikeDirectory(candidate))
AddRecentUnique(_createdDirectories, NormalizePath(candidate), MaxListItems);
}
if (!string.IsNullOrWhiteSpace(result.FilePath) && LooksLikeDirectory(result.FilePath))
AddRecentUnique(_createdDirectories, NormalizePath(result.FilePath), MaxListItems);
}
private void TrackReads(string toolName, JsonElement? toolInput, ToolResult result)
{
if (!IsReadOnlyContextTool(toolName))
return;
foreach (var candidate in ExtractPathCandidates(toolInput))
{
if (LooksLikeFile(candidate))
AddRecentUnique(_recentReads, NormalizePath(candidate), MaxListItems);
}
if (!string.IsNullOrWhiteSpace(result.FilePath) && LooksLikeFile(result.FilePath))
AddRecentUnique(_recentReads, NormalizePath(result.FilePath), MaxListItems);
}
private void TrackWrites(string toolName, JsonElement? toolInput, ToolResult result)
{
if (!IsMutatingCodeTool(toolName))
return;
var touchedFiles = new List<string>();
foreach (var candidate in ExtractPathCandidates(toolInput))
{
if (LooksLikeFile(candidate))
touchedFiles.Add(NormalizePath(candidate));
}
if (!string.IsNullOrWhiteSpace(result.FilePath) && LooksLikeFile(result.FilePath))
touchedFiles.Add(NormalizePath(result.FilePath));
foreach (var file in touchedFiles.Where(path => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase))
AddRecentUnique(_recentWrites, file, MaxListItems);
if (touchedFiles.Count > 0)
_lastMutationSummary = "Updated " + string.Join(", ", touchedFiles.Distinct(StringComparer.OrdinalIgnoreCase).Take(3));
}
private void TrackDiagnostics(string toolName, ToolResult result)
{
if (!IsVerificationTool(toolName))
return;
if (result.Success)
{
_activeDiagnostics.Clear();
return;
}
var diagnostics = ExtractDiagnostics(toolName, result.Output ?? "");
if (diagnostics.Count == 0 && !string.IsNullOrWhiteSpace(result.Error))
diagnostics = ExtractDiagnostics(toolName, result.Error);
if (diagnostics.Count == 0)
{
var fallbackSummary = CollapseWhitespace(result.Error ?? result.Output ?? "");
if (!string.IsNullOrWhiteSpace(fallbackSummary))
{
_activeDiagnostics.Clear();
_activeDiagnostics.Add(new CodeDiagnostic(toolName, "", null, "", TruncateSingleLine(fallbackSummary, 180)));
}
return;
}
_activeDiagnostics.Clear();
_activeDiagnostics.AddRange(diagnostics.Take(3));
}
private void UpdateNextFocus(string toolName, ToolResult result)
{
if (_activeDiagnostics.Count > 0)
{
_nextFocus = "Resolve the latest build/test diagnostics before creating more files.";
return;
}
if (IsMutatingCodeTool(toolName) && result.Success)
{
_nextFocus = "Verify the recent code changes with build or test before broadening the scope.";
return;
}
if (IsReadOnlyContextTool(toolName) && _recentWrites.Count > 0)
{
_nextFocus = "Use the inspected files to continue from the latest edits instead of restarting exploration.";
return;
}
if (string.IsNullOrWhiteSpace(_nextFocus) && _createdDirectories.Count > 0)
_nextFocus = "Keep writing into the chosen project structure instead of falling back to flat root files.";
}
private List<CodeDiagnostic> ExtractDiagnostics(string toolName, string text)
{
var diagnostics = new List<CodeDiagnostic>();
if (string.IsNullOrWhiteSpace(text))
return diagnostics;
foreach (Match match in BuildDiagnosticWithFileRegex.Matches(text))
{
var filePath = NormalizePath(match.Groups["file"].Value);
int? lineNumber = int.TryParse(match.Groups["line"].Value, out var parsedLine) ? parsedLine : null;
var code = match.Groups["code"].Value.Trim();
var message = CollapseWhitespace(match.Groups["message"].Value);
diagnostics.Add(new CodeDiagnostic(toolName, filePath, lineNumber, code, message));
if (diagnostics.Count >= 3)
return DistinctDiagnostics(diagnostics);
}
foreach (Match match in BuildDiagnosticRegex.Matches(text))
{
var code = match.Groups["code"].Value.Trim();
var message = CollapseWhitespace(match.Groups["message"].Value);
diagnostics.Add(new CodeDiagnostic(toolName, "", null, code, message));
if (diagnostics.Count >= 3)
break;
}
return DistinctDiagnostics(diagnostics);
}
private static List<CodeDiagnostic> DistinctDiagnostics(IEnumerable<CodeDiagnostic> diagnostics)
=> diagnostics
.GroupBy(diagnostic => diagnostic.CacheKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Take(3)
.ToList();
private IEnumerable<string> ExtractPathCandidates(JsonElement? toolInput)
{
if (toolInput is not { ValueKind: JsonValueKind.Object } input)
yield break;
foreach (var property in input.EnumerateObject())
{
var propertyName = property.Name;
if (property.Value.ValueKind == JsonValueKind.String)
{
var text = property.Value.GetString() ?? "";
if (LooksLikePathProperty(propertyName, text))
yield return text;
}
else if (property.Value.ValueKind == JsonValueKind.Array)
{
foreach (var item in property.Value.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
continue;
var text = item.GetString() ?? "";
if (LooksLikePathProperty(propertyName, text))
yield return text;
}
}
}
}
private static bool LooksLikePathProperty(string propertyName, string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var loweredName = propertyName.Trim().ToLowerInvariant();
if (loweredName.Contains("path", StringComparison.Ordinal)
|| loweredName.Contains("file", StringComparison.Ordinal)
|| loweredName.Contains("dir", StringComparison.Ordinal)
|| loweredName.Contains("folder", StringComparison.Ordinal)
|| loweredName.Contains("target", StringComparison.Ordinal)
|| loweredName.Contains("source", StringComparison.Ordinal)
|| loweredName.Contains("destination", StringComparison.Ordinal))
{
return true;
}
return value.Contains('\\', StringComparison.Ordinal)
|| value.Contains('/', StringComparison.Ordinal)
|| value.Contains('.', StringComparison.Ordinal);
}
private string NormalizePath(string rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
return rawPath.Trim();
var trimmed = rawPath.Trim().Trim('"');
try
{
var candidate = trimmed;
if (!Path.IsPathRooted(candidate) && !string.IsNullOrWhiteSpace(_workFolder))
candidate = Path.Combine(_workFolder, candidate);
var normalized = Path.GetFullPath(candidate);
if (!string.IsNullOrWhiteSpace(_workFolder))
{
var root = Path.GetFullPath(_workFolder)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (normalized.StartsWith(root, StringComparison.OrdinalIgnoreCase))
{
var relative = Path.GetRelativePath(root, normalized);
return relative.Replace('\\', '/');
}
}
return normalized.Replace('\\', '/');
}
catch
{
return trimmed.Replace('\\', '/');
}
}
private static bool LooksLikeDirectory(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var trimmed = value.Trim().Trim('"').Replace('\\', '/');
if (trimmed.EndsWith("/", StringComparison.Ordinal))
return true;
var fileName = Path.GetFileName(trimmed);
return !fileName.Contains('.', StringComparison.Ordinal);
}
private static bool LooksLikeFile(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var trimmed = value.Trim().Trim('"');
var fileName = Path.GetFileName(trimmed);
return fileName.Contains('.', StringComparison.Ordinal);
}
private static bool IsMutatingCodeTool(string toolName)
=> toolName.Contains("file_write", StringComparison.OrdinalIgnoreCase)
|| toolName.Contains("file_edit", StringComparison.OrdinalIgnoreCase)
|| toolName.Contains("multi_edit", StringComparison.OrdinalIgnoreCase)
|| toolName.Contains("apply_patch", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "file_manage", StringComparison.OrdinalIgnoreCase);
private static bool IsReadOnlyContextTool(string toolName)
=> string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "multi_read", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "lsp_code_intel", StringComparison.OrdinalIgnoreCase);
private static bool IsVerificationTool(string toolName)
=> string.Equals(toolName, "build_run", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "test_loop", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "process", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "shell", StringComparison.OrdinalIgnoreCase);
private static string? TryGetString(JsonElement? input, params string[] propertyNames)
{
if (input is not { ValueKind: JsonValueKind.Object } obj)
return null;
foreach (var name in propertyNames)
{
if (obj.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String)
return value.GetString();
}
return null;
}
private static void AddRecentUnique(List<string> list, string value, int maxCount)
{
if (string.IsNullOrWhiteSpace(value))
return;
var existingIndex = list.FindIndex(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
list.RemoveAt(existingIndex);
list.Add(value);
if (list.Count > maxCount)
list.RemoveRange(0, list.Count - maxCount);
}
private static string CollapseWhitespace(string? value)
=> Regex.Replace(value ?? "", @"\s+", " ").Trim();
private static string TruncateSingleLine(string? value, int maxLength)
{
var collapsed = CollapseWhitespace(value);
if (collapsed.Length <= maxLength)
return collapsed;
return collapsed[..maxLength].TrimEnd() + "...";
}
private sealed record CodeDiagnostic(
string ToolName,
string FilePath,
int? LineNumber,
string Code,
string Message)
{
public string CacheKey => $"{ToolName}|{FilePath}|{LineNumber}|{Code}|{Message}";
public string ToSummary()
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(Code))
parts.Add(Code);
if (!string.IsNullOrWhiteSpace(FilePath))
parts.Add(FilePath);
if (LineNumber is > 0)
parts.Add($"line {LineNumber.Value}");
parts.Add(TruncateSingleLine(Message, 140));
return string.Join(" - ", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
}
}
}

View File

@@ -906,11 +906,13 @@ public partial class LlmService
});
}
// ── tool_calls ↔ tool 메시지 쌍 검증 ──
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
// 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지.
SanitizeToolCallPairs(msgs);
// Validate historical tool-call chains before sending the request.
var sanitization = SanitizeToolCallPairs(msgs);
if (sanitization.TotalRepairedCount > 0)
{
LogService.Info(
$"[ToolUse] normalized historical tool trace (flattened_assistant={sanitization.FlattenedAssistantCount}, converted_orphans={sanitization.ConvertedOrphanToolCount})");
}
// OpenAI 도구 정의
var toolDefs = tools.Select(t =>
@@ -1964,15 +1966,20 @@ public partial class LlmService
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다.
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
/// </summary>
private static void SanitizeToolCallPairs(List<object> msgs)
private sealed record ToolCallPairSanitizationResult(
int FlattenedAssistantCount,
int ConvertedOrphanToolCount)
{
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ──
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용
public int TotalRepairedCount => FlattenedAssistantCount + ConvertedOrphanToolCount;
}
/// <summary>
/// Repairs broken historical tool_call/tool pairs before the request leaves the client.
/// </summary>
private static ToolCallPairSanitizationResult SanitizeToolCallPairs(List<object> msgs)
{
var flattenedAssistantCount = 0;
var convertedOrphanToolCount = 0;
var pairedToolIndices = new HashSet<int>();
for (int i = 0; i < msgs.Count; i++)
@@ -2029,13 +2036,14 @@ public partial class LlmService
var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? "";
msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" };
pairedToolIndices.Remove(j);
convertedOrphanToolCount++;
}
flattenedAssistantCount++;
LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})");
}
}
// ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ──
for (int i = 0; i < msgs.Count; i++)
{
if (pairedToolIndices.Contains(i)) continue;
@@ -2047,8 +2055,11 @@ public partial class LlmService
var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? "";
msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" };
convertedOrphanToolCount++;
LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})");
}
return new ToolCallPairSanitizationResult(flattenedAssistantCount, convertedOrphanToolCount);
}
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>

View File

@@ -22,7 +22,7 @@ public partial class ChatWindow
// ─── 프로젝트 문맥 파일 (AGENTS.md) ──────────────────────────────────
/// <summary>
/// P4: 워크스페이스 컨텍스트 자동 생성 파일(.ax-context.md)을 읽어 시스템 프롬프트에 주입합니다.
/// Loads the workspace context file and kicks off bootstrap generation on first use.
/// </summary>
private static string LoadWorkspaceContext(string? workFolder)
{
@@ -32,13 +32,32 @@ public partial class ChatWindow
if (!(app?.SettingsService?.Settings.Llm.EnableAutoWorkspaceContext ?? true))
return "";
var ensureTask = Services.Agent.WorkspaceContextGenerator.EnsureContextAsync(workFolder);
var content = Services.Agent.WorkspaceContextGenerator.LoadContext(workFolder);
if (string.IsNullOrEmpty(content)) return "";
if (string.IsNullOrEmpty(content))
{
try
{
if (ensureTask.Wait(TimeSpan.FromMilliseconds(180)))
content = ensureTask.Result;
}
catch
{
// Keep the prompt builder non-blocking when background generation fails.
}
}
// 비동기 자동 생성 트리거 (파일이 아직 없을 때)
_ = Task.Run(() => Services.Agent.WorkspaceContextGenerator.EnsureContextAsync(workFolder));
if (!string.IsNullOrEmpty(content))
return $"\n## Workspace Context (auto-detected)\n{content}\n";
return $"\n## Workspace Context (auto-detected)\n{content}\n";
var workflowHints = Services.Agent.WorkspaceContextGenerator.DetectLanguageWorkflowHints(workFolder);
if (workflowHints.Count == 0)
return "";
var hintLines = string.Join("\n", workflowHints.Select(hint => $"- {hint}"));
return "\n## Workspace Context (bootstrap)\n- Background workspace context generation started.\n" +
hintLines +
"\n";
}
/// <summary>