코드 탭 컨텍스트 영속화와 Auto budget 복구 적용

- AxAgentExecutionEngine에서 시스템 프롬프트 중복을 제거하고 structured tool_use/tool_result 전사본을 conversation.Messages로 동기화해 다음 턴과 저장 이력에서도 코드 작업 컨텍스트가 유지되도록 수정
- AgentQueryContextBuilder와 ContextCondenser에 post-compact tool snippet 복원, recent window 확대, tool result 보존 강화 로직을 추가해 장기 코드 실행 중 빌드/파일 근거 손실을 줄임
- MaxContextTokens=0 Auto 모드를 AppSettings, SettingsService 마이그레이션, 설정 UI, 오버레이 UI, 컨텍스트 사용량 표시, LLM 요청 본문에 연결하고 Auto 모드에서는 provider output cap 강제 주입을 제거
- 관련 회귀 테스트와 문서 README/DEVELOPMENT/CODE_CONTEXT_RELIABILITY_PLAN을 갱신하고 깨진 진단 문자열 기대값을 영어 기준으로 정리

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_followup\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests|AgentLoopDiagnosticsFormatterTests" -p:OutputPath=bin\\verify_context_reliability_followup_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_context_reliability_followup_tests2\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests2\\
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_final\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_final\\
This commit is contained in:
2026-04-16 07:45:36 +09:00
parent c6e5abfa50
commit e07b6dbed0
23 changed files with 851 additions and 268 deletions

102
README.md
View File

@@ -1,63 +1,15 @@
# AX Commander
- Update: 2026-04-16 06:41 (KST)
- Continued the Code-tab structural refactor by extracting the dispatch/stream stage into [AgentLoopLlmDispatchStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs>).
- The new dispatch stage now owns:
- workflow `query_context` / `llm_request` logging
- stream preview and tool-ready thinking updates
- read-only tool prefetch handoff
- the recovery-capable `SendWithToolsWithRecoveryAsync(...)` dispatch itself
- [AgentLoopService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs>) now treats LLM dispatch as a staged service call instead of building the stream callback inline, which keeps the loop closer to the staged `claw-code` shape.
- [StreamingToolExecutionCoordinator.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs>) was also rewritten with English-only status strings so the active stream/wait path no longer carries mojibake text, and [AgentLoopPreLlmStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs>) now returns clean English missing-tool failures.
- Added [AgentLoopLlmDispatchStageServiceTests.cs](</E:/AX Copilot - Codex/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs>) to lock:
- staged dispatch argument handoff
- run-state retry reset after a successful dispatch
- streaming preview / tool-ready / retry-reset event emission
- read-only prefetch delegation through the coordinator
- Validation:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_llm_dispatch_stage_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_escalated2\\` warnings 0 / errors 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopLlmDispatchStageServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_llm_dispatch_stage_tests_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_tests_escalated2\\` passed 43
- Update: 2026-04-16 02:13 (KST)
- Continued the Code-tab structural refactor by extracting the pre-LLM stage into [AgentLoopPreLlmStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs>).
- The new stage service now owns:
- iteration thinking-summary selection
- Gemini free-tier delay planning
- user-prompt submit hook planning and payload generation
- missing-tool guard construction
- final request assembly handoff through the staged query/history pipeline
- [AgentLoopService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs>) now reads more like an orchestrator: history/query assembly, pre-LLM stage planning, LLM dispatch, tool execution, and recovery.
- Added [AgentLoopPreLlmStageServiceTests.cs](</E:/AX Copilot - Codex/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs>) to lock:
- free-tier delay planning
- prompt-submit hook dedup by fingerprint
- runtime-policy missing-tool failure shaping
- working-set-backed request assembly
- Validation:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_pre_llm_stage_structure\\ -p:IntermediateOutputPath=obj\\verify_pre_llm_stage_structure\\` warnings 0 / errors 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|SessionLearningCollectorTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_pre_llm_stage_structure_tests\\ -p:IntermediateOutputPath=obj\\verify_pre_llm_stage_structure_tests\\` passed 60
- Update: 2026-04-16 02:05 (KST)
- Continued the Code-tab context reliability work with a structural refactor that moves query/history assembly closer to the staged shape used by `claw-code`.
- Added `src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs` so `AgentLoopService` no longer directly owns all of the following responsibilities in one place:
- refreshing session learnings for non-Code tabs
- projecting queued commands into the message list
- preparing the query window
- appending Code working-set supplemental context before request dispatch
- `src/AxCopilot/Services/Agent/AgentLoopService.cs` now delegates staged assembly to `AgentLoopQueryAssemblyService.PrepareHistory(...)` and `PrepareRequest(...)`, leaving the loop focused more narrowly on orchestration, hooks, tool execution, and retry flow.
- Added `src/AxCopilot.Tests/Services/AgentLoopQueryAssemblyServiceTests.cs` to lock:
- non-Code session-learning injection
- Code-tab skip behavior for session learnings
- Code working-set supplemental message injection before the tool reminder
- Rewrote `src/AxCopilot.Tests/Services/SessionLearningCollectorTests.cs` in English-only comments and assertions so the test file itself also follows the new encoding/comment rule.
- Validation:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_query_assembly_structure\\ -p:IntermediateOutputPath=obj\\verify_query_assembly_structure\\` warnings 0 / errors 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|SessionLearningCollectorTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_query_assembly_structure_tests\\ -p:IntermediateOutputPath=obj\\verify_query_assembly_structure_tests\\` passed 56
- Update: 2026-04-16 01:28 (KST)
- Added an encoding rule to `AGENTS.md`: comments inside code files must be written in English only, and any broken mojibake strings found in touched code files should be rewritten in English before commit.
- Reviewed the recent Code tab runs again. On `2026-04-16`, the request message count still grew during long runs (`messages=7 -> 125`, and another run `118 -> 139`), so the main issue is not raw context length. The problem is context fidelity: build/file evidence is being compacted too aggressively while tool-trace repair noise is repeatedly inserted.
- Captured the remediation roadmap in `docs/CODE_CONTEXT_RELIABILITY_PLAN.md`. The plan covers workspace-context bootstrap repair, a dedicated Code working-set memory layer, task-aware pruning, tool-trace invariant hardening, and prompt/encoding cleanup.
- The plan references `claw-code/.../src/query.ts`, `history.ts`, and `memory-context.md`, and it also incorporates external research from Anthropic Claude Code memory docs, the OpenAI practical guide to building agents, and the SWE-Pruner paper.
- Update: 2026-04-16 07:40 (KST)
- Code tab context reliability was hardened to keep durable tool transcript history, auto-resolve the context budget when `MaxContextTokens = 0`, restore recent compacted tool snippets after boundary compaction, and deduplicate repeated system prompts before each loop request.
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs` now preserves full `ChatMessage` metadata during turn cloning and syncs structured tool-use and tool-result transcript messages back into `conversation.Messages`, so the next turn and persisted history keep the same repair context.
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` and `src/AxCopilot/Services/Agent/ContextCondenser.cs` now protect a larger recent window, keep more tool-result detail, and inject compacted tool snippets into the post-compact context block instead of only count-based summaries.
- `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/Services/SettingsService.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`, and `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs` now treat `MaxContextTokens = 0` as Auto and expose that choice consistently in settings and live UI.
- `src/AxCopilot/Services/LlmService.cs` and `src/AxCopilot/Services/LlmService.ToolUse.cs` now omit provider output token caps when the shared context budget is set to Auto, which avoids forcing the old 32K cap into OpenAI-compatible, Gemini, IBM, and tool-calling request payloads.
- Verification:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_followup\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup\\` warning 0 / error 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests|AgentLoopDiagnosticsFormatterTests" -p:OutputPath=bin\\verify_context_reliability_followup_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests\\` passed 50
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_context_reliability_followup_tests2\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests2\\` passed 38
- 업데이트: 2026-04-16 00:57 (KST)
- AX Agent 앱 생성 메시지의 가로 폭과 정렬을 다시 다듬었습니다. `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs``GetAgentEventMaxWidth()`를 추가해 앱이 그리는 진행/도구/완료 카드 폭만 별도로 줄였고, `src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs`, `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2AgentEventPresentation.cs`는 해당 폭을 사용하면서 중앙 정렬 대신 좌측 기준으로 붙도록 맞췄습니다.
@@ -2433,37 +2385,3 @@ MIT License
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183
업데이트: 2026-04-16 01:41 (KST)
- Code 탭 컨텍스트 신뢰성 보강 1차 구현을 반영했습니다. `src/AxCopilot/Services/Agent/CodeTaskWorkingSetService.cs`를 추가해 최근 생성 디렉터리, 최근 읽기/쓰기 파일, 최신 build/test 진단, 다음 복구 초점을 하나의 working set 블록으로 유지하고, 각 반복의 실제 LLM 요청에 보조 system context로 주입합니다.
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`, `AgentToolResultBudget.cs`, `AgentLoopIterationPreparationService.cs`, `AgentLoopLlmRequestPreparationService.cs`를 함께 조정해 Code 탭에서는 더 넓은 protected recent window와 더 큰 tool_result budget을 사용하도록 바꿨습니다. `build_run`, `test_loop`, `file_read`, `multi_read`, `lsp_code_intel`, `git_tool` 같은 고가치 evidence는 기본 탭보다 덜 잘리도록 보호합니다.
- `src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs``.ax-context.md`가 아직 없더라도 첫 요청 시점에 workspace context 생성을 바로 시작하고, 생성이 완료되기 전에는 언어 워크플로우 힌트를 bootstrap context로 넣도록 바꿨습니다. 덕분에 완전한 빈 작업 폴더에서도 첫 루프부터 최소한의 프로젝트 힌트가 LLM에 전달됩니다.
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 Code 탭에서 generic `session_learnings` 주입을 줄이고, 대신 working set과 query-context diagnostics를 반복마다 `WorkflowLogService.LogTransition(..., \"query_context\", ...)`로 남깁니다. 이제 로그에서 query-view 범위, protected recent 값, supplemental context 수, estimated send token, working-set 요약을 함께 확인할 수 있습니다.
- `src/AxCopilot/Services/LlmService.ToolUse.cs`는 historical tool-call sanitization 결과를 assistant flatten / orphan conversion 건수로 요약 로그에 남깁니다. 사후 보정은 유지하되, 보정 빈도를 추적할 수 있게 만들어 이후 invariant hardening 후속 작업의 기준선을 확보했습니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\` 통과 150
- 업데이트: 2026-04-16 01:57 (KST)
- Code 탭 컨텍스트 신뢰성 보강 2차 작업을 반영했습니다.
- `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs`
- 전송 직전 `tool_use/tool_result` 이력을 다시 검사하는 `NormalizeHistoricalToolTrace(...)`를 추가했습니다.
- 결과가 없는 structured assistant tool-call은 plain assistant transcript로 평탄화하고, 대응 assistant가 없는 `tool_result`는 plain user transcript로 바꿔 request payload 자체가 더 일관된 상태로 나가도록 조정했습니다.
- `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs`
- query view를 그대로 참조하지 않고 deep clone한 뒤 tool-trace normalization을 적용하도록 바꿨습니다.
- supplemental message도 clone해서 원본 대화 이력을 직접 변형하지 않도록 했습니다.
- normalization 결과(`flattened structured assistant`, `converted orphan tool_result`)를 preparation result에 함께 남깁니다.
- `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs`
- `query_context` workflow log에 `tool_trace_repair=assistants:X/orphan_results:Y`를 추가해, 전송 직전 어떤 보정이 들어갔는지 실행 단위로 추적 가능하게 했습니다.
- `src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs`
- active Code 경로에서 보이던 깨진 컨텍스트 압축 완료 문구를 영어 기준으로 정리했습니다.
- `src/AxCopilot/Services/Agent/SessionLearningCollector.cs`
- 주석과 주입 메시지, 학습 추출 요약 문자열을 영어 기준으로 전면 정리해 인코딩 손상 시에도 깨진 문자열이 다시 노출되지 않게 했습니다.
- 테스트:
- `src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs`
- missing tool-result가 있는 structured assistant flatten
- orphan `tool_result` plain transcript 변환
- valid structured tool pair 보존
- `src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs`
- request preparation 단계가 broken tool trace를 normalize하면서도 원본 query messages는 보존하는지 검증
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tool_trace_hardening\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentMessageInvariantHelperTests|AgentLoopLlmRequestPreparationServiceTests|AgentQueryContextBuilderTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_tool_trace_hardening_tests\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening_tests\\` 통과 34

