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 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 - `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 - better structural consistency for project generation and large edits
- less drift in long-running Code tasks - less drift in long-running Code tasks
- fewer quality losses caused by broken strings and low-signal context replacements - 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 - Encoding hygiene and prompt quality cleanup
- 계획 문서는 `claude-code` 참조 지점(`claw-code/.../src/query.ts`, `history.ts`, `memory-context.md`), AX 적용 위치, 완료 조건, 품질 판정 시나리오를 함께 기록했습니다. - 계획 문서는 `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" 원칙을 계획에 녹였습니다. - 외부 근거로는 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"); 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] [Fact]
public void BuildToolResultWaitSummary_ShouldFormatToolNameAndElapsedMilliseconds() public void BuildToolResultWaitSummary_ShouldFormatToolNameAndElapsedMilliseconds()
{ {

View File

@@ -55,4 +55,35 @@ public class AgentLoopLlmRequestPreparationServiceTests
result.InjectedToolReminder.Should().BeFalse(); result.InjectedToolReminder.Should().BeFalse();
result.SendMessages.Should().HaveCount(1); 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 != null &&
message.QueryPreviewContent.Contains("call-synth-view", StringComparison.OrdinalIgnoreCase)); 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 Timestamp = message.Timestamp
}).ToList(); }).ToList();
var first = AgentToolResultBudget.Apply(firstWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages); var first = AgentToolResultBudget.Apply(
firstWindow,
protectedRecentNonSystemMessages: 1,
sourceMessages: sourceMessages);
sourceMessages[0].QueryPreviewContent.Should().NotBeNullOrWhiteSpace(); sourceMessages[0].QueryPreviewContent.Should().NotBeNullOrWhiteSpace();
first.TruncatedCount.Should().Be(1); first.TruncatedCount.Should().Be(1);
@@ -50,24 +53,26 @@ public class AgentToolResultBudgetTests
Timestamp = message.Timestamp Timestamp = message.Timestamp
}).ToList(); }).ToList();
var second = AgentToolResultBudget.Apply(secondWindow, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages); var second = AgentToolResultBudget.Apply(
secondWindow,
protectedRecentNonSystemMessages: 1,
sourceMessages: sourceMessages);
second.ReusedPreviewCount.Should().Be(1); second.ReusedPreviewCount.Should().Be(1);
secondWindow[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent); secondWindow[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
} }
[Fact] [Fact]
public void Apply_ShouldReusePreviewByToolUseIdAcrossClonedMessages() public void Apply_ShouldPreserveLargerBuildResultsInCodeMode()
{ {
var longContent = new string('B', 1500); var longContent = new string('B', 2_600);
var sourceMessages = new List<ChatMessage> var queryView = new List<ChatMessage>
{ {
new() new()
{ {
MsgId = "source-tool", MsgId = "tool-build",
Role = "user", Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"{{longContent}}"}""", Content = $$"""{"type":"tool_result","tool_use_id":"call-build","tool_name":"build_run","content":"{{longContent}}"}"""
QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"preview"}"""
}, },
new() new()
{ {
@@ -77,65 +82,19 @@ public class AgentToolResultBudgetTests
} }
}; };
var queryView = new List<ChatMessage> var result = AgentToolResultBudget.Apply(
{ queryView,
new() AgentToolResultBudget.CreateCodeOptions(),
{ sourceMessages: queryView);
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, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages); result.TruncatedCount.Should().Be(0);
queryView[0].Content.Should().Contain(longContent[..256]);
result.ReusedPreviewCount.Should().Be(1);
queryView[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
} }
[Fact] [Fact]
public void Apply_ShouldReusePreviewWithinSameWindow_WhenSourceMessagesAreOmitted() public void Apply_ShouldReusePreviewFingerprintAcrossSessions()
{ {
var longContent = new string('C', 1600); var longContent = new string('C', 1700);
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 sourceMessages = new List<ChatMessage> var sourceMessages = new List<ChatMessage>
{ {
new() 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); result.ReusedPreviewCount.Should().Be(1);
queryView[0].Content.Should().Contain("call-replayed"); 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); string? ToolResultWaitSummary);
/// <summary> /// <summary>
/// RunAsync 반복 시작 시점의 공통 준비 작업을 묶습니다. /// Performs the common per-iteration preparation work before each LLM request.
/// queued command 투영, tool_result 대기 진단, query view 생성 책임을 분리해 /// This keeps AgentLoopService focused on orchestration.
/// AgentLoopService 본체를 orchestration 중심으로 유지합니다.
/// </summary> /// </summary>
internal static class AgentLoopIterationPreparationService internal static class AgentLoopIterationPreparationService
{ {
@@ -19,10 +18,11 @@ internal static class AgentLoopIterationPreparationService
AgentCommandQueue pendingCommands, AgentCommandQueue pendingCommands,
DateTime? lastToolResultAtUtc, DateTime? lastToolResultAtUtc,
string? lastToolResultToolName, string? lastToolResultToolName,
DateTime utcNow) DateTime utcNow,
AgentQueryContextBuilder.AgentQueryContextBuildOptions? queryOptions = null)
{ {
var queueProjection = ProjectQueuedCommands(messages, pendingCommands); var queueProjection = ProjectQueuedCommands(messages, pendingCommands);
var queryView = AgentQueryContextBuilder.Build(messages); var queryView = AgentQueryContextBuilder.Build(messages, queryOptions);
var waitSummary = BuildToolResultWaitSummary(lastToolResultAtUtc, lastToolResultToolName, utcNow); var waitSummary = BuildToolResultWaitSummary(lastToolResultAtUtc, lastToolResultToolName, utcNow);
return new AgentLoopIterationPreparationResult(queueProjection, queryView, waitSummary); return new AgentLoopIterationPreparationResult(queueProjection, queryView, waitSummary);

View File

@@ -5,12 +5,12 @@ namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopLlmRequestPreparationResult( internal sealed record AgentLoopLlmRequestPreparationResult(
List<ChatMessage> SendMessages, List<ChatMessage> SendMessages,
bool ForceInitialToolCall, bool ForceInitialToolCall,
bool InjectedToolReminder); bool InjectedToolReminder,
int SupplementalMessageCount);
/// <summary> /// <summary>
/// query view가 만들어진 뒤 실제 LLM 요청 배열을 조립합니다. /// Builds the final LLM request array from the query window and optional
/// 초기 tool call 강제 여부와 사전 reminder 주입을 한곳에서 결정해 /// supplemental context blocks.
/// AgentLoopService 본체가 orchestration에 더 집중하도록 분리합니다.
/// </summary> /// </summary>
internal static class AgentLoopLlmRequestPreparationService internal static class AgentLoopLlmRequestPreparationService
{ {
@@ -19,25 +19,29 @@ internal static class AgentLoopLlmRequestPreparationService
int totalToolCalls, int totalToolCalls,
bool forceInitialToolCallEnabled, bool forceInitialToolCallEnabled,
bool injectPreCallToolReminder, bool injectPreCallToolReminder,
int noToolCallLoopRetry) int noToolCallLoopRetry,
IEnumerable<ChatMessage>? supplementalMessages = null)
{ {
var sendMessages = queryMessages.ToList();
var supplementalCount = AppendSupplementalMessages(sendMessages, supplementalMessages);
var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled; var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled;
if (!forceInitialToolCall if (!forceInitialToolCall
|| !injectPreCallToolReminder || !injectPreCallToolReminder
|| noToolCallLoopRetry > 0) || noToolCallLoopRetry > 0)
{ {
return new AgentLoopLlmRequestPreparationResult( return new AgentLoopLlmRequestPreparationResult(
queryMessages.ToList(), sendMessages,
forceInitialToolCall, forceInitialToolCall,
false); false,
supplementalCount);
} }
var sendMessages = queryMessages.ToList();
sendMessages.Add(BuildToolReminderMessage()); sendMessages.Add(BuildToolReminderMessage());
return new AgentLoopLlmRequestPreparationResult( return new AgentLoopLlmRequestPreparationResult(
sendMessages, sendMessages,
forceInitialToolCall, forceInitialToolCall,
true); true,
supplementalCount);
} }
internal static ChatMessage BuildToolReminderMessage() internal static ChatMessage BuildToolReminderMessage()
@@ -45,8 +49,26 @@ internal static class AgentLoopLlmRequestPreparationService
return new ChatMessage return new ChatMessage
{ {
Role = "user", 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>" "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 explorationState = runBootstrap.ExplorationState;
var pathAccessState = runBootstrap.PathAccessState; var pathAccessState = runBootstrap.PathAccessState;
var sessionLearnings = runBootstrap.SessionLearnings; var sessionLearnings = runBootstrap.SessionLearnings;
var isCodeTab = string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase);
DateTime? lastToolResultAtUtc = null; DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null; string? lastToolResultToolName = null;
@@ -299,6 +300,14 @@ public partial class AgentLoopService
context.InitialUserQuery = userQuery; context.InitialUserQuery = userQuery;
runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder); runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder);
var workspaceWasInitiallyEmpty = runState.WorkspaceAppearsEmpty; var workspaceWasInitiallyEmpty = runState.WorkspaceAppearsEmpty;
var queryContextOptions = CreateQueryContextBuildOptions(isCodeTab);
var codeWorkingSet = isCodeTab
? new CodeTaskWorkingSetService(
userQuery,
context.WorkFolder,
explorationState.ScaffoldProfile?.Label,
workspaceWasInitiallyEmpty)
: null;
var preferredInitialToolSequence = BuildPreferredInitialToolSequence( var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState, explorationState,
@@ -454,7 +463,8 @@ public partial class AgentLoopService
_pendingCommands, _pendingCommands,
lastToolResultAtUtc, lastToolResultAtUtc,
lastToolResultToolName, lastToolResultToolName,
DateTime.UtcNow); DateTime.UtcNow,
queryContextOptions);
if (!string.IsNullOrWhiteSpace(iterationPreparation.ToolResultWaitSummary)) if (!string.IsNullOrWhiteSpace(iterationPreparation.ToolResultWaitSummary))
{ {
WorkflowLogService.LogTransition( WorkflowLogService.LogTransition(
@@ -520,8 +530,8 @@ public partial class AgentLoopService
} }
} }
// P3: 누적 학습 메시지 주입 (매 반복 갱신) // Refresh the session learnings block only for non-Code tabs.
if (sessionLearnings is { Count: > 0 }) if (!isCodeTab && sessionLearnings is { Count: > 0 })
{ {
var learningMsg = sessionLearnings.BuildInjectionMessage(); var learningMsg = sessionLearnings.BuildInjectionMessage();
if (learningMsg != null) if (learningMsg != null)
@@ -562,12 +572,14 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", $"{tabLabel} 탭에서 사용 가능한 도구가 없습니다."); EmitEvent(AgentEventType.Error, "", $"{tabLabel} 탭에서 사용 가능한 도구가 없습니다.");
return $"⚠ 현재 {tabLabel} 탭에서 사용 가능한 도구가 없어 작업을 진행할 수 없습니다. 탭별 도구 노출 정책을 확인하세요."; return $"⚠ 현재 {tabLabel} 탭에서 사용 가능한 도구가 없어 작업을 진행할 수 없습니다. 탭별 도구 노출 정책을 확인하세요.";
} }
var workingSetMessage = codeWorkingSet?.BuildChatMessage();
var llmRequest = AgentLoopLlmRequestPreparationService.Prepare( var llmRequest = AgentLoopLlmRequestPreparationService.Prepare(
queryMessages, queryMessages,
totalToolCalls, totalToolCalls,
executionPolicy.ForceInitialToolCall, executionPolicy.ForceInitialToolCall,
executionPolicy.InjectPreCallToolReminder, executionPolicy.InjectPreCallToolReminder,
runState.NoToolCallLoopRetry); runState.NoToolCallLoopRetry,
workingSetMessage is null ? null : [workingSetMessage]);
var forceFirst = llmRequest.ForceInitialToolCall; var forceFirst = llmRequest.ForceInitialToolCall;
var sendMessages = llmRequest.SendMessages; var sendMessages = llmRequest.SendMessages;
@@ -575,6 +587,12 @@ public partial class AgentLoopService
llmCallSw.Restart(); llmCallSw.Restart();
var (_, currentModel) = _llm.GetCurrentModelInfo(); var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration); WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
WorkflowLogService.LogTransition(
_conversationId,
_currentRunId,
iteration,
"query_context",
BuildQueryContextDetail(queryView, llmRequest, codeWorkingSet));
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration, WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
currentModel, sendMessages.Count, activeTools.Count, forceFirst); currentModel, sendMessages.Count, activeTools.Count, forceFirst);
var streamedTextPreview = new StringBuilder(); var streamedTextPreview = new StringBuilder();
@@ -1578,8 +1596,10 @@ public partial class AgentLoopService
lastToolResultAtUtc = DateTime.UtcNow; lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName; lastToolResultToolName = effectiveCall.ToolName;
// P3: 누적 학습 — 도구 결과에서 학습 포인트 추출 // Keep Code runs focused on the current working set instead of generic session learnings.
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success); if (!isCodeTab)
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
codeWorkingSet?.RecordToolResult(effectiveCall.ToolName, effectiveCall.ToolInput, result);
if (!result.Success) if (!result.Success)
{ {

View File

@@ -15,18 +15,46 @@ public sealed class AgentQueryContextWindowResult
public int ReusedToolResultPreviewCount { get; init; } public int ReusedToolResultPreviewCount { get; init; }
public int TokensBeforeBudget { get; init; } public int TokensBeforeBudget { get; init; }
public int TokensAfterBudget { 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> /// <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> /// </summary>
public static class AgentQueryContextBuilder 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"; 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) if (sourceMessages is IList<ChatMessage> mutableSourceMessages)
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(mutableSourceMessages); AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(mutableSourceMessages);
@@ -45,6 +73,10 @@ public static class AgentQueryContextBuilder
ReusedToolResultPreviewCount = 0, ReusedToolResultPreviewCount = 0,
TokensBeforeBudget = 0, TokensBeforeBudget = 0,
TokensAfterBudget = 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); InjectPostCompactContextMessage(windowMessages);
var tokensBeforeBudget = TokenEstimator.EstimateMessages(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); var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
return new AgentQueryContextWindowResult return new AgentQueryContextWindowResult
@@ -86,6 +118,10 @@ public static class AgentQueryContextBuilder
ReusedToolResultPreviewCount = budgetResult.ReusedPreviewCount, ReusedToolResultPreviewCount = budgetResult.ReusedPreviewCount,
TokensBeforeBudget = tokensBeforeBudget, TokensBeforeBudget = tokensBeforeBudget,
TokensAfterBudget = tokensAfterBudget, 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 ?? ""; 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) || 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) private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
{ {
toolName = ""; toolName = "";
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) var content = message.Content ?? "";
|| string.IsNullOrWhiteSpace(message.Content) if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
|| !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
return false; return false;
try try
{ {
using var doc = System.Text.Json.JsonDocument.Parse(message.Content); using var doc = System.Text.Json.JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl)) if (!doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
{ return false;
toolName = toolNameEl.GetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName); toolName = toolNameEl.GetString() ?? "";
} return !string.IsNullOrWhiteSpace(toolName);
} }
catch catch
{ {
return false;
} }
return false;
} }
} }

