From 0f64bf3f843223408cbf92c138426745ca41636e Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 01:45:28 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFCode=20=ED=83=AD=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=88=84=EC=A0=81=20=EC=8B=A0=EB=A2=B0?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=9E=91=EC=97=85=20=EC=97=B0=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=A0=84=EB=A9=B4=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이번 커밋은 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 --- README.md | 9 + docs/CODE_CONTEXT_RELIABILITY_PLAN.md | 25 + docs/DEVELOPMENT.md | 43 ++ ...entLoopIterationPreparationServiceTests.cs | 32 ++ ...ntLoopLlmRequestPreparationServiceTests.cs | 31 ++ .../Services/AgentQueryContextBuilderTests.cs | 28 + .../Services/AgentToolResultBudgetTests.cs | 88 +--- .../CodeTaskWorkingSetServiceTests.cs | 92 ++++ .../Agent/AgentLoopContextReliability.cs | 19 + .../AgentLoopIterationPreparationService.cs | 10 +- .../AgentLoopLlmRequestPreparationService.cs | 42 +- .../Services/Agent/AgentLoopService.cs | 32 +- .../Agent/AgentQueryContextBuilder.cs | 70 ++- .../Services/Agent/AgentToolResultBudget.cs | 135 ++++- .../Agent/CodeTaskWorkingSetService.cs | 479 ++++++++++++++++++ src/AxCopilot/Services/LlmService.ToolUse.cs | 39 +- .../Views/ChatWindow.UtilityPresentation.cs | 29 +- 17 files changed, 1074 insertions(+), 129 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs create mode 100644 src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs diff --git a/README.md b/README.md index 83ad787..551fd02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index 85f2d97..45936e4 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f8f933e..0391993 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs index 7d85c09..39e07ac 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs @@ -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 + { + 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() { diff --git a/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs index 7f12ebe..1f01a90 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs @@ -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 + { + 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]"); + } } diff --git a/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs b/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs index 5b692c6..f98e340 100644 --- a/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs +++ b/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs @@ -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 + { + 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); + } } diff --git a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs index 19b0c33..679913f 100644 --- a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs +++ b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs @@ -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 + var longContent = new string('B', 2_600); + var queryView = new List { 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 - { - 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 - { - 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 { 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"); diff --git a/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs b/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs new file mode 100644 index 0000000..e11d093 --- /dev/null +++ b/src/AxCopilot.Tests/Services/CodeTaskWorkingSetServiceTests.cs @@ -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"); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs new file mode 100644 index 0000000..040714a --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs @@ -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}"; + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs b/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs index d147224..ae390d5 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs @@ -8,9 +8,8 @@ internal sealed record AgentLoopIterationPreparationResult( string? ToolResultWaitSummary); /// -/// 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. /// 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); diff --git a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs index 68d2b3e..76b93bd 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs @@ -5,12 +5,12 @@ namespace AxCopilot.Services.Agent; internal sealed record AgentLoopLlmRequestPreparationResult( List SendMessages, bool ForceInitialToolCall, - bool InjectedToolReminder); + bool InjectedToolReminder, + int SupplementalMessageCount); /// -/// query view가 만들어진 뒤 실제 LLM 요청 배열을 조립합니다. -/// 초기 tool call 강제 여부와 사전 reminder 주입을 한곳에서 결정해 -/// AgentLoopService 본체가 orchestration에 더 집중하도록 분리합니다. +/// Builds the final LLM request array from the query window and optional +/// supplemental context blocks. /// internal static class AgentLoopLlmRequestPreparationService { @@ -19,25 +19,29 @@ internal static class AgentLoopLlmRequestPreparationService int totalToolCalls, bool forceInitialToolCallEnabled, bool injectPreCallToolReminder, - int noToolCallLoopRetry) + int noToolCallLoopRetry, + IEnumerable? 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] 지금 즉시 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" + + Content = "[TOOL_REQUIRED] Emit a valid block right away. A plain-text reply will be rejected.\n" + "Output format:\n\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n" }; } + + private static int AppendSupplementalMessages(List sendMessages, IEnumerable? 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; + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 172ed92..b4269f6 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -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) { diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs index 28ff45d..0454106 100644 --- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -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; } } /// -/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다. +/// Builds the query window that is actually sent to the LLM, similar to +/// claude-code's messagesForQuery pipeline. /// 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 sourceMessages) + public static AgentQueryContextWindowResult Build( + IReadOnlyList sourceMessages, + AgentQueryContextBuildOptions? options = null) { + options ??= AgentQueryContextBuildOptions.CreateDefault(); if (sourceMessages is IList 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; } } diff --git a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs index 67f4495..5afeb66 100644 --- a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs +++ b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs @@ -12,20 +12,61 @@ public sealed class AgentToolResultBudgetResult } /// -/// 오래된 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. /// public static class AgentToolResultBudget { public const int DefaultSoftCharLimit = 900; public const int DefaultAggregateBudgetChars = 7_500; + private static readonly HashSet 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 messages, - int protectedRecentNonSystemMessages, - int softCharLimit = DefaultSoftCharLimit, - int aggregateBudgetChars = DefaultAggregateBudgetChars, + AgentToolResultBudgetOptions? options = null, IReadOnlyList? 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 messages, + int protectedRecentNonSystemMessages, + int softCharLimit = DefaultSoftCharLimit, + int aggregateBudgetChars = DefaultAggregateBudgetChars, + IReadOnlyList? 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 diff --git a/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs b/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs new file mode 100644 index 0000000..df58744 --- /dev/null +++ b/src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs @@ -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( + @"(?(?:[A-Za-z]:)?[^:\r\n]+?\.[A-Za-z0-9]+)\((?\d+)(?:,\d+)?\)\s*:\s*error\s*(?[A-Z]{1,4}\d{3,5})\s*:\s*(?.+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex BuildDiagnosticRegex = new( + @"error\s*(?[A-Z]{1,4}\d{3,5})\s*:\s*(?.+)", + 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 _createdDirectories = new(); + private readonly List _recentReads = new(); + private readonly List _recentWrites = new(); + private readonly List _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 { "[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(); + 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 ExtractDiagnostics(string toolName, string text) + { + var diagnostics = new List(); + 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 DistinctDiagnostics(IEnumerable diagnostics) + => diagnostics + .GroupBy(diagnostic => diagnostic.CacheKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Take(3) + .ToList(); + + private IEnumerable 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 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(); + 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))); + } + } +} diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index f874c08..ba86cd1 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -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 // ─── 공통 헬퍼 ───────────────────────────────────────────────────── - /// - /// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면 - /// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다. - /// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다. - /// - private static void SanitizeToolCallPairs(List msgs) + private sealed record ToolCallPairSanitizationResult( + int FlattenedAssistantCount, + int ConvertedOrphanToolCount) { - // ── 1패스: tool_calls assistant 메시지의 쌍 검증 ── - // tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용 + public int TotalRepairedCount => FlattenedAssistantCount + ConvertedOrphanToolCount; + } + + /// + /// Repairs broken historical tool_call/tool pairs before the request leaves the client. + /// + private static ToolCallPairSanitizationResult SanitizeToolCallPairs(List msgs) + { + var flattenedAssistantCount = 0; + var convertedOrphanToolCount = 0; var pairedToolIndices = new HashSet(); 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); } /// ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함. diff --git a/src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs b/src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs index 60a2a53..2a35fd9 100644 --- a/src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs @@ -22,7 +22,7 @@ public partial class ChatWindow // ─── 프로젝트 문맥 파일 (AGENTS.md) ────────────────────────────────── /// - /// P4: 워크스페이스 컨텍스트 자동 생성 파일(.ax-context.md)을 읽어 시스템 프롬프트에 주입합니다. + /// Loads the workspace context file and kicks off bootstrap generation on first use. /// 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"; } ///