From e07b6dbed09e9e4c5d906910b148ec316aea86a1 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 07:45:36 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=EC=BD=94=EB=93=9C=20=ED=83=AD=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=ED=99=94=EC=99=80=20Auto=20budget=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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\\ --- README.md | 102 +------- docs/CODE_CONTEXT_RELIABILITY_PLAN.md | 27 +++ docs/DEVELOPMENT.md | 11 + .../AgentLoopDiagnosticsFormatterTests.cs | 2 +- .../Services/AgentQueryContextBuilderTests.cs | 39 +++ .../Services/AxAgentExecutionEngineTests.cs | 118 ++++++++++ .../Services/ContextCondenserTests.cs | 10 +- .../Services/SettingsServiceTests.cs | 4 +- src/AxCopilot/Models/AppSettings.cs | 4 +- .../Agent/AgentQueryContextBuilder.cs | 143 ++++++++++- .../Services/Agent/AxAgentExecutionEngine.cs | 222 +++++++++++++----- .../Services/Agent/ContextCondenser.cs | 21 +- src/AxCopilot/Services/LlmService.ToolUse.cs | 100 ++++---- src/AxCopilot/Services/LlmService.cs | 101 +++++--- src/AxCopilot/Services/SettingsService.cs | 21 +- src/AxCopilot/ViewModels/SettingsViewModel.cs | 8 +- .../Views/AgentSettingsWindow.xaml.cs | 19 +- .../ChatWindow.ContextUsagePresentation.cs | 8 +- .../ChatWindow.OverlaySettingsPresentation.cs | 59 ++++- src/AxCopilot/Views/ChatWindow.xaml | 25 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 65 ++++- src/AxCopilot/Views/SettingsWindow.xaml | 6 + src/AxCopilot/Views/SettingsWindow.xaml.cs | 4 +- 23 files changed, 851 insertions(+), 268 deletions(-) diff --git a/README.md b/README.md index b252237..307ba38 100644 --- a/README.md +++ b/README.md @@ -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](). -- 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]() 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]() was also rewritten with English-only status strings so the active stream/wait path no longer carries mojibake text, and [AgentLoopPreLlmStageService.cs]() now returns clean English missing-tool failures. -- Added [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](). -- 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]() now reads more like an orchestrator: history/query assembly, pre-LLM stage planning, LLM dispatch, tool execution, and recovery. -- Added [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 diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index bf98dd8..7256a3e 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -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: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ebbe823..a5c22a7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/src/AxCopilot.Tests/Services/AgentLoopDiagnosticsFormatterTests.cs b/src/AxCopilot.Tests/Services/AgentLoopDiagnosticsFormatterTests.cs index 12eb004..16bc90d 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopDiagnosticsFormatterTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopDiagnosticsFormatterTests.cs @@ -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] diff --git a/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs b/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs index f98e340..76719a1 100644 --- a/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs +++ b/src/AxCopilot.Tests/Services/AgentQueryContextBuilderTests.cs @@ -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 + { + 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":""}""" + }, + 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:"); + } } diff --git a/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs b/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs index 3c727fd..17ba089 100644 --- a/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs +++ b/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs @@ -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":""}""" + }); + 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(), + }; + } } diff --git a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs index 255a207..732fdae 100644 --- a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs +++ b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs @@ -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 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 = "둘째 답변" }, diff --git a/src/AxCopilot.Tests/Services/SettingsServiceTests.cs b/src/AxCopilot.Tests/Services/SettingsServiceTests.cs index 4699587..45ec8ad 100644 --- a/src/AxCopilot.Tests/Services/SettingsServiceTests.cs +++ b/src/AxCopilot.Tests/Services/SettingsServiceTests.cs @@ -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 테마 ─────────────────────────────────────────────── diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 3024872..895406e 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -831,9 +831,9 @@ public class LlmSettings [JsonPropertyName("streaming")] public bool Streaming { get; set; } = true; - /// 최대 컨텍스트 토큰 수. 기본값 32768. + /// Maximum context token budget. 0 means auto (model-aware). [JsonPropertyName("maxContextTokens")] - public int MaxContextTokens { get; set; } = 32_768; + public int MaxContextTokens { get; set; } /// 대화 보관 기간(일). 7 | 30 | 90 | 0(무제한). 기본값 30. [JsonPropertyName("retentionDays")] diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs index 0454106..87975b1 100644 --- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -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 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 messages) + private static void InjectPostCompactContextMessage( + List messages, + IReadOnlyList 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 { "[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 CollectDroppedToolSnippets(IReadOnlyList sourceMessages, int windowStartIndex) + { + if (windowStartIndex <= 0) + return []; + + var snippets = new List(); + var seenSnippets = new HashSet(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(); + var textParts = new List(); + 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 = ""; diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs index a00656b..69a7dc9 100644 --- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -4,9 +4,9 @@ using AxCopilot.Services; namespace AxCopilot.Services.Agent; /// -/// 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. /// public sealed class AxAgentExecutionEngine { @@ -27,12 +27,21 @@ public sealed class AxAgentExecutionEngine string? taskSystem = null) { var prompts = new List(); - 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(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( + 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 preparedMessages, + IReadOnlyList? agentLoopMessages) + { + if (agentLoopMessages == null || agentLoopMessages.Count == 0) + return 0; + + var baselineIds = new HashSet( + preparedMessages + .Select(message => message.MsgId) + .Where(id => !string.IsNullOrWhiteSpace(id)), + StringComparer.Ordinal); + var existingConversationIds = new HashSet( + conversation.Messages + .Select(message => message.MsgId) + .Where(id => !string.IsNullOrWhiteSpace(id)), + StringComparer.Ordinal); + var candidateIds = new HashSet( + 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); } /// - /// LLM 응답이 비어있을 때 실행 이벤트에서 의미 있는 완료 메시지를 구성합니다. - /// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다. + /// Builds a fallback completion message from execution evidence when the + /// final assistant response is empty. /// 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(), }; } diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index f290ece..720f119 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -34,12 +34,13 @@ public static class ContextCondenser /// 도구 결과 1개당 최대 유지 길이 (자) 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; /// 요약 시 유지할 최근 메시지 수 - 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 /// private static bool TruncateToolResults(List 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++) { diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index ba86cd1..051d4d5 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -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 { - 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 { - 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 + var body = new Dictionary { ["contents"] = contents, ["tools"] = new[] { new { function_declarations = funcDecls } }, - ["generationConfig"] = new - { - temperature = ResolveTemperature(), - maxOutputTokens = _settings.Settings.Llm.MaxContextTokens, - } }; + var generationConfig = new Dictionary + { + ["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 { - 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 + { + ["messages"] = msgs, + ["tools"] = toolDefs, + ["tool_choice"] = "required", + ["parameters"] = parameters, + ["chat_template_kwargs"] = new { enable_thinking = false }, }; } - return new + var fallbackParameters = new Dictionary { - 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 + { + ["messages"] = msgs, + ["tools"] = toolDefs, + ["parameters"] = fallbackParameters, + ["chat_template_kwargs"] = new { enable_thinking = false }, }; } diff --git a/src/AxCopilot/Services/LlmService.cs b/src/AxCopilot/Services/LlmService.cs index 80afdc1..c151467 100644 --- a/src/AxCopilot/Services/LlmService.cs +++ b/src/AxCopilot/Services/LlmService.cs @@ -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 { - 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 + { + ["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 messages, bool stream) { - var llm = _settings.Settings.Llm; var msgs = BuildMessageList(messages, openAiVision: true); var body = new Dictionary { @@ -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 { - contents, - generationConfig = new { temperature = ResolveTemperature(), maxOutputTokens = llm.MaxContextTokens } + ["temperature"] = ResolveTemperature(), }; + var maxOutputTokens = ResolveConfiguredMaxOutputTokens(); + if (maxOutputTokens.HasValue) + generationConfig["maxOutputTokens"] = maxOutputTokens.Value; + + var body = new Dictionary + { + ["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 messages, bool stream) { - var llm = _settings.Settings.Llm; var msgs = new List(); 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 { - 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 { - 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; } // ─── 공용 헬퍼 ───────────────────────────────────────────────────────── diff --git a/src/AxCopilot/Services/SettingsService.cs b/src/AxCopilot/Services/SettingsService.cs index 3147d34..2e3b88f 100644 --- a/src/AxCopilot/Services/SettingsService.cs +++ b/src/AxCopilot/Services/SettingsService.cs @@ -45,7 +45,7 @@ public class SettingsService : ISettingsService /// - 올릴 때마다 MigrateIfNeeded() 에 해당 버전으로의 마이그레이션 블록을 추가합니다. /// - 현재 매핑: 앱 v1.0.0~1.0.2 → 설정 v1.0 / 앱 v1.0.3~ → 설정 v1.1 /// - 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 }; } } - diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 8c23cc9..d009280 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -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); diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs index 2f66b50..5396b61 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs @@ -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(); } diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs index ff0d624..7cee42b 100644 --- a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs @@ -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; diff --git a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs index 4d04841..85340e8 100644 --- a/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs @@ -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); diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 9e28201..2a1021d 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -4533,14 +4533,25 @@ LineHeight="18" MaxWidth="280">한 번의 작업에서 AX Agent가 유지할 수 있는 최대 문맥 크기입니다. 너무 낮으면 긴 대화가 빨리 잘리고, 너무 높으면 느려질 수 있습니다. - + + + + + + + - - - - _streamingTabs = []; private readonly Dictionary _tabStreamCts = new(); private readonly Dictionary _streamingConversations = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _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? messages) + { + var key = NormalizeRunTabKey(tab); + if (messages == null || messages.Count == 0) + { + _lastAgentLoopMessagesByTab.Remove(key); + return; + } + + _lastAgentLoopMessagesByTab[key] = messages.Select(CloneChatMessageForLoopSnapshot).ToList(); + } + + private List? 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? 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, diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index fa8cd86..f2ab9f2 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4043,6 +4043,12 @@ + 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,