View File

@@ -12,20 +12,61 @@ public sealed class AgentToolResultBudgetResult
} }
/// <summary> /// <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> /// </summary>
public static class AgentToolResultBudget public static class AgentToolResultBudget
{ {
public const int DefaultSoftCharLimit = 900; public const int DefaultSoftCharLimit = 900;
public const int DefaultAggregateBudgetChars = 7_500; 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( public static AgentToolResultBudgetResult Apply(
List<ChatMessage> messages, List<ChatMessage> messages,
int protectedRecentNonSystemMessages, AgentToolResultBudgetOptions? options = null,
int softCharLimit = DefaultSoftCharLimit,
int aggregateBudgetChars = DefaultAggregateBudgetChars,
IReadOnlyList<ChatMessage>? sourceMessages = null) IReadOnlyList<ChatMessage>? sourceMessages = null)
{ {
options ??= CreateDefaultOptions();
var result = new AgentToolResultBudgetResult(); var result = new AgentToolResultBudgetResult();
var previewSourceMessages = sourceMessages ?? messages; var previewSourceMessages = sourceMessages ?? messages;
if (ReferenceEquals(previewSourceMessages, messages)) if (ReferenceEquals(previewSourceMessages, messages))
@@ -47,10 +88,10 @@ public static class AgentToolResultBudget
.Select(x => x.index) .Select(x => x.index)
.ToList(); .ToList();
if (nonSystemIndexes.Count <= protectedRecentNonSystemMessages) if (nonSystemIndexes.Count <= options.ProtectedRecentNonSystemMessages)
return result; return result;
var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - protectedRecentNonSystemMessages)]; var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - options.ProtectedRecentNonSystemMessages)];
var spentChars = 0; var spentChars = 0;
for (var i = 0; i < protectedStart; i++) for (var i = 0; i < protectedStart; i++)
@@ -87,10 +128,12 @@ public static class AgentToolResultBudget
} }
spentChars += content.Length; 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; continue;
var truncated = TruncateToolResultJson(content, softCharLimit); var truncated = TruncateToolResultJson(content, effectiveSoftLimit);
if (string.Equals(truncated, content, StringComparison.Ordinal)) if (string.Equals(truncated, content, StringComparison.Ordinal))
continue; continue;
@@ -109,6 +152,22 @@ public static class AgentToolResultBudget
return result; 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) public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit)
{ {
try try
@@ -130,7 +189,7 @@ public static class AgentToolResultBudget
var head = content[..keepHead]; var head = content[..keepHead];
var tail = keepTail > 0 ? content[^keepTail..] : ""; var tail = keepTail > 0 ? content[^keepTail..] : "";
var compacted = head + var compacted = head +
$"\n...[tool_result 축약: {content.Length:N0}]...\n" + $"\n...[tool_result truncated: {content.Length:N0} chars]...\n" +
tail; tail;
return JsonSerializer.Serialize(new return JsonSerializer.Serialize(new
@@ -147,7 +206,7 @@ public static class AgentToolResultBudget
return json; return json;
var head = json[..Math.Min(softCharLimit, json.Length)]; 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) private static ChatMessage CloneMessage(ChatMessage source, string content)
{ {
return new ChatMessage 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 메시지 쌍 검증 ── // Validate historical tool-call chains before sending the request.
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데 var sanitization = SanitizeToolCallPairs(msgs);
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함. if (sanitization.TotalRepairedCount > 0)
// 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지. {
SanitizeToolCallPairs(msgs); LogService.Info(
$"[ToolUse] normalized historical tool trace (flattened_assistant={sanitization.FlattenedAssistantCount}, converted_orphans={sanitization.ConvertedOrphanToolCount})");
}
// OpenAI 도구 정의 // OpenAI 도구 정의
var toolDefs = tools.Select(t => var toolDefs = tools.Select(t =>
@@ -1964,15 +1966,20 @@ public partial class LlmService
// ─── 공통 헬퍼 ───────────────────────────────────────────────────── // ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary> private sealed record ToolCallPairSanitizationResult(
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면 int FlattenedAssistantCount,
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다. int ConvertedOrphanToolCount)
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
/// </summary>
private static void SanitizeToolCallPairs(List<object> msgs)
{ {
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ── public int TotalRepairedCount => FlattenedAssistantCount + ConvertedOrphanToolCount;
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용 }
/// <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>(); var pairedToolIndices = new HashSet<int>();
for (int i = 0; i < msgs.Count; i++) 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 ?? ""; var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? "";
msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" }; msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" };
pairedToolIndices.Remove(j); pairedToolIndices.Remove(j);
convertedOrphanToolCount++;
} }
flattenedAssistantCount++;
LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})"); LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})");
} }
} }
// ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ──
for (int i = 0; i < msgs.Count; i++) for (int i = 0; i < msgs.Count; i++)
{ {
if (pairedToolIndices.Contains(i)) continue; if (pairedToolIndices.Contains(i)) continue;
@@ -2047,8 +2055,11 @@ public partial class LlmService
var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? ""; var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? "";
msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" }; msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" };
convertedOrphanToolCount++;
LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})"); LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})");
} }
return new ToolCallPairSanitizationResult(flattenedAssistantCount, convertedOrphanToolCount);
} }
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary> /// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>

