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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs
Normal file
19
src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
479
src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs
Normal file
479
src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user