View File

@@ -1,5 +1,32 @@
# Code Context Reliability Plan
Update: 2026-04-16 07:40 (KST)
- Closed the main gaps that were still open versus the comparison checklist:
- durable structured tool transcript persistence back into `conversation.Messages`
- `MaxContextTokens = 0` Auto mode with model-aware context budget resolution
- snippet-based post-compact tool trace restoration
- repeated system prompt deduplication before each prepared turn
- The active implementation now spans:
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs`
- `src/AxCopilot/Services/Agent/ContextCondenser.cs`
- `src/AxCopilot/Models/AppSettings.cs`
- `src/AxCopilot/Services/SettingsService.cs`
- `src/AxCopilot/ViewModels/SettingsViewModel.cs`
- `src/AxCopilot/Views/SettingsWindow.xaml`
- `src/AxCopilot/Views/SettingsWindow.xaml.cs`
- `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`
- `src/AxCopilot/Views/ChatWindow.xaml`
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`
- `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs`
- `src/AxCopilot/Services/LlmService.cs`
- `src/AxCopilot/Services/LlmService.ToolUse.cs`
- Remaining follow-up is now narrower:
- consider splitting context budget and output budget into separate user-facing controls if future provider tuning needs it
- continue cleaning legacy mojibake strings in low-traffic diagnostic paths as they are touched
Update: 2026-04-16 06:41 (KST)
- Added `src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs` so the LLM dispatch path is now split into:

View File