View File

@@ -22,7 +22,7 @@ public partial class ChatWindow
// ─── 프로젝트 문맥 파일 (AGENTS.md) ────────────────────────────────── // ─── 프로젝트 문맥 파일 (AGENTS.md) ──────────────────────────────────
/// <summary> /// <summary>
/// P4: 워크스페이스 컨텍스트 자동 생성 파일(.ax-context.md)을 읽어 시스템 프롬프트에 주입합니다. /// Loads the workspace context file and kicks off bootstrap generation on first use.
/// </summary> /// </summary>
private static string LoadWorkspaceContext(string? workFolder) private static string LoadWorkspaceContext(string? workFolder)
{ {
@@ -32,13 +32,32 @@ public partial class ChatWindow
if (!(app?.SettingsService?.Settings.Llm.EnableAutoWorkspaceContext ?? true)) if (!(app?.SettingsService?.Settings.Llm.EnableAutoWorkspaceContext ?? true))
return ""; return "";
var ensureTask = Services.Agent.WorkspaceContextGenerator.EnsureContextAsync(workFolder);
var content = Services.Agent.WorkspaceContextGenerator.LoadContext(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.
}
}
// 비동기 자동 생성 트리거 (파일이 아직 없을 때) if (!string.IsNullOrEmpty(content))
_ = Task.Run(() => Services.Agent.WorkspaceContextGenerator.EnsureContextAsync(workFolder)); 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> /// <summary>