@@ -1881,3 +1881,14 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- 테스트: `src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs`
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_llm_dispatch_stage_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_escalated2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopLlmDispatchStageServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_llm_dispatch_stage_tests_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_tests_escalated2\\` 통과 43
업데이트: 2026-04-16 07:40 (KST)
- Code tab context reliability updates:
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs` now deduplicates repeated system prompts, preserves full `ChatMessage` metadata during cloned turn preparation, and syncs structured tool transcript messages back into `conversation.Messages` so later turns keep durable tool history.
- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` now restores recent compacted tool snippets into the post-compact context block, while `src/AxCopilot/Services/Agent/ContextCondenser.cs` increases the protected recent window and keeps more tool-result detail before truncation.
- `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/Services/SettingsService.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`, `src/AxCopilot/Views/SettingsWindow.xaml.cs`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`, and `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs` now support `MaxContextTokens = 0` as an Auto, model-aware context budget.
- `src/AxCopilot/Services/LlmService.cs` and `src/AxCopilot/Services/LlmService.ToolUse.cs` now omit output token caps for Auto budget mode instead of forcing the legacy 32K limit into provider payloads.
- `src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs`, `src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs`, `src/AxCopilot.Tests/Services/ContextCondenserTests.cs`, `src/AxCopilot.Tests/Services/SettingsServiceTests.cs`, and `src/AxCopilot.Tests/Services/AgentLoopDiagnosticsFormatterTests.cs` were updated with regression coverage for transcript sync, post-compact snippet recovery, Auto context budget migration, and prompt dedupe.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_followup\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests|AgentLoopDiagnosticsFormatterTests" -p:OutputPath=bin\\verify_context_reliability_followup_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests\\` 통과 50
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_context_reliability_followup_tests2\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests2\\` 통과 38

View File

@@ -19,7 +19,7 @@ public class AgentLoopDiagnosticsFormatterTests
var message = AgentLoopDiagnosticsFormatter.BuildCompactionCompleteMessage(result);
message.Should().Be("컨텍스트 압축 완료 — tool-result -> session-memory · 10.3K tokens 절감");
message.Should().Be("Context compaction complete: tool-result -> session-memory, saved 10.3K tokens");
}
[Fact]

View File

@@ -96,4 +96,43 @@ public class AgentQueryContextBuilderTests
result.ProtectedRecentNonSystemMessages.Should().BeGreaterThan(8);
result.ToolResultAggregateBudgetChars.Should().BeGreaterThan(AgentToolResultBudget.DefaultAggregateBudgetChars);
}
[Fact]
public void Build_ShouldRestoreRecentCompactedToolSnippetsIntoPostCompactContextMessage()
{
var sourceMessages = new List<ChatMessage>
{
new()
{
MsgId = "tool-call-1",
Role = "assistant",
Content = """{"_tool_use_blocks":[{"type":"tool_use","id":"call-restore","name":"file_read","input":{"path":"MainWindow.xaml"}}]}"""
},
new()
{
MsgId = "tool-result-1",
Role = "user",
Content = """{"type":"tool_result","tool_use_id":"call-restore","tool_name":"file_read","content":"<Window Title=\"Preview\" />"}"""
},
new()
{
MsgId = "boundary-1",
Role = "system",
MetaKind = "microcompact_boundary",
Content = "[previous conversation summary]"
},
new()
{
MsgId = "tail-1",
Role = "assistant",
Content = "recent tail"
}
};
var result = AgentQueryContextBuilder.Build(sourceMessages);
var postCompactMessage = result.Messages.Single(message => message.MetaKind == "post_compact_context");
postCompactMessage.Content.Should().Contain("recent compacted tool trace:");
postCompactMessage.Content.Should().Contain("file_read result:");
}
}

View File

@@ -8,6 +8,99 @@ namespace AxCopilot.Tests.Services;
public class AxAgentExecutionEngineTests
{
[Fact]
public void PrepareTurn_ShouldAvoidDuplicatingSystemPromptsAlreadyInConversation()
{
var engine = new AxAgentExecutionEngine();
var conversation = new ChatConversation
{
Messages =
[
new ChatMessage { MsgId = "sys-1", Role = "system", Content = "shared prompt" },
new ChatMessage { MsgId = "user-1", Role = "user", Content = "hello" }
]
};
var prepared = engine.PrepareTurn(conversation, ["shared prompt", "slash prompt"]);
prepared.Messages.Count(message => message.Role == "system").Should().Be(2);
prepared.Messages[0].Content.Should().Be("slash prompt");
prepared.Messages.Count(message => message.Content == "shared prompt").Should().Be(1);
}
[Fact]
public void SyncAgentLoopMessages_ShouldAppendStructuredTranscriptMessages()
{
var engine = new AxAgentExecutionEngine();
var conversation = new ChatConversation
{
Messages =
[
new ChatMessage { MsgId = "user-1", Role = "user", Content = "start task" }
]
};
var prepared = engine.PrepareTurn(conversation, ["system prompt"]);
var loopMessages = prepared.Messages
.Select(CloneMessage)
.ToList();
loopMessages.Add(new ChatMessage
{
MsgId = "assistant-tool-1",
Role = "assistant",
Content = """{"_tool_use_blocks":[{"type":"tool_use","id":"call-1","name":"file_read","input":{"path":"App.xaml"}}]}"""
});
loopMessages.Add(new ChatMessage
{
MsgId = "tool-result-1",
Role = "user",
Content = """{"type":"tool_result","tool_use_id":"call-1","tool_name":"file_read","content":"<Window />"}"""
});
loopMessages.Add(new ChatMessage
{
MsgId = "compact-1",
Role = "system",
MetaKind = "microcompact_boundary",
Content = "[previous conversation summary]"
});
var appendedCount = engine.SyncAgentLoopMessages(conversation, prepared.Messages, loopMessages);
appendedCount.Should().Be(3);
conversation.Messages.Should().Contain(message => message.MsgId == "assistant-tool-1");
conversation.Messages.Should().Contain(message => message.MsgId == "tool-result-1");
conversation.Messages.Should().Contain(message => message.MsgId == "compact-1");
}
[Fact]
public void SyncAgentLoopMessages_ShouldNormalizeDanglingStructuredToolMessagesBeforePersisting()
{
var engine = new AxAgentExecutionEngine();
var conversation = new ChatConversation
{
Messages =
[
new ChatMessage { MsgId = "user-1", Role = "user", Content = "start task" }
]
};
var prepared = engine.PrepareTurn(conversation, ["system prompt"]);
var loopMessages = prepared.Messages
.Select(CloneMessage)
.ToList();
loopMessages.Add(new ChatMessage
{
MsgId = "assistant-tool-dangling",
Role = "assistant",
Content = """{"_tool_use_blocks":[{"type":"tool_use","id":"call-missing","name":"file_read","input":{"path":"MainWindow.xaml"}}]}"""
});
var appendedCount = engine.SyncAgentLoopMessages(conversation, prepared.Messages, loopMessages);
appendedCount.Should().Be(1);
var persisted = conversation.Messages.Single(message => message.MsgId == "assistant-tool-dangling");
persisted.Content.Should().NotStartWith("{\"_tool_use_blocks\"");
persisted.Content.Should().Contain("file_read");
}
[Fact]
public void AppendExecutionEvent_PreservesVisibleConversation_WhenSameTabRunContinuesInBackground()
{
@@ -56,4 +149,29 @@ public class AxAgentExecutionEngineTests
runningConversation.ExecutionEvents.Should().ContainSingle();
runningConversation.ExecutionEvents[0].RunId.Should().Be("run-background");
}
private static ChatMessage CloneMessage(ChatMessage source)
{
return new ChatMessage
{
MsgId = source.MsgId,
Role = source.Role,
Content = source.Content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
ResponseElapsedMs = source.ResponseElapsedMs,
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
QueryPreviewContent = source.QueryPreviewContent,
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,
MimeType = image.MimeType,
FileName = image.FileName,
}).ToList(),
};
}
}

View File

@@ -61,6 +61,14 @@ public class ContextCondenserTests
}).Should().BeTrue();
}
[Fact]
public void ResolveContextBudgetTokens_WhenConfiguredLimitIsAuto_ShouldUseModelDefault()
{
var resolved = ContextCondenser.ResolveContextBudgetTokens("gemini", "gemini-2.5-flash", 0);
resolved.Should().Be(900_000);
}
private static List<ChatMessage> BuildLargeConversation()
{
var largeOutput = new string('A', 30_000);
@@ -70,7 +78,7 @@ public class ContextCondenserTests
[
new ChatMessage { Role = "system", Content = "system prompt" },
new ChatMessage { Role = "user", Content = "첫 질문" },
new ChatMessage { Role = "assistant", Content = toolJson }, // 오래된 구간에 배치
new ChatMessage { Role = "assistant", Content = toolJson }, // Keep the large payload in the old segment.
new ChatMessage { Role = "assistant", Content = "첫 답변" },
new ChatMessage { Role = "user", Content = "둘째 질문" },
new ChatMessage { Role = "assistant", Content = "둘째 답변" },

View File

@@ -74,9 +74,9 @@ public class SettingsServiceTests
}
[Fact]
public void LlmSettings_DefaultMaxContextTokens_IsThirtyTwoK()
public void LlmSettings_DefaultMaxContextTokens_IsAuto()
{
new LlmSettings().MaxContextTokens.Should().Be(32_768);
new LlmSettings().MaxContextTokens.Should().Be(0);
}
// ─── LauncherSettings 테마 ───────────────────────────────────────────────

View File

@@ -831,9 +831,9 @@ public class LlmSettings
[JsonPropertyName("streaming")]
public bool Streaming { get; set; } = true;
/// <summary>최대 컨텍스트 토큰 수. 기본값 32768.</summary>
/// <summary>Maximum context token budget. 0 means auto (model-aware).</summary>
[JsonPropertyName("maxContextTokens")]
public int MaxContextTokens { get; set; } = 32_768;
public int MaxContextTokens { get; set; }
/// <summary>대화 보관 기간(일). 7 | 30 | 90 | 0(무제한). 기본값 30.</summary>
[JsonPropertyName("retentionDays")]

View File

@@ -49,6 +49,8 @@ public static class AgentQueryContextBuilder
}
private const string PostCompactContextMetaKind = "post_compact_context";
private const int PostCompactRecentToolSnippetCount = 6;
private const int PostCompactSnippetMaxChars = 320;
public static AgentQueryContextWindowResult Build(
IReadOnlyList<ChatMessage> sourceMessages,
@@ -99,7 +101,7 @@ public static class AgentQueryContextBuilder
}
if (boundaryApplied)
InjectPostCompactContextMessage(windowMessages);
InjectPostCompactContextMessage(windowMessages, sourceMessages, adjustedStartIndex);
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, options.ToolResultBudget, sourceMessages: sourceMessages);
@@ -186,7 +188,10 @@ public static class AgentQueryContextBuilder
};
}
private static void InjectPostCompactContextMessage(List<ChatMessage> messages)
private static void InjectPostCompactContextMessage(
List<ChatMessage> messages,
IReadOnlyList<ChatMessage> sourceMessages,
int windowStartIndex)
{
if (messages.Count == 0)
return;
@@ -219,13 +224,15 @@ public static class AgentQueryContextBuilder
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(4)
.ToList();
var compactedToolSnippets = CollectDroppedToolSnippets(sourceMessages, windowStartIndex);
if (attachedFiles.Count == 0
&& imageCount == 0
&& compactSummaryCount == 0
&& structuredToolHistoryCount == 0
&& branchContextCount == 0
&& recentToolNames.Count == 0)
&& recentToolNames.Count == 0
&& compactedToolSnippets.Count == 0)
return;
var lines = new List<string> { "[post-compact context]" };
@@ -237,6 +244,11 @@ public static class AgentQueryContextBuilder
lines.Add($"restored tool history blocks: {structuredToolHistoryCount}");
if (recentToolNames.Count > 0)
lines.Add("restored recent tools: " + string.Join(", ", recentToolNames));
if (compactedToolSnippets.Count > 0)
{
lines.Add("recent compacted tool trace:");
lines.AddRange(compactedToolSnippets.Select(snippet => "- " + snippet));
}
if (attachedFiles.Count > 0)
lines.Add("restored file refs: " + string.Join(", ", attachedFiles));
if (imageCount > 0)
@@ -256,6 +268,131 @@ public static class AgentQueryContextBuilder
});
}
private static List<string> CollectDroppedToolSnippets(IReadOnlyList<ChatMessage> sourceMessages, int windowStartIndex)
{
if (windowStartIndex <= 0)
return [];
var snippets = new List<string>();
var seenSnippets = new HashSet<string>(StringComparer.Ordinal);
for (var i = windowStartIndex - 1; i >= 0 && snippets.Count < PostCompactRecentToolSnippetCount; i--)
{
var message = sourceMessages[i];
string? snippet = null;
if (AgentMessageInvariantHelper.TryGetToolResultId(message, out _)
&& TryBuildToolResultSnippet(message, out var toolResultSnippet))
{
snippet = toolResultSnippet;
}
else if (string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase)
&& (message.Content ?? string.Empty).StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)
&& TryBuildToolUseSnippet(message, out var toolUseSnippet))
{
snippet = toolUseSnippet;
}
if (string.IsNullOrWhiteSpace(snippet) || !seenSnippets.Add(snippet))
continue;
snippets.Add(snippet);
}
return snippets;
}
private static bool TryBuildToolResultSnippet(ChatMessage message, out string snippet)
{
snippet = "";
var content = string.IsNullOrWhiteSpace(message.QueryPreviewContent) ? message.Content : message.QueryPreviewContent;
if (string.IsNullOrWhiteSpace(content))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(content);
var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var toolNameEl)
? toolNameEl.GetString() ?? "tool"
: "tool";
var toolContent = root.TryGetProperty("content", out var contentEl)
? contentEl.GetString() ?? ""
: "";
if (string.IsNullOrWhiteSpace(toolContent))
toolContent = "tool result preserved";
snippet = $"{toolName} result: {TruncateSnippet(toolContent)}";
return true;
}
catch
{
return false;
}
}
private static bool TryBuildToolUseSnippet(ChatMessage message, out string snippet)
{
snippet = "";
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl)
|| blocksEl.ValueKind != System.Text.Json.JsonValueKind.Array)
{
return false;
}
var toolNames = new List<string>();
var textParts = new List<string>();
foreach (var block in blocksEl.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl))
continue;
var type = typeEl.GetString() ?? "";
if (string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase)
&& block.TryGetProperty("name", out var nameEl))
{
var toolName = nameEl.GetString();
if (!string.IsNullOrWhiteSpace(toolName))
toolNames.Add(toolName);
}
else if (string.Equals(type, "text", StringComparison.OrdinalIgnoreCase)
&& block.TryGetProperty("text", out var textEl))
{
var text = textEl.GetString();
if (!string.IsNullOrWhiteSpace(text))
textParts.Add(text);
}
}
if (toolNames.Count == 0)
return false;
var assistantText = string.Join(" ", textParts).Trim();
snippet = string.IsNullOrWhiteSpace(assistantText)
? $"assistant tool call: {string.Join(", ", toolNames)}"
: $"assistant tool call: {string.Join(", ", toolNames)} — {TruncateSnippet(assistantText)}";
return true;
}
catch
{
return false;
}
}
private static string TruncateSnippet(string text)
{
var normalized = text.Replace('\r', ' ').Replace('\n', ' ').Trim();
if (normalized.Length <= PostCompactSnippetMaxChars)
return normalized;
return normalized[..PostCompactSnippetMaxChars] + "...";
}
private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
{
toolName = "";

View File

@@ -4,9 +4,9 @@ using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// AX Agent execution-prep engine.
/// UI 레이어가 메시지 조립과 최종 어시스턴트 커밋 로직을 직접 소유하지 않도록
/// 입력 준비(preparation)와 세션 실행(execution)을 분리하는 패턴입니다.
/// AX Agent execution preparation engine.
/// Keeps prompt assembly, loop transcript persistence, and final assistant
/// commits out of the main window orchestration flow.
/// </summary>
public sealed class AxAgentExecutionEngine
{
@@ -27,12 +27,21 @@ public sealed class AxAgentExecutionEngine
string? taskSystem = null)
{
var prompts = new List<string>();
if (!string.IsNullOrWhiteSpace(conversationSystem))
prompts.Add(conversationSystem.Trim());
if (!string.IsNullOrWhiteSpace(slashSystem))
prompts.Add(slashSystem.Trim());
if (!string.IsNullOrWhiteSpace(taskSystem))
prompts.Add(taskSystem.Trim());
var seenPrompts = new HashSet<string>(StringComparer.Ordinal);
void AddPrompt(string? prompt)
{
var normalized = prompt?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
return;
if (seenPrompts.Add(normalized))
prompts.Add(normalized);
}
AddPrompt(conversationSystem);
AddPrompt(slashSystem);
AddPrompt(taskSystem);
return prompts;
}
@@ -118,12 +127,82 @@ public sealed class AxAgentExecutionEngine
.Select(prompt => prompt!.Trim())
.ToList();
var existingSystemPrompts = new HashSet<string>(
outbound
.Where(message => string.Equals(message.Role, "system", StringComparison.OrdinalIgnoreCase))
.Select(message => (message.Content ?? string.Empty).Trim())
.Where(content => !string.IsNullOrWhiteSpace(content)),
StringComparer.Ordinal);
for (var i = promptList.Count - 1; i >= 0; i--)
{
if (!existingSystemPrompts.Add(promptList[i]))
continue;
outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] });
}
return new PreparedTurn(outbound);
}
public int SyncAgentLoopMessages(
ChatConversation conversation,
IReadOnlyList<ChatMessage> preparedMessages,
IReadOnlyList<ChatMessage>? agentLoopMessages)
{
if (agentLoopMessages == null || agentLoopMessages.Count == 0)
return 0;
var baselineIds = new HashSet<string>(
preparedMessages
.Select(message => message.MsgId)
.Where(id => !string.IsNullOrWhiteSpace(id)),
StringComparer.Ordinal);
var existingConversationIds = new HashSet<string>(
conversation.Messages
.Select(message => message.MsgId)
.Where(id => !string.IsNullOrWhiteSpace(id)),
StringComparer.Ordinal);
var candidateIds = new HashSet<string>(
agentLoopMessages
.Where(ShouldPersistLoopTranscriptMessage)
.Select(message => message.MsgId)
.Where(id => !string.IsNullOrWhiteSpace(id)),
StringComparer.Ordinal);
if (candidateIds.Count == 0)
return 0;
var normalizedMessages = agentLoopMessages
.Select(CloneMessage)
.ToList();
AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(normalizedMessages);
var appendedCount = 0;
foreach (var message in normalizedMessages)
{
var messageId = message.MsgId;
if (string.IsNullOrWhiteSpace(messageId))
continue;
if (!candidateIds.Contains(messageId)
|| baselineIds.Contains(messageId)
|| existingConversationIds.Contains(messageId))
{
continue;
}
conversation.Messages.Add(CloneMessage(message));
existingConversationIds.Add(messageId);
appendedCount++;
}
if (appendedCount > 0)
conversation.UpdatedAt = DateTime.Now;
return appendedCount;
}
public ChatMessage CommitAssistantMessage(
ChatSessionStateService? session,
ChatConversation conversation,
@@ -147,8 +226,9 @@ public sealed class AxAgentExecutionEngine
if (session != null)
{
// session.CurrentConversation이 전달된 conversation과 다른 경우 (새 대화 시작 등),
// session을 통하지 않고 conversation에 직접 추가하여 새 대화가 오염되지 않도록 함.
// When the active session conversation is different from the target
// conversation, append directly to the target to avoid contaminating
// the newly selected conversation.
if (session.CurrentConversation == null ||
string.Equals(session.CurrentConversation.Id, conversation.Id, StringComparison.Ordinal))
{
@@ -191,12 +271,12 @@ public sealed class AxAgentExecutionEngine
{
if (cancelled)
{
var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!;
return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다.");
var content = string.IsNullOrWhiteSpace(currentContent) ? "(cancelled)" : currentContent!;
return new FinalizedContent(content, true, "The user cancelled the task.");
}
if (error != null)
return new FinalizedContent($"오류: {error.Message}", false, error.Message);
return new FinalizedContent($"Error: {error.Message}", false, error.Message);
return new FinalizedContent(currentContent ?? string.Empty, false, null);
}
@@ -211,7 +291,7 @@ public sealed class AxAgentExecutionEngine
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
?? "(execution finished, but the final summary was empty. Check the execution log.)";
return BuildFallbackCompletionMessage(conversation, runTab);
}
@@ -220,13 +300,13 @@ public sealed class AxAgentExecutionEngine
{
if (cancelled)
{
var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!;
return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다.");
var content = string.IsNullOrWhiteSpace(currentContent) ? "(cancelled)" : currentContent!;
return new FinalizedContent(content, true, "The user cancelled the task.");
}
if (error != null)
{
return new FinalizedContent($"⚠ 오류: {error.Message}", false, error.Message);
return new FinalizedContent($"Error: {error.Message}", false, error.Message);
}
return new FinalizedContent(currentContent ?? string.Empty, false, null);
@@ -286,51 +366,58 @@ public sealed class AxAgentExecutionEngine
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
?? "(execution finished, but the final summary was empty. Check the execution log.)";
return BuildFallbackCompletionMessage(conversation, runTab);
}
/// <summary>
/// LLM 응답이 비어있을 때 실행 이벤트에서 의미 있는 완료 메시지를 구성합니다.
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
/// Builds a fallback completion message from execution evidence when the
/// final assistant response is empty.
/// </summary>
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
=> TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(빈 응답)";
=> TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(empty response)";
private static string? TryBuildEvidenceBackedCompletionMessage(ChatConversation conversation, string runTab)
{
static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
static bool IsSignificantEventType(string type)
=> !string.Equals(type, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "SessionStart", StringComparison.OrdinalIgnoreCase);
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
static bool IsInternalStatusSummary(string? summary)
{
if (string.IsNullOrWhiteSpace(summary))
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("반복 ") && summary.Contains('/')
|| summary.Contains("[System:");
return summary.StartsWith("LLM request", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("unresolved tool loop", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("forced execution retry", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("read-first request", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("parallel execution", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("temporary LLM error", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("context window exceeded", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|| (summary.Contains("iteration ", StringComparison.OrdinalIgnoreCase) && summary.Contains('/'))
|| (summary.Contains("반복 ", StringComparison.Ordinal) && summary.Contains('/'))
|| summary.Contains("[System:", StringComparison.OrdinalIgnoreCase);
}
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
"Code" => "코드 작업이 완료되었습니다.",
"Cowork" => "Cowork task finished.",
"Code" => "Code task finished.",
_ => null,
};
// 파일 경로가 있는 이벤트를 최우선으로 — 산출물 파일을 명시적으로 표시
var artifactEvent = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type))
.OrderByDescending(evt => evt.Timestamp)
@@ -339,14 +426,13 @@ public sealed class AxAgentExecutionEngine
if (artifactEvent != null)
{
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
? $"생성된 파일: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
? $"Created file: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\nPath: {artifactEvent.FilePath}";
return completionLine != null
? $"{completionLine}\n\n{fileLine}"
: fileLine;
}
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외)
var latestSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
&& IsSignificantEventType(evt.Type)
@@ -362,16 +448,16 @@ public sealed class AxAgentExecutionEngine
: latestSummary;
}
return completionLine ?? "(빈 응답)";
return completionLine ?? "(empty response)";
}
private static string? TryBuildStrictExecutionCompletionMessage(ChatConversation conversation, string runTab)
{
static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
static bool IsSignificantEventType(string type)
=> !string.Equals(type, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(type, "SessionStart", StringComparison.OrdinalIgnoreCase);
static bool IsInternalStatusSummary(string? summary)
{
@@ -379,20 +465,26 @@ public sealed class AxAgentExecutionEngine
return false;
return summary.StartsWith("LLM", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("unresolved tool loop", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("forced execution retry", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("read-first request", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("parallel execution", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("temporary LLM error", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("context window exceeded", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("[System:", StringComparison.OrdinalIgnoreCase);
}
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
"Code" => "코드 작업이 완료되었습니다.",
"Cowork" => "Cowork task finished.",
"Code" => "Code task finished.",
_ => null,
};
@@ -403,8 +495,8 @@ public sealed class AxAgentExecutionEngine
if (artifactEvent != null)
{
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
? $"생성된 파일: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
? $"Created file: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\nPath: {artifactEvent.FilePath}";
return completionLine != null
? $"{completionLine}\n\n{fileLine}"
: fileLine;
@@ -427,15 +519,37 @@ public sealed class AxAgentExecutionEngine
return null;
}
private static bool ShouldPersistLoopTranscriptMessage(ChatMessage message)
{
if (string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
return (message.Content ?? string.Empty).StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal);
if (string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
return AgentMessageInvariantHelper.TryGetToolResultId(message, out _);
return string.Equals(message.Role, "system", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message.MetaKind, "post_compact_context", StringComparison.OrdinalIgnoreCase));
}
private static ChatMessage CloneMessage(ChatMessage source)
{
return new ChatMessage
{
MsgId = source.MsgId,
Role = source.Role,
Content = source.Content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
ResponseElapsedMs = source.ResponseElapsedMs,
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
QueryPreviewContent = source.QueryPreviewContent,
Images = source.Images?.Select(CloneImage).ToList(),
};
}

View File

@@ -34,12 +34,13 @@ public static class ContextCondenser
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
private const int MaxToolResultChars = 1500;
private const int MicrocompactSingleKeepChars = 480;
private const int MicrocompactGroupMinCount = 2;
private const int MicrocompactGroupMinCount = 4;
private const int SnipKeepHeadChars = 220;
private const int SnipKeepTailChars = 140;
private const int ToolResultTruncationKeepCount = 6;
/// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6;
private const int RecentKeepCount = 12;
private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000;
private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다.";
@@ -78,9 +79,17 @@ public static class ContextCondenser
};
}
private static int GetEffectiveContextWindowSize(string service, string model, int configuredLimit)
public static int ResolveContextBudgetTokens(string service, string model, int configuredLimit)
{
var contextWindow = configuredLimit > 0 ? configuredLimit : GetModelInputLimit(service, model);
if (configuredLimit > 0)
return Math.Clamp(configuredLimit, 1_024, 1_000_000);
return GetModelInputLimit(service, model);
}
public static int GetEffectiveContextWindowSize(string service, string model, int configuredLimit)
{
var contextWindow = ResolveContextBudgetTokens(service, model, configuredLimit);
var reservedForSummary = Math.Min(SummaryReserveTokens, Math.Max(4_000, contextWindow / 8));
return Math.Max(8_000, contextWindow - reservedForSummary);
}
@@ -337,10 +346,10 @@ public static class ContextCondenser
/// </summary>
private static bool TruncateToolResults(List<ChatMessage> messages)
{
var budgetResult = AgentToolResultBudget.Apply(messages, RecentKeepCount);
var budgetResult = AgentToolResultBudget.Apply(messages, ToolResultTruncationKeepCount);
bool truncated = budgetResult.TruncatedCount > 0;
var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
var cutoff = Math.Max(0, messages.Count - ToolResultTruncationKeepCount);
for (int i = 0; i < cutoff; i++)
{

View File

@@ -292,30 +292,35 @@ public partial class LlmService
// 시스템 프롬프트
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
var activeModel = ResolveModel();
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
if (!string.IsNullOrEmpty(systemPrompt))
{
return new
var body = new Dictionary<string, object?>
{
model = activeModel,
max_tokens = ResolveOpenAiCompatibleMaxTokens(),
temperature = llm.Temperature,
system = systemPrompt,
messages = msgs,
tools = toolDefs,
stream = false
["model"] = activeModel,
["temperature"] = llm.Temperature,
["system"] = systemPrompt,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
};
if (maxTokens.HasValue)
body["max_tokens"] = maxTokens.Value;
return body;
}
return new
var fallbackBody = new Dictionary<string, object?>
{
model = activeModel,
max_tokens = ResolveOpenAiCompatibleMaxTokens(),
temperature = llm.Temperature,
messages = msgs,
tools = toolDefs,
stream = false
["model"] = activeModel,
["temperature"] = llm.Temperature,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
};
if (maxTokens.HasValue)
fallbackBody["max_tokens"] = maxTokens.Value;
return fallbackBody;
}
// ─── Gemini Function Calling ───────────────────────────────────────
@@ -476,16 +481,19 @@ public partial class LlmService
var systemInstruction = messages.FirstOrDefault(m => m.Role == "system");
var body = new Dictionary<string, object>
var body = new Dictionary<string, object?>
{
["contents"] = contents,
["tools"] = new[] { new { function_declarations = funcDecls } },
["generationConfig"] = new
{
temperature = ResolveTemperature(),
maxOutputTokens = _settings.Settings.Llm.MaxContextTokens,
}
};
var generationConfig = new Dictionary<string, object?>
{
["temperature"] = ResolveTemperature(),
};
var maxOutputTokens = ResolveConfiguredMaxOutputTokens();
if (maxOutputTokens.HasValue)
generationConfig["maxOutputTokens"] = maxOutputTokens.Value;
body["generationConfig"] = generationConfig;
if (systemInstruction != null)
{
@@ -967,9 +975,11 @@ public partial class LlmService
["tools"] = toolDefs,
["stream"] = true,
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls,
};
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
if (maxTokens.HasValue)
body["max_tokens"] = maxTokens.Value;
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
@@ -1114,30 +1124,38 @@ public partial class LlmService
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
return new
var parameters = new Dictionary<string, object?>
{
messages = msgs,
tools = toolDefs,
tool_choice = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
},
chat_template_kwargs = new { enable_thinking = false },
["temperature"] = ResolveToolTemperature(),
};
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
if (maxTokens.HasValue)
parameters["max_new_tokens"] = maxTokens.Value;
return new Dictionary<string, object?>
{
["messages"] = msgs,
["tools"] = toolDefs,
["tool_choice"] = "required",
["parameters"] = parameters,
["chat_template_kwargs"] = new { enable_thinking = false },
};
}
return new
var fallbackParameters = new Dictionary<string, object?>
{
messages = msgs,
tools = toolDefs,
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
},
chat_template_kwargs = new { enable_thinking = false },
["temperature"] = ResolveToolTemperature(),
};
var fallbackMaxTokens = ResolveOpenAiCompatibleMaxTokens();
if (fallbackMaxTokens.HasValue)
fallbackParameters["max_new_tokens"] = fallbackMaxTokens.Value;
return new Dictionary<string, object?>
{
["messages"] = msgs,
["tools"] = toolDefs,
["parameters"] = fallbackParameters,
["chat_template_kwargs"] = new { enable_thinking = false },
};
}

View File

@@ -301,14 +301,25 @@ public partial class LlmService : ILlmService
return llm.Model;
}
private int ResolveOpenAiCompatibleMaxTokens()
private int? ResolveConfiguredMaxOutputTokens()
{
var llm = _settings.Settings.Llm;
var requested = Math.Clamp(llm.MaxContextTokens, 1, 1_000_000);
return llm.MaxContextTokens > 0
? Math.Clamp(llm.MaxContextTokens, 1, 1_000_000)
: null;
}
private int? ResolveOpenAiCompatibleMaxTokens()
{
var llm = _settings.Settings.Llm;
var requested = ResolveConfiguredMaxOutputTokens();
if (!requested.HasValue)
return null;
var service = NormalizeServiceName(llm.Service);
if (service == "vllm")
return Math.Min(requested, 8192);
return Math.Min(requested.Value, 8192);
return requested;
}
@@ -469,16 +480,19 @@ public partial class LlmService : ILlmService
var temperature = ResolveTemperature();
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody 완료: finalMessages={msgs.Count}건, temp={temperature}, maxTokens={maxTokens}");
return new
var parameters = new Dictionary<string, object?>
{
messages = msgs,
parameters = new
{
temperature,
max_new_tokens = maxTokens
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
["temperature"] = temperature,
};
if (maxTokens.HasValue)
parameters["max_new_tokens"] = maxTokens.Value;
return new Dictionary<string, object?>
{
["messages"] = msgs,
["parameters"] = parameters,
// Qwen3.5 thinking mode is disabled to keep content in the normal response field.
["chat_template_kwargs"] = new { enable_thinking = false },
};
}
@@ -1155,7 +1169,6 @@ public partial class LlmService : ILlmService
private object BuildOpenAiBody(List<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = BuildMessageList(messages, openAiVision: true);
var body = new Dictionary<string, object?>
{
@@ -1163,8 +1176,10 @@ public partial class LlmService : ILlmService
["messages"] = msgs,
["stream"] = stream,
["temperature"] = ResolveTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens()
};
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
if (maxTokens.HasValue)
body["max_tokens"] = maxTokens.Value;
// 스트리밍 시 마지막 청크에 토큰 사용량을 포함하도록 요청 (vLLM/OpenAI 호환)
if (stream)
body["stream_options"] = new { include_usage = true };
@@ -1298,19 +1313,23 @@ public partial class LlmService : ILlmService
});
}
if (systemInstruction != null)
return new
{
systemInstruction,
contents,
generationConfig = new { temperature = ResolveTemperature(), maxOutputTokens = llm.MaxContextTokens }
};
return new
var generationConfig = new Dictionary<string, object?>
{
contents,
generationConfig = new { temperature = ResolveTemperature(), maxOutputTokens = llm.MaxContextTokens }
["temperature"] = ResolveTemperature(),
};
var maxOutputTokens = ResolveConfiguredMaxOutputTokens();
if (maxOutputTokens.HasValue)
generationConfig["maxOutputTokens"] = maxOutputTokens.Value;
var body = new Dictionary<string, object?>
{
["contents"] = contents,
["generationConfig"] = generationConfig,
};
if (systemInstruction != null)
body["systemInstruction"] = systemInstruction;
return body;
}
// ═══════════════════════════════════════════════════════════════════════
@@ -1422,7 +1441,6 @@ public partial class LlmService : ILlmService
private object BuildSigmoidBody(List<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
@@ -1444,27 +1462,32 @@ public partial class LlmService : ILlmService
}
var activeModel = ResolveModel();
var maxTokens = ResolveConfiguredMaxOutputTokens();
if (!string.IsNullOrEmpty(_systemPrompt))
{
return new
var body = new Dictionary<string, object?>
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = ResolveTemperature(),
system = _systemPrompt,
messages = msgs,
stream
["model"] = activeModel,
["temperature"] = ResolveTemperature(),
["system"] = _systemPrompt,
["messages"] = msgs,
["stream"] = stream,
};
if (maxTokens.HasValue)
body["max_tokens"] = maxTokens.Value;
return body;
}
return new
var fallbackBody = new Dictionary<string, object?>
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = ResolveTemperature(),
messages = msgs,
stream
["model"] = activeModel,
["temperature"] = ResolveTemperature(),
["messages"] = msgs,
["stream"] = stream,
};
if (maxTokens.HasValue)
fallbackBody["max_tokens"] = maxTokens.Value;
return fallbackBody;
}
// ─── 공용 헬퍼 ─────────────────────────────────────────────────────────

View File

@@ -45,7 +45,7 @@ public class SettingsService : ISettingsService
/// - 올릴 때마다 MigrateIfNeeded() 에 해당 버전으로의 마이그레이션 블록을 추가합니다.
/// - 현재 매핑: 앱 v1.0.0~1.0.2 → 설정 v1.0 / 앱 v1.0.3~ → 설정 v1.1
/// </summary>
private const string CurrentSettingsVersion = "1.2";
private const string CurrentSettingsVersion = "1.3";
public void Load()
{
@@ -140,8 +140,18 @@ public class SettingsService : ISettingsService
migrated = true;
}
// ── 향후 마이그레이션은 여기에 추가 ─────────────────────────────────
// if (IsVersionLessThan(ver, "1.3")) { ... ver = "1.3"; migrated = true; }
// ── 1.2 → 1.3 마이그레이션 ──────────────────────────────────────────
if (IsVersionLessThan(ver, "1.3"))
{
// 0 means auto context budget. Legacy default 32K is converted to auto
// so the effective budget follows the selected model.
if (_settings.Llm.MaxContextTokens == 32_768)
_settings.Llm.MaxContextTokens = 0;
LogService.Info("설정 마이그레이션: 1.2 → 1.3 (컨텍스트 토큰 Auto 기본값 적용)");
ver = "1.3";
migrated = true;
}
if (migrated)
{
@@ -150,7 +160,9 @@ public class SettingsService : ISettingsService
MigrationSummary = $"설정이 v{CurrentSettingsVersion}으로 업데이트되었습니다.\n\n"
+ "• 파일 대화상자 연동 기능이 비활성화되었습니다.\n"
+ " 웹 브라우저 파일 업로드 시 런처가 열리는 문제를 방지합니다.\n"
+ " 필요 시 설정 → 기능 → AI 기능에서 다시 활성화할 수 있습니다.";
+ " 필요 시 설정 → 기능 → AI 기능에서 다시 활성화할 수 있습니다.\n\n"
+ "• 컨텍스트 토큰 기본값이 Auto로 전환되었습니다.\n"
+ " 선택한 모델의 입력 한도에 맞춰 자동으로 조절됩니다.";
LogService.Info($"설정 파일 마이그레이션 완료 → v{CurrentSettingsVersion}");
}
}
@@ -355,4 +367,3 @@ public class SettingsService : ISettingsService
};
}
}

View File

@@ -260,8 +260,10 @@ public class SettingsViewModel : INotifyPropertyChanged
public int LlmMaxContextTokens
{
get => _llmMaxContextTokens;
set { _llmMaxContextTokens = Math.Clamp(value, 1024, 1_000_000); OnPropertyChanged(); }
set { _llmMaxContextTokens = NormalizeContextTokenSetting(value); OnPropertyChanged(); }
}
private static int NormalizeContextTokenSetting(int value)
=> value <= 0 ? 0 : Math.Clamp(value, 1_024, 1_000_000);
public int LlmRetentionDays
{
get => _llmRetentionDays;
@@ -1319,7 +1321,7 @@ public class SettingsViewModel : INotifyPropertyChanged
var llm = s.Llm;
_llmService = NormalizeServiceKey(llm.Service);
_llmStreaming = llm.Streaming;
_llmMaxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
_llmMaxContextTokens = NormalizeContextTokenSetting(llm.MaxContextTokens);
_llmRetentionDays = llm.RetentionDays;
_llmTemperature = llm.Temperature;
_defaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission);
@@ -1794,7 +1796,7 @@ public class SettingsViewModel : INotifyPropertyChanged
// LLM 공통 설정 저장
s.Llm.Service = _llmService;
s.Llm.Streaming = _llmStreaming;
s.Llm.MaxContextTokens = Math.Clamp(_llmMaxContextTokens, 1024, 1_000_000);
s.Llm.MaxContextTokens = NormalizeContextTokenSetting(_llmMaxContextTokens);
s.Llm.RetentionDays = _llmRetentionDays;
s.Llm.Temperature = _llmTemperature;
s.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_defaultAgentPermission);

View File

@@ -58,7 +58,7 @@ public partial class AgentSettingsWindow : Window
ChkVllmAllowInsecureTls.IsChecked = _llm.VllmAllowInsecureTls;
ChkEnableProactiveCompact.IsChecked = _llm.EnableProactiveContextCompact;
TxtContextCompactTriggerPercent.Text = Math.Clamp(_llm.ContextCompactTriggerPercent, 10, 95).ToString();
TxtMaxContextTokens.Text = Math.Max(1024, _llm.MaxContextTokens).ToString();
TxtMaxContextTokens.Text = FormatContextTokenSetting(_llm.MaxContextTokens);
TxtMaxRetryOnError.Text = Math.Clamp(_llm.MaxRetryOnError, 0, 10).ToString();
ChkEnableIbmDiagnosticLog.IsChecked = _llm.EnableIbmDiagnosticLog;
@@ -549,7 +549,7 @@ public partial class AgentSettingsWindow : Window
_llm.EnableProactiveContextCompact = ChkEnableProactiveCompact.IsChecked == true;
_llm.ContextCompactTriggerPercent = ParseInt(TxtContextCompactTriggerPercent.Text, 80, 10, 95);
_llm.MaxContextTokens = ParseInt(TxtMaxContextTokens.Text, 32_768, 1024, 200000);
_llm.MaxContextTokens = ParseContextTokens(TxtMaxContextTokens.Text, _llm.MaxContextTokens);
_llm.MaxRetryOnError = ParseInt(TxtMaxRetryOnError.Text, 3, 0, 10);
_llm.EnableIbmDiagnosticLog = ChkEnableIbmDiagnosticLog.IsChecked == true;
@@ -1178,4 +1178,19 @@ public partial class AgentSettingsWindow : Window
value = fallback;
return Math.Clamp(value, min, max);
}
private static int ParseContextTokens(string? text, int fallback)
{
var normalized = (text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
return fallback;
if (string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase) || normalized == "0")
return 0;
return ParseInt(normalized, fallback <= 0 ? 0 : fallback, 1_024, 200_000);
}
private static string FormatContextTokenSetting(int value)
=> value <= 0 ? "Auto" : Math.Clamp(value, 1_024, 200_000).ToString();
}

View File

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
@@ -29,7 +30,8 @@ public partial class ChatWindow
var llm = _settings.Settings.Llm;
var expressionLevel = GetAgentUiExpressionLevel();
var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
var (service, model) = _llm.GetCurrentModelInfo();
var maxContextTokens = ContextCondenser.ResolveContextBudgetTokens(service, model, llm.MaxContextTokens);
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
var triggerRatio = triggerPercent / 100.0;
@@ -102,7 +104,9 @@ public partial class ChatWindow
}
else
{
detailText = $"자동 압축 시작 {triggerPercent}%";
detailText = llm.MaxContextTokens <= 0
? $"Auto budget · compact at {triggerPercent}%"
: $"Auto compact starts at {triggerPercent}%";
}
TokenUsageArc.Stroke = progressBrush;

View File

@@ -469,7 +469,7 @@ public partial class ChatWindow
CommitOverlayApiKeyInput();
CommitOverlayModelInput(normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, llm.ContextCompactTriggerPercent, 10, 95, value => llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxContextTokens, llm.MaxContextTokens, 1024, 1_000_000, value => llm.MaxContextTokens = value, normalizeOnInvalid: true);
CommitOverlayContextTokenInput(normalizeOnInvalid: true);
CommitOverlayTemperatureInput(normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, llm.MaxAgentIterations, 1, 500, value => llm.MaxAgentIterations = value, normalizeOnInvalid: true);
@@ -584,7 +584,7 @@ public partial class ChatWindow
if (TxtOverlayContextCompactTriggerPercent != null)
TxtOverlayContextCompactTriggerPercent.Text = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95).ToString();
if (TxtOverlayMaxContextTokens != null)
TxtOverlayMaxContextTokens.Text = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000).ToString();
TxtOverlayMaxContextTokens.Text = FormatOverlayContextTokenSetting(llm.MaxContextTokens);
if (TxtOverlayTemperature != null)
TxtOverlayTemperature.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
if (SldOverlayTemperature != null)
@@ -830,6 +830,53 @@ public partial class ChatWindow
return true;
}
private static int NormalizeOverlayContextTokenSetting(int value)
=> value <= 0 ? 0 : Math.Clamp(value, 1_024, 1_000_000);
private static string FormatOverlayContextTokenSetting(int value)
=> value <= 0 ? "Auto" : NormalizeOverlayContextTokenSetting(value).ToString();
private bool CommitOverlayContextTokenInput(bool normalizeOnInvalid)
{
if (TxtOverlayMaxContextTokens == null)
return false;
var raw = (TxtOverlayMaxContextTokens.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(raw))
{
if (normalizeOnInvalid)
TxtOverlayMaxContextTokens.Text = FormatOverlayContextTokenSetting(_settings.Settings.Llm.MaxContextTokens);
return false;
}
int normalizedValue;
if (string.Equals(raw, "auto", StringComparison.OrdinalIgnoreCase) || raw == "0")
{
normalizedValue = 0;
}
else if (int.TryParse(raw, out var parsed))
{
normalizedValue = NormalizeOverlayContextTokenSetting(parsed);
}
else
{
MarkOverlayValidation(TxtOverlayMaxContextTokens, "Enter Auto or a value between 1024 and 1000000.");
if (normalizeOnInvalid)
{
TxtOverlayMaxContextTokens.Text = FormatOverlayContextTokenSetting(_settings.Settings.Llm.MaxContextTokens);
ClearOverlayValidation(TxtOverlayMaxContextTokens);
}
return false;
}
var changed = _settings.Settings.Llm.MaxContextTokens != normalizedValue;
_settings.Settings.Llm.MaxContextTokens = normalizedValue;
ClearOverlayValidation(TxtOverlayMaxContextTokens);
TxtOverlayMaxContextTokens.Text = FormatOverlayContextTokenSetting(normalizedValue);
return changed;
}
private LlmSettings? TryGetOverlayLlmSettings()
=> _settings?.Settings?.Llm;
@@ -938,7 +985,7 @@ public partial class ChatWindow
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxContextTokens, _settings.Settings.Llm.MaxContextTokens, 1024, 1_000_000, value => _settings.Settings.Llm.MaxContextTokens = value, normalizeOnInvalid: false))
if (CommitOverlayContextTokenInput(normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
@@ -1145,9 +1192,9 @@ public partial class ChatWindow
if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value))
return;
_settings.Settings.Llm.MaxContextTokens = Math.Clamp(value, 1024, 1_000_000);
_settings.Settings.Llm.MaxContextTokens = NormalizeOverlayContextTokenSetting(value);
if (TxtOverlayMaxContextTokens != null)
TxtOverlayMaxContextTokens.Text = _settings.Settings.Llm.MaxContextTokens.ToString();
TxtOverlayMaxContextTokens.Text = FormatOverlayContextTokenSetting(_settings.Settings.Llm.MaxContextTokens);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
@@ -2923,6 +2970,7 @@ public partial class ChatWindow
var context = llm.MaxContextTokens switch
{
<= 0 => 0,
<= 4096 => 4096,
<= 16384 => 16384,
<= 32768 => 32768,
@@ -2931,6 +2979,7 @@ public partial class ChatWindow
<= 262144 => 262144,
_ => 1_000_000
};
SetOverlayCardSelection(OverlayContextAutoCard, context == 0);
SetOverlayCardSelection(OverlayContext4KCard, context == 4096);
SetOverlayCardSelection(OverlayContext16KCard, context == 16384);
SetOverlayCardSelection(OverlayContext32KCard, context == 32768);

View File

@@ -4533,14 +4533,25 @@
LineHeight="18"
MaxWidth="280">한 번의 작업에서 AX Agent가 유지할 수 있는 최대 문맥 크기입니다. 너무 낮으면 긴 대화가 빨리 잘리고, 너무 높으면 느려질 수 있습니다.</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<StackPanel Grid.Column="1">
<WrapPanel HorizontalAlignment="Right">
<Border x:Name="OverlayContextAutoCard"
Cursor="Hand"
CornerRadius="8"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="9,7"
Margin="0,0,8,8"
MouseLeftButtonUp="OverlayContextPresetCard_MouseLeftButtonUp"
Tag="0">
<TextBlock Text="Auto" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="1">
<WrapPanel HorizontalAlignment="Right">
<Border x:Name="OverlayContext4KCard"
Cursor="Hand"
CornerRadius="8"
<Border x:Name="OverlayContext4KCard"
Cursor="Hand"
CornerRadius="8"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="9,7"

View File

@@ -35,6 +35,7 @@ public partial class ChatWindow : Window
private readonly HashSet<string> _streamingTabs = [];
private readonly Dictionary<string, CancellationTokenSource> _tabStreamCts = new();
private readonly Dictionary<string, ChatConversation> _streamingConversations = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<ChatMessage>> _lastAgentLoopMessagesByTab = new(StringComparer.OrdinalIgnoreCase);
private bool _isStreaming => _streamingTabs.Count > 0;
private bool _sidebarVisible = true;
private double _sidebarExpandedWidth = 262;
@@ -201,6 +202,53 @@ public partial class ChatWindow : Window
_tabRunProgressSteps.Remove(key);
}
private void StoreAgentLoopMessagesSnapshot(string? tab, IReadOnlyList<ChatMessage>? messages)
{
var key = NormalizeRunTabKey(tab);
if (messages == null || messages.Count == 0)
{
_lastAgentLoopMessagesByTab.Remove(key);
return;
}
_lastAgentLoopMessagesByTab[key] = messages.Select(CloneChatMessageForLoopSnapshot).ToList();
}
private List<ChatMessage>? TakeAgentLoopMessagesSnapshot(string? tab)
{
var key = NormalizeRunTabKey(tab);
if (!_lastAgentLoopMessagesByTab.TryGetValue(key, out var snapshot))
return null;
_lastAgentLoopMessagesByTab.Remove(key);
return snapshot;
}
private static ChatMessage CloneChatMessageForLoopSnapshot(ChatMessage source)
{
return new ChatMessage
{
MsgId = source.MsgId,
Role = source.Role,
Content = source.Content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
ResponseElapsedMs = source.ResponseElapsedMs,
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
QueryPreviewContent = source.QueryPreviewContent,
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,
MimeType = image.MimeType,
FileName = image.FileName,
}).ToList(),
};
}
private AgentEvent? GetPendingAgentUiEvent(string? tab)
{
var key = NormalizeRunTabKey(tab);
@@ -4534,6 +4582,9 @@ public partial class ChatWindow : Window
var model = GetCurrentModelDisplayName();
var folder = GetCurrentWorkFolder();
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
var (serviceKey, modelKey) = _llm.GetCurrentModelInfo();
var contextBudget = ContextCondenser.ResolveContextBudgetTokens(serviceKey, modelKey, llm.MaxContextTokens);
var contextBudgetLabel = llm.MaxContextTokens <= 0 ? $"Auto ({contextBudget:N0})" : $"{contextBudget:N0}";
return
$"현재 상태\n" +
$"- 탭: {tab}\n" +
@@ -4541,7 +4592,7 @@ public partial class ChatWindow : Window
$"- 권한: {permission}\n" +
$"- 작업 폴더: {folderText}\n" +
$"- 스트리밍: {(llm.Streaming ? "ON" : "OFF")}\n" +
$"- 컨텍스트 토큰: {llm.MaxContextTokens:N0}";
$"- 컨텍스트 토큰: {contextBudgetLabel}";
}
private string BuildSlashStatsText()
@@ -6218,6 +6269,7 @@ public partial class ChatWindow : Window
loop.UserDecisionCallback = CreatePlanDecisionCallback();
// 채팅창 내 라이브 진행 카드 표시
ShowAgentLiveCard(runTab);
List<ChatMessage>? msgList = null;
try
{
loop.ActiveTab = runTab;
@@ -6225,7 +6277,7 @@ public partial class ChatWindow : Window
// 에이전트 루프를 백그라운드 스레드에서 실행 — UI 스레드 블록 방지
// RunAsync 내부의 _llm.SendAsync가 SynchronizationContext로 UI 스레드에
// 복귀할 때 WPF 메시지 펌프를 독점하여 창 드래그/최소화가 안 되는 문제 해결
var msgList = sendMessages.ToList();
msgList = sendMessages.ToList();
var response = await Task.Run(() => loop.RunAsync(msgList, cancellationToken));
if (_settings.Settings.Llm.NotifyOnComplete)
{
@@ -6269,6 +6321,7 @@ public partial class ChatWindow : Window
}
finally
{
StoreAgentLoopMessagesSnapshot(runTab, msgList);
ResetPermissionRulesForRun(runTab);
loop.RuntimeWorkFolderOverride = null;
loop.EventOccurred -= agentEventHandler;
@@ -6657,6 +6710,14 @@ public partial class ChatWindow : Window
lock (_convLock)
{
var session = ChatSession;
if (preparedExecution.Mode.UseAgentLoop)
{
_chatEngine.SyncAgentLoopMessages(
conversation,
preparedExecution.Messages,
TakeAgentLoopMessagesSnapshot(runTab));
}
assistantContent = _chatEngine.FinalizeAssistantTurn(
session,
conversation,

View File

@@ -4043,6 +4043,12 @@
<TextBlock Style="{StaticResource RowHint}" Text="한 번의 요청에 포함할 최대 토큰 수."/>
</StackPanel>
<WrapPanel HorizontalAlignment="Right" VerticalAlignment="Center" MaxWidth="360">
<RadioButton x:Name="AgentContextTokensAuto"
Content="Auto"
GroupName="AgentContextTokens"
Style="{StaticResource AgentSubTabStyle}"
Margin="0,0,6,6"
Checked="AgentContextTokensCard_Checked"/>
<RadioButton x:Name="AgentContextTokens4K"
Content="4K"
GroupName="AgentContextTokens"

View File

@@ -195,7 +195,8 @@ public partial class SettingsWindow : Window
if (AgentOperationModeExternal != null) AgentOperationModeExternal.IsChecked = operationMode == OperationModePolicy.ExternalMode;
var maxContextTokens = _vm.LlmMaxContextTokens;
if (AgentContextTokens4K != null) AgentContextTokens4K.IsChecked = maxContextTokens <= 4_096;
if (AgentContextTokensAuto != null) AgentContextTokensAuto.IsChecked = maxContextTokens <= 0;
if (AgentContextTokens4K != null) AgentContextTokens4K.IsChecked = maxContextTokens > 0 && maxContextTokens <= 4_096;
if (AgentContextTokens16K != null) AgentContextTokens16K.IsChecked = maxContextTokens > 4_096 && maxContextTokens <= 16_384;
if (AgentContextTokens32K != null) AgentContextTokens32K.IsChecked = maxContextTokens > 16_384 && maxContextTokens <= 32_768;
if (AgentContextTokens64K != null) AgentContextTokens64K.IsChecked = maxContextTokens > 32_768 && maxContextTokens <= 65_536;
@@ -2296,6 +2297,7 @@ public partial class SettingsWindow : Window
_vm.LlmMaxContextTokens = rb.Name switch
{
"AgentContextTokensAuto" => 0,
"AgentContextTokens16K" => 16_384,
"AgentContextTokens32K" => 32_768,
"AgentContextTokens64K" => 65_536,