From 0f83dc802c4273a9b2c172e09c7bdc7e9cffec9d Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 12 Apr 2026 21:36:50 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=B7=B0=EC=99=80=20=EC=95=95=EC=B6=95=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=A5=BC=20claw-code=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claw-code의 query.ts, autoCompact.ts, sessionMemoryCompact.ts 흐름을 참고해 AX Agent의 컨텍스트 관리와 압축 동작을 더 가깝게 맞췄다. - AgentQueryContextBuilder를 추가해 저장된 전체 대화와 실제 LLM 전송용 query view를 분리 - compact boundary 이후만 전송하고 tool_result/tool_use 짝이 끊기지 않도록 start index를 보정 - 오래된 tool_result는 query view에서만 별도 budget으로 축약하도록 조정 - ContextCondenser의 자동 압축 시작점을 effective context window, summary reserve, buffer 기준으로 재계산 - 미사용 입력 높이 캐시 필드를 제거해 빌드 경고를 해소 - README.md, docs/DEVELOPMENT.md에 2026-04-12 21:34 (KST) 기준 작업 이력 반영 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0 --- README.md | 15 + docs/DEVELOPMENT.md | 17 + .../Services/Agent/AgentLoopService.cs | 433 +++++++++++++++++- .../Agent/AgentQueryContextBuilder.cs | 334 ++++++++++++++ .../Services/Agent/ContextCondenser.cs | 24 +- .../ChatWindow.ComposerQueuePresentation.cs | 63 ++- 6 files changed, 844 insertions(+), 42 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs diff --git a/README.md b/README.md index 67932dd..0c30b1a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-10 14:30 (KST) +- **UI 프리징 근본 수정**: 스트리밍 중 렌더링 쓰로틀(1.5초 최소 간격), 이중 RenderMessages 제거, 타이머 Stop→Start 무한 루프 차단, 불필요 타이머 4개 일시 정지, 타이머 간격 2-10배 증가(350ms→5s, 500ms→2s 등). 에이전트 이벤트 디스패처 우선순위를 Normal→Background로 하향. +- **모델 프로파일 도구 사용 버그 수정**: Ollama 모델에 `tool_choice: "required"` 미전달 버그 수정 — `BuildOpenAiToolBody`에서 Ollama 조기 리턴 전에 `tool_choice` 주입. `FindRegisteredModel`의 대소문자 민감 비교를 `OrdinalIgnoreCase`로 변경하여 프로파일 매칭 실패 방지. +- **트랜스크립트 UI 개선**: "처리 중..." / "작업을 준비하는 중입니다..." 트랜스크립트 힌트를 제거하고 PulseDotBar로만 상태 표시 (Claude Desktop 스타일). 완료 이벤트의 이모지 깨짐("?챗셝?콺듦") 수정 — 서로게이트 쌍(비-BMP 유니코드) 자동 제거. +- **PPT 기능 확장**: 이미지 삽입(BlipFill+aspect ratio), 아이콘 라이브러리(170+ 유니코드/120+ OpenXML), 슬라이드 복제(전체/개별), 네이티브 차트(bar/line/pie ChartPart), theme_file 마스터 복제. +- **아이콘 공유**: 새 `IconLibrary.cs`를 DOCX/XLSX/HTML 스킬에서 공유. `{icon:name}` 인라인 구문 + 블록 아이콘 지원. +- **마스코트 개선**: 3배 크기(300px), NearestNeighbor 픽셀아트 렌더, 투명 배경, 좌우 이동 애니메이션 10종. +- 검증: `dotnet build` 경고 0 / 오류 0 + - 업데이트: 2026-04-10 09:02 (KST) - `claude-code` 기준으로 Cowork/Code의 남은 차이를 더 줄였습니다. Cowork/Code 프롬프트의 텍스트-only 완료 조건을 완화해, 작업이 이미 끝났거나 충분한 근거가 있을 때는 불필요한 도구 호출을 더 강제하지 않도록 정리했습니다. - 에이전트 루프도 같이 손봤습니다. 텍스트-only 재시도는 이제 실제 산출물 생성이나 코드 수정처럼 구체적인 실행이 필요한 경우에만 다시 도구를 강제하고, 문서 생성 fallback도 같은 범위로 좁혔습니다. @@ -1613,3 +1622,9 @@ MIT License - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 agent dispatcher는 `DispatcherPriority.Normal` 대신 `Background`를 사용해 입력/렌더가 먼저 흐르도록 조정했습니다. - 같은 파일의 `OnAgentEvent()`는 이벤트마다 라이브 카드와 상태 서브아이템을 즉시 갱신하지 않고, 완료/오류 같은 종료 신호만 즉시 처리한 뒤 나머지는 기존 배치 타이머로 넘기도록 단순화했습니다. - [ChatWindow.AgentEventProcessor.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs)는 execution event를 UI 스레드와 백그라운드에서 두 번 append하던 구조를 제거하고, 백그라운드 단일 리더에서 한 번만 대화 히스토리를 반영하도록 바꿨습니다. +- 업데이트: 2026-04-12 21:34 (KST) + - `claw-code`의 `messagesForQuery`, `autoCompact`, `sessionMemoryCompact` 흐름을 기준으로 AX Agent의 컨텍스트 전송 뷰와 압축 트리거를 한 단계 더 정리했습니다. + - [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)를 추가해, 저장된 전체 대화와 실제 LLM에 전송할 query view를 분리했습니다. 이 뷰는 마지막 compact boundary부터만 다시 보내고, 오래된 `tool_result`는 전송 직전에만 budget 기준으로 더 줄입니다. + - 같은 helper에서 `tool_result`가 남아 있는 kept range를 검사해, 대응되는 assistant `_tool_use_blocks`가 잘리지 않도록 window start를 뒤로 보정합니다. + - [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 각 반복에서 `messagesForQuery`에 해당하는 전송 뷰를 만든 뒤 `SendWithToolsWithRecoveryAsync()`와 텍스트 fallback 호출에 사용하도록 바꿨습니다. + - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `triggerPercent`만 보던 기준에서 `effective context window - output reserve - buffer` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c8037fe..aef6f3a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -568,3 +568,20 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - 긴 tool/thinking 이벤트가 연속으로 들어와도 창 전체가 끝날 때까지 얼어붙는 느낌이 줄어듭니다. - transcript 저장과 실행 이력 반영이 한 번만 일어나, 실행 길이가 길수록 커지던 UI 스레드 부담이 완화됩니다. +## claw-code식 컨텍스트 전송 뷰 / 압축 트리거 정리 (2026-04-12 21:34 KST) + +- `claw-code`의 `query.ts`, `autoCompact.ts`, `sessionMemoryCompact.ts`를 다시 대조해 AX도 저장용 전체 대화와 실제 API 전송용 컨텍스트 뷰를 분리하기 시작했습니다. +- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` + - `messagesForQuery` 역할의 전송 전용 view builder를 추가했습니다. + - 마지막 compact boundary(`microcompact_boundary`, `session_memory_compaction`, `collapsed_boundary`, 이전 대화 요약)부터만 다시 보내도록 window start를 계산합니다. + - kept range에 `tool_result`가 남아 있는데 대응 `assistant _tool_use_blocks`가 잘릴 수 있는 경우, start index를 뒤로 보정해 pair invariant를 유지합니다. + - 오래된 `tool_result`는 원본 `messages`를 바꾸지 않고 query view에서만 별도 budget으로 축약합니다. +- `src/AxCopilot/Services/Agent/AgentLoopService.cs` + - 각 반복에서 `_pendingUserMessages` 주입 뒤 `AgentQueryContextBuilder.Build(messages)`를 호출해 `queryMessages`를 만들고, 이를 메인 LLM 호출과 텍스트 fallback에 사용하도록 바꿨습니다. + - workflow 로그에는 `query_view` 전이를 추가해 source/view 메시지 수, preserved pair 수, tool_result budget 적용 수, budget 전후 토큰을 확인할 수 있게 했습니다. +- `src/AxCopilot/Services/Agent/ContextCondenser.cs` + - 모델 한도 계산을 `triggerPercent` 단독 기준에서 `effective context window - summary reserve - auto compact buffer` 구조를 함께 반영하도록 변경했습니다. + - `SummaryReserveTokens=20_000`, `AutoCompactBufferTokens=13_000` 기준을 두어 `claw-code`의 auto-compact headroom 계산에 더 가깝게 맞췄습니다. +- 부가 정리 + - `src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs`의 미사용 필드를 제거해 빌드 경고를 없앴습니다. + diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 4115b91..6367c2b 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -36,6 +36,16 @@ public partial class AgentLoopService }; private readonly ConcurrentDictionary _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase); + /// 실행 중 사용자 메시지 주입 큐 (Claude Code 스타일 mid-execution steering). + private readonly ConcurrentQueue _pendingUserMessages = new(); + + /// 실행 중인 에이전트 루프에 사용자 메시지를 주입합니다. 다음 LLM 호출 전에 반영됩니다. + public void InjectUserMessage(string message) + { + if (!string.IsNullOrWhiteSpace(message)) + _pendingUserMessages.Enqueue(message); + } + /// 에이전트 이벤트 스트림 (UI 바인딩용). public ObservableCollection Events { get; } = new(); @@ -59,6 +69,7 @@ public partial class AgentLoopService /// 문서 생성 폴백 재시도 여부 (루프당 1회만). private bool _docFallbackAttempted; + private bool _documentPlanApproved; private string _currentRunId = ""; private bool _runPendingPostCompactionTurn; private int _runPostCompactionTurnCounter; @@ -167,6 +178,8 @@ public partial class AgentLoopService IsRunning = true; _currentRunId = Guid.NewGuid().ToString("N"); _docFallbackAttempted = false; + _documentPlanApproved = false; + while (_pendingUserMessages.TryDequeue(out _)) { } // 이전 실행의 잔여 메시지 제거 var llm = _settings.Settings.Llm; var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : 25; var maxIterations = baseMax; // 동적 조정 가능 @@ -222,6 +235,7 @@ public partial class AgentLoopService string? documentPlanTitle = null; // document_plan이 제안한 문서 제목 string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적 + var statsModifiedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적 var taskType = ClassifyTaskType(userQuery, ActiveTab); var taskPolicy = TaskTypePolicy.FromTaskType(taskType); var executionPolicy = _llm.GetActiveExecutionPolicy(); @@ -261,6 +275,9 @@ public partial class AgentLoopService if (!executionPolicy.ReduceEarlyMemoryPressure) InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy); var runtimeOverrides = ResolveSkillRuntimeOverrides(messages); + // 스킬이 allowed-tools를 명시하면 탐색 필터링(DirectCreation의 folder_map 차단 등)을 비활성화 + if (runtimeOverrides != null && runtimeOverrides.AllowedToolNames.Count > 0) + explorationState.SkillAllowedToolsActive = true; var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides); var runtimeOverrideApplied = false; string? lastUserPromptHookFingerprint = null; @@ -395,7 +412,35 @@ public partial class AgentLoopService lastToolResultToolName = null; } - EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"); + // ── 실행 중 사용자 메시지 주입 (Claude Code 스타일 steering) ── + while (_pendingUserMessages.TryDequeue(out var injectedMsg)) + { + messages.Add(new ChatMessage { Role = "user", Content = injectedMsg }); + EmitEvent(AgentEventType.UserMessage, "", injectedMsg); + } + + var queryView = AgentQueryContextBuilder.Build(messages); + var queryMessages = queryView.Messages; + if (queryView.BoundaryApplied || queryView.ToolPairExpanded || queryView.TruncatedToolResultCount > 0) + { + var queryViewSummary = + $"query-view {queryView.SourceMessageCount}->{queryView.ViewMessageCount}, " + + $"start={queryView.WindowStartIndex}, " + + $"pairs={queryView.PreservedToolPairCount}, " + + $"tool_result_budget={queryView.TruncatedToolResultCount}, " + + $"tokens {queryView.TokensBeforeBudget}->{queryView.TokensAfterBudget}"; + WorkflowLogService.LogTransition( + _conversationId, + _currentRunId, + iteration, + "query_view", + queryViewSummary); + } + + var isDebugLog = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase); + EmitEvent(AgentEventType.Thinking, "", isDebugLog + ? $"LLM에 요청 중... (반복 {iteration}/{maxIterations})" + : "LLM에 요청 중..."); // Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) var activeService = (_settings.Settings.Llm.Service ?? "").Trim(); @@ -456,12 +501,12 @@ public partial class AgentLoopService // IBM/Qwen 등 chatty 모델 대응: 첫 번째 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder 주입. // recovery 메시지가 이미 추가된 경우(NoToolCallLoopRetry > 0)에는 중복 주입하지 않음. // 임시 메시지이므로 실제 messages 목록은 수정하지 않고, 별도 sendMessages로 전달. - List sendMessages = messages; + List sendMessages = queryMessages; if (forceFirst && executionPolicy.InjectPreCallToolReminder && runState.NoToolCallLoopRetry == 0) { - sendMessages = [.. messages, new ChatMessage + sendMessages = [.. queryMessages, new ChatMessage { Role = "user", Content = "[TOOL_REQUIRED] 지금 즉시 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" + @@ -535,16 +580,26 @@ public partial class AgentLoopService catch (NotSupportedException) { // Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체 - var textResp = await _llm.SendAsync(messages, ct); + var textResp = await _llm.SendAsync(queryMessages, ct); + EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return textResp; } catch (ToolCallNotSupportedException ex) { - // 서버가 도구 호출을 400으로 거부 → 도구 없이 일반 응답으로 폴백 - LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: {ex.Message}"); + // 서버가 도구 호출을 400으로 거부 → 텍스트 기반 도구 호출 시도 후 폴백 + LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 텍스트 기반 폴백 시도: {ex.Message}"); EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…"); - // document_plan이 완료됐지만 html_create 미실행 → 조기 종료 전에 앱이 직접 생성 + // ── 1단계: 텍스트 기반 도구 호출 폴백 (프롬프트에 도구 설명 임베딩) ── + var textFallbackResult = await TryTextBasedToolCallFallbackAsync( + messages, cachedActiveTools, context, ct); + if (!string.IsNullOrEmpty(textFallbackResult)) + { + EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); + return textFallbackResult; + } + + // ── 2단계: document_plan 직접 생성 폴백 ── if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; @@ -603,9 +658,11 @@ public partial class AgentLoopService } } + // ── 3단계: 순수 텍스트 폴백 ── try { - var textResp = await _llm.SendAsync(messages, ct); + var textResp = await _llm.SendAsync(queryMessages, ct); + EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return textResp; } catch (Exception fallbackEx) @@ -664,9 +721,8 @@ public partial class AgentLoopService EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", steps: planSteps); - // 플랜 모드 "auto"에서만 승인 대기 - // - auto: 계획 감지 시 승인 대기 (단, 도구 호출이 함께 있으면 이미 실행 중이므로 스킵) - // - off/always: 승인창 띄우지 않음 (off=자동 진행, always=앞에서 이미 처리됨) + // 일반 계획 승인은 비활성화 — document_plan 도구 실행 시에만 승인 요청 + // 코드 작업 등에서 LLM이 번호 매긴 텍스트를 반환해도 불필요한 승인 방지 const bool requireApproval = false; if (requireApproval && UserDecisionCallback != null) @@ -973,7 +1029,9 @@ public partial class AgentLoopService if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); + EmitEvent(AgentEventType.Complete, "", + BuildCompletionSummary(iteration, totalToolCalls, statsSuccessCount, statsFailCount, lastArtifactFilePath, statsUsedTools, statsModifiedFiles), + filePath: lastArtifactFilePath); return textResponse; } @@ -1516,7 +1574,11 @@ public partial class AgentLoopService lastFailedToolSignature = null; repeatedFailedToolSignatureCount = 0; if (!string.IsNullOrWhiteSpace(result.FilePath)) + { lastArtifactFilePath = result.FilePath; + if (IsFileModifyingTool(effectiveCall.ToolName)) + statsModifiedFiles.Add(result.FilePath); + } // 도구 결과를 LLM에 피드백 messages.Add(LlmService.CreateToolResultMessage( @@ -1575,6 +1637,46 @@ public partial class AgentLoopService ref documentPlanTitle, ref documentPlanScaffold); + // document_plan 도구 실행 후 계획 승인 대기 — 비활성화 (사용자 요청) + // 문서 작업 시 자동으로 계획창이 나오지 않도록 함 + if (false && string.Equals(effectiveCall.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase) + && result.Success + && UserDecisionCallback != null + && !_documentPlanApproved) + { + var planOutput = result.Output ?? string.Empty; + var planStepsFromTool = ExtractDocumentPlanSections(planOutput); + + EmitEvent(AgentEventType.Decision, "", $"문서 계획 확인 대기 · {planStepsFromTool.Count}개 섹션", + steps: planStepsFromTool); + + var planDecision = await UserDecisionCallback( + planOutput, + new List { "승인", "수정 요청", "취소" }); + + if (string.Equals(planDecision, "취소", StringComparison.OrdinalIgnoreCase)) + { + EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다"); + return "작업이 중단되었습니다."; + } + + // 승인 완료 — 이후 루프에서 다시 물어보지 않음 + _documentPlanApproved = true; + + if (planDecision != null + && !string.Equals(planDecision, "승인", StringComparison.OrdinalIgnoreCase) + && !string.Equals(planDecision, "확인", StringComparison.OrdinalIgnoreCase) + && !TryParseApprovedPlanDecision(planDecision, out _, out _)) + { + // 수정 요청 — 사용자 피드백을 메시지에 추가 + _documentPlanApproved = false; // 수정 시 재승인 필요 + messages.Add(new ChatMessage { Role = "user", Content = planDecision }); + EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중..."); + continue; // 루프 재시작 + } + // 승인 — 그대로 진행 + } + var (terminalCompleted, consumedExtraIteration) = await TryHandleTerminalDocumentCompletionTransitionAsync( effectiveCall, result, @@ -2240,6 +2342,14 @@ public partial class AgentLoopService or "pptx_create" or "document_assemble" or "csv_create"; } + /// 파일을 생성하거나 수정하는 도구인지 확인합니다. + private static bool IsFileModifyingTool(string toolName) + { + return toolName is "file_write" or "file_edit" or "file_manage" or "script_create" + or "html_create" or "docx_create" or "excel_create" or "csv_create" + or "pptx_create" or "markdown_create" or "chart_create" or "document_assemble"; + } + /// 코드 생성/수정 도구인지 확인합니다 (Code 검증 대상). private static bool IsCodeVerificationTarget(string toolName) { @@ -3647,6 +3757,180 @@ public partial class AgentLoopService .ToList(); } + /// + /// 서버가 OpenAI tools 파라미터를 거부(400)할 때, 프롬프트에 도구 설명을 임베딩하고 + /// <tool_call> 형식으로 출력하게 유도하여 텍스트 기반 도구 호출을 시도합니다. + /// + private async Task TryTextBasedToolCallFallbackAsync( + List messages, + IReadOnlyCollection activeTools, + AgentContext context, + CancellationToken ct) + { + if (activeTools.Count == 0) return null; + + try + { + // 도구 설명을 텍스트로 빌드 + var toolDesc = new StringBuilder(); + toolDesc.AppendLine("# Available Tools"); + toolDesc.AppendLine("To call a tool, output EXACTLY this format (no other text before it):"); + toolDesc.AppendLine(""); + toolDesc.AppendLine("{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}"); + toolDesc.AppendLine(""); + toolDesc.AppendLine(); + foreach (var tool in activeTools) + { + toolDesc.AppendLine($"## {tool.Name}"); + toolDesc.AppendLine($"{tool.Description}"); + if (tool.Parameters?.Properties != null) + { + toolDesc.AppendLine("Parameters:"); + foreach (var (pName, pSchema) in tool.Parameters.Properties) + { + var required = tool.Parameters.Required?.Contains(pName) == true ? " (required)" : ""; + toolDesc.AppendLine($" - {pName} ({pSchema.Type}): {pSchema.Description}{required}"); + } + } + toolDesc.AppendLine(); + } + + // 메시지 복사 후 도구 설명을 user 메시지로 추가 + var enhancedMessages = new List(messages); + enhancedMessages.Add(new ChatMessage + { + Role = "user", + Content = $"[SYSTEM] The API tool-calling endpoint is unavailable. " + + $"You MUST call tools by outputting tags as shown below.\n\n" + + $"{toolDesc}\n" + + $"Now call the appropriate tool(s) using format to complete the task." + }); + + EmitEvent(AgentEventType.Thinking, "", "텍스트 기반 도구 호출로 재시도 중…"); + var textResp = await _llm.SendAsync(enhancedMessages, ct); + if (string.IsNullOrEmpty(textResp)) return null; + + // 태그에서 도구 호출 파싱 + var extractedCalls = LlmService.TryExtractToolCallsFromText(textResp); + if (extractedCalls.Count == 0) + { + LogService.Debug("[AgentLoop] 텍스트 기반 폴백: 태그를 찾지 못함"); + return null; + } + + LogService.Info($"[AgentLoop] 텍스트 기반 폴백: {extractedCalls.Count}개 도구 호출 파싱 성공"); + + // 파싱된 도구 호출 실행 + var results = new StringBuilder(); + string? lastFilePath = null; + foreach (var call in extractedCalls) + { + var tool = activeTools.FirstOrDefault(t => + string.Equals(t.Name, call.ToolName, StringComparison.OrdinalIgnoreCase)); + if (tool == null) + { + EmitEvent(AgentEventType.Error, call.ToolName ?? "", $"알 수 없는 도구: {call.ToolName}"); + continue; + } + + EmitEvent(AgentEventType.ToolCall, tool.Name, $"텍스트 기반 도구 호출: {tool.Name}"); + + // 권한 확인 + var permResult = await EnforceToolPermissionAsync(tool.Name, call.ToolInput ?? default, context, messages); + if (permResult != null) + { + EmitEvent(AgentEventType.ToolResult, tool.Name, $"⚠ 권한 거부: {tool.Name}"); + continue; + } + + var toolResult = await tool.ExecuteAsync(call.ToolInput ?? default, context, ct); + var summary = TruncateOutput(toolResult.Output, 300); + EmitEvent( + AgentEventType.ToolResult, + tool.Name, + toolResult.Success ? $"✅ {tool.Name}: {summary}" : $"❌ {tool.Name}: {summary}", + filePath: toolResult.FilePath); + + if (toolResult.Success) + { + results.AppendLine(toolResult.Output); + if (!string.IsNullOrEmpty(toolResult.FilePath)) + lastFilePath = toolResult.FilePath; + } + else + { + results.AppendLine($"⚠ {tool.Name} 실패: {toolResult.Output}"); + } + } + + if (results.Length == 0) return null; + + // 도구 실행 결과를 LLM에 보내서 최종 응답 생성 + var finalMessages = new List(messages); + finalMessages.Add(new ChatMessage + { + Role = "user", + Content = $"[도구 실행 결과]\n{results}\n\n위 결과를 바탕으로 사용자에게 간결하게 보고하세요." + }); + + try + { + var finalResp = await _llm.SendAsync(finalMessages, ct); + return !string.IsNullOrEmpty(finalResp) ? finalResp : results.ToString(); + } + catch + { + return results.ToString(); + } + } + catch (Exception fallbackEx) + { + LogService.Warn($"[AgentLoop] 텍스트 기반 도구 호출 폴백 실패: {fallbackEx.Message}"); + return null; + } + } + + /// 정상 완료 시 작업 요약 문자열 생성. + private static string BuildCompletionSummary( + int iterations, + int totalToolCalls, + int successCount, + int failCount, + string? lastArtifactFilePath, + List usedTools, + HashSet? modifiedFiles = null) + { + var parts = new List(); + parts.Add($"반복 {iterations}회"); + if (totalToolCalls > 0) + parts.Add($"도구 {totalToolCalls}회 (성공 {successCount}, 실패 {failCount})"); + if (!string.IsNullOrEmpty(lastArtifactFilePath)) + parts.Add($"산출물: {System.IO.Path.GetFileName(lastArtifactFilePath)}"); + + // 수정/생성된 파일 목록 + if (modifiedFiles != null && modifiedFiles.Count > 0) + { + var fileNames = modifiedFiles + .Select(f => System.IO.Path.GetFileName(f)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (fileNames.Count <= 4) + parts.Add($"변경 파일: {string.Join(", ", fileNames)}"); + else + parts.Add($"변경 파일: {string.Join(", ", fileNames.Take(3))} 외 {fileNames.Count - 3}개"); + } + + if (usedTools.Count > 0) + { + var distinct = usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(); + var toolList = string.Join(", ", distinct); + if (usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Count() > 6) + toolList += $" 외 {usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Count() - 6}개"; + parts.Add($"사용 도구: {toolList}"); + } + return string.Join(" · ", parts); + } + private static string BuildIterationLimitFallbackResponse( int maxIterations, TaskTypePolicy taskPolicy, @@ -4188,7 +4472,7 @@ public partial class AgentLoopService private AgentContext BuildContext() { var llm = _settings.Settings.Llm; - var baseWorkFolder = llm.WorkFolder; + var baseWorkFolder = ResolveTabWorkFolder(llm, ActiveTab); var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder); return new AgentContext { @@ -4207,6 +4491,58 @@ public partial class AgentLoopService }; } + /// + /// 탭(Cowork/Code)별 작업 폴더를 결정합니다. + /// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백. + /// 최종 경로가 비어있으면 기본 경로를 생성합니다. + /// + private static string ResolveTabWorkFolder(LlmSettings llm, string? activeTab) + { + var isCode = string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase); + + // 탭별 전용 경로 확인 + var tabFolder = isCode ? llm.CodeWorkFolder : llm.CoworkWorkFolder; + + // 탭 전용 경로가 설정되어 있으면 사용 + if (!string.IsNullOrWhiteSpace(tabFolder)) + { + EnsureDirectoryExists(tabFolder); + return tabFolder; + } + + // 레거시 WorkFolder 폴백 + if (!string.IsNullOrWhiteSpace(llm.WorkFolder)) + { + EnsureDirectoryExists(llm.WorkFolder); + return llm.WorkFolder; + } + + // 모두 미설정 → 기본 경로 자동 생성 + var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var defaultRoot = Path.Combine(docs, "AX Agent"); + var defaultTab = Path.Combine(defaultRoot, isCode ? "Code" : "Cowork"); + EnsureDirectoryExists(defaultTab); + + // 설정에 기본값 기록 (다음 실행 시 유지) + if (isCode) + llm.CodeWorkFolder = defaultTab; + else + llm.CoworkWorkFolder = defaultTab; + + return defaultTab; + } + + /// 디렉토리가 없으면 생성합니다. + private static void EnsureDirectoryExists(string path) + { + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + catch { /* 권한 문제 등은 무시 — 이후 도구 실행에서 별도 에러 처리 */ } + } + private static string ResolveRuntimeWorkFolder(string? configuredRoot) { if (string.IsNullOrWhiteSpace(configuredRoot)) @@ -4593,6 +4929,15 @@ public partial class AgentLoopService // AgentLogLevel에 따라 이벤트 필터링 var logLevel = _settings.Settings.Llm.AgentLogLevel; + // hidden: Complete, Error, Decision, Permission, total_stats, ToolCall, ToolResult, StepDone 전달 + // (ToolCall/ToolResult/StepDone는 라이브 진행 카드용으로 필요) + if (logLevel == "hidden" && type is not (AgentEventType.Complete or AgentEventType.Error + or AgentEventType.Decision or AgentEventType.PermissionRequest + or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied + or AgentEventType.ToolCall or AgentEventType.ToolResult + or AgentEventType.StepDone or AgentEventType.StepStart)) + return; + // simple: ToolCall, ToolResult, Error, Complete, StepStart, StepDone, Decision만 if (logLevel == "simple" && type is AgentEventType.Thinking or AgentEventType.Planning) return; @@ -4602,7 +4947,8 @@ public partial class AgentLoopService summary = summary[..200] + "…"; // debug 아닌 경우 ToolInput 제거 - if (logLevel != "debug") + // hidden/simple: ToolInput 제거, detailed/debug: 유지 + if (logLevel is "hidden" or "simple") toolInput = null; var evt = new AgentEvent @@ -4655,6 +5001,14 @@ public partial class AgentLoopService var toolName = call.ToolName ?? ""; var input = call.ToolInput; + // 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략 + if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode)) + { + var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission); + if (PermissionModeCatalog.IsBypassPermissions(effectivePerm)) + return null; + } + // Git 커밋 — 수준에 관계없이 무조건 확인 if (toolName == "git_tool") { @@ -4948,4 +5302,55 @@ public partial class AgentLoopService steps: steps); } + /// document_plan 도구 결과에서 섹션 목록을 추출합니다. + private static List ExtractDocumentPlanSections(string planOutput) + { + var sections = new List(); + if (string.IsNullOrWhiteSpace(planOutput)) + { + sections.Add("문서 계획 검토"); + return sections; + } + + // 1) JSON의 "heading" 필드 추출 + var headingMatches = System.Text.RegularExpressions.Regex.Matches( + planOutput, @"""heading""\s*:\s*""([^""]+)"""); + foreach (System.Text.RegularExpressions.Match m in headingMatches) + sections.Add(m.Groups[1].Value); + + if (sections.Count >= 2) return sections; + + // 2) HTML

태그 + sections.Clear(); + var h2Matches = System.Text.RegularExpressions.Regex.Matches( + planOutput, @"

([^<]+)

"); + foreach (System.Text.RegularExpressions.Match m in h2Matches) + sections.Add(m.Groups[1].Value); + + if (sections.Count >= 2) return sections; + + // 3) Markdown ## 헤딩 + sections.Clear(); + var mdMatches = System.Text.RegularExpressions.Regex.Matches( + planOutput, @"(?:^|\n)##\s+(.+?)(?:\n|$)"); + foreach (System.Text.RegularExpressions.Match m in mdMatches) + { + var heading = m.Groups[1].Value.Trim(); + if (!heading.StartsWith("[") && !heading.Contains("즉시 실행")) + sections.Add(heading); + } + + if (sections.Count >= 2) return sections; + + // 4) 번호 매긴 단계 + sections.Clear(); + var numbered = TaskDecomposer.ExtractSteps(planOutput); + if (numbered.Count >= 2) return numbered; + + // 5) 폴백 + sections.Clear(); + sections.Add("문서 계획 검토"); + return sections; + } + } diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs new file mode 100644 index 0000000..fd3dca3 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -0,0 +1,334 @@ +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +public sealed class AgentQueryContextWindowResult +{ + public required List Messages { get; init; } + public int SourceMessageCount { get; init; } + public int ViewMessageCount { get; init; } + public int WindowStartIndex { get; init; } + public bool BoundaryApplied { get; init; } + public bool ToolPairExpanded { get; init; } + public int PreservedToolPairCount { get; init; } + public int TruncatedToolResultCount { get; init; } + public int TokensBeforeBudget { get; init; } + public int TokensAfterBudget { get; init; } +} + +/// +/// claude-code의 messagesForQuery 계층처럼, 저장된 전체 대화와 +/// 실제 LLM에 전송할 컨텍스트 뷰를 분리합니다. +/// +public static class AgentQueryContextBuilder +{ + private const int ProtectedRecentNonSystemMessages = 8; + private const int OldToolResultSoftCharLimit = 900; + private const int OldToolResultAggregateBudgetChars = 7_500; + + public static AgentQueryContextWindowResult Build(IReadOnlyList sourceMessages) + { + if (sourceMessages.Count == 0) + { + return new AgentQueryContextWindowResult + { + Messages = new List(), + SourceMessageCount = 0, + ViewMessageCount = 0, + WindowStartIndex = 0, + BoundaryApplied = false, + ToolPairExpanded = false, + PreservedToolPairCount = 0, + TruncatedToolResultCount = 0, + TokensBeforeBudget = 0, + TokensAfterBudget = 0, + }; + } + + var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied); + var adjustedStartIndex = AdjustStartIndexForToolPairs(sourceMessages, startIndex, out var preservedToolPairs); + var toolPairExpanded = adjustedStartIndex < startIndex; + + var windowMessages = new List(sourceMessages.Count); + for (var i = 0; i < sourceMessages.Count; i++) + { + var include = string.Equals(sourceMessages[i].Role, "system", StringComparison.OrdinalIgnoreCase) + || i >= adjustedStartIndex; + if (!include) + continue; + + windowMessages.Add(CloneMessage(sourceMessages[i])); + } + + var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages); + var truncatedToolResults = ApplyOldToolResultBudget(windowMessages); + var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages); + + return new AgentQueryContextWindowResult + { + Messages = windowMessages, + SourceMessageCount = sourceMessages.Count, + ViewMessageCount = windowMessages.Count, + WindowStartIndex = adjustedStartIndex, + BoundaryApplied = boundaryApplied, + ToolPairExpanded = toolPairExpanded, + PreservedToolPairCount = preservedToolPairs, + TruncatedToolResultCount = truncatedToolResults, + TokensBeforeBudget = tokensBeforeBudget, + TokensAfterBudget = tokensAfterBudget, + }; + } + + private static int FindWindowStartIndex(IReadOnlyList sourceMessages, out bool boundaryApplied) + { + for (var i = sourceMessages.Count - 1; i >= 0; i--) + { + if (IsQueryBoundaryMarker(sourceMessages[i])) + { + boundaryApplied = true; + return i; + } + } + + boundaryApplied = false; + return 0; + } + + private static bool IsQueryBoundaryMarker(ChatMessage message) + { + var metaKind = message.MetaKind ?? ""; + if (metaKind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase) + || metaKind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase) + || metaKind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var content = message.Content ?? ""; + return content.StartsWith("[이전 대화 요약", StringComparison.Ordinal) + || content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal) + || content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal) + || content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal); + } + + private static int AdjustStartIndexForToolPairs( + IReadOnlyList sourceMessages, + int startIndex, + out int preservedToolPairs) + { + preservedToolPairs = 0; + if (startIndex <= 0) + return startIndex; + + var toolResultIds = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = startIndex; i < sourceMessages.Count; i++) + { + if (!TryGetToolResultId(sourceMessages[i], out var toolResultId)) + continue; + + toolResultIds.Add(toolResultId); + } + + if (toolResultIds.Count == 0) + return startIndex; + + var toolUseIdsInWindow = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = startIndex; i < sourceMessages.Count; i++) + { + foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i])) + toolUseIdsInWindow.Add(toolUseId); + } + + toolResultIds.ExceptWith(toolUseIdsInWindow); + if (toolResultIds.Count == 0) + return startIndex; + + var adjustedStart = startIndex; + for (var i = startIndex - 1; i >= 0 && toolResultIds.Count > 0; i--) + { + var foundAny = false; + foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i])) + { + if (!toolResultIds.Remove(toolUseId)) + continue; + + preservedToolPairs++; + foundAny = true; + } + + if (foundAny) + adjustedStart = i; + } + + return adjustedStart; + } + + private static bool TryGetToolResultId(ChatMessage message, out string toolResultId) + { + toolResultId = ""; + if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)) + return false; + + var content = message.Content ?? ""; + if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)) + return false; + + try + { + using var doc = JsonDocument.Parse(content); + toolResultId = doc.RootElement.TryGetProperty("tool_use_id", out var idEl) + ? idEl.GetString() ?? "" + : ""; + return !string.IsNullOrWhiteSpace(toolResultId); + } + catch + { + return false; + } + } + + private static IEnumerable EnumerateToolUseIds(ChatMessage message) + { + if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + yield break; + + var content = message.Content ?? ""; + if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)) + yield break; + + JsonDocument? doc = null; + try + { + doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array) + yield break; + + foreach (var block in blocksEl.EnumerateArray()) + { + if (!block.TryGetProperty("type", out var typeEl) + || !string.Equals(typeEl.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (block.TryGetProperty("id", out var idEl)) + { + var toolUseId = idEl.GetString(); + if (!string.IsNullOrWhiteSpace(toolUseId)) + yield return toolUseId!; + } + } + } + finally + { + doc?.Dispose(); + } + } + + private static int ApplyOldToolResultBudget(List messages) + { + var nonSystemIndexes = messages + .Select((message, index) => new { message, index }) + .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.index) + .ToList(); + + if (nonSystemIndexes.Count <= ProtectedRecentNonSystemMessages) + return 0; + + var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - ProtectedRecentNonSystemMessages)]; + var spentChars = 0; + var truncatedCount = 0; + + for (var i = 0; i < protectedStart; i++) + { + var message = messages[i]; + if (!TryGetToolResultId(message, out _)) + continue; + + var content = message.Content ?? ""; + if (string.IsNullOrWhiteSpace(content)) + continue; + + spentChars += content.Length; + if (content.Length <= OldToolResultSoftCharLimit && spentChars <= OldToolResultAggregateBudgetChars) + continue; + + var truncated = TruncateToolResultJson(content); + if (string.Equals(truncated, content, StringComparison.Ordinal)) + continue; + + messages[i] = CloneMessage(message, truncated); + truncatedCount++; + } + + return truncatedCount; + } + + private static string TruncateToolResultJson(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var toolType = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : ""; + if (!string.Equals(toolType, "tool_result", StringComparison.Ordinal)) + return json; + + var toolUseId = root.TryGetProperty("tool_use_id", out var idEl) ? idEl.GetString() ?? "" : ""; + var toolName = root.TryGetProperty("tool_name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var content = root.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : ""; + if (content.Length <= OldToolResultSoftCharLimit) + return json; + + var keepHead = Math.Min(360, content.Length); + var keepTail = Math.Min(220, Math.Max(0, content.Length - keepHead)); + var head = content[..keepHead]; + var tail = keepTail > 0 ? content[^keepTail..] : ""; + var compacted = head + + $"\n...[query-view tool_result 축약: {content.Length:N0}자]...\n" + + tail; + + return JsonSerializer.Serialize(new + { + type = "tool_result", + tool_use_id = toolUseId, + tool_name = toolName, + content = compacted + }); + } + catch + { + if (json.Length <= OldToolResultSoftCharLimit) + return json; + + var head = json[..Math.Min(OldToolResultSoftCharLimit, json.Length)]; + return head + "...[query-view 축약됨]"; + } + } + + private static ChatMessage CloneMessage(ChatMessage source, string? contentOverride = null) + { + return new ChatMessage + { + MsgId = source.MsgId, + Role = source.Role, + Content = contentOverride ?? 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(), + Images = source.Images?.Select(image => new ImageAttachment + { + Base64 = image.Base64, + MimeType = image.MimeType, + FileName = image.FileName, + }).ToList(), + }; + } +} diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 1e95698..5e39f64 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -40,8 +40,10 @@ public static class ContextCondenser /// 요약 시 유지할 최근 메시지 수 private const int RecentKeepCount = 6; + private const int AutoCompactBufferTokens = 13_000; + private const int SummaryReserveTokens = 20_000; - /// 모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용. + /// 모델별 입력 토큰 한도 (대략). private static int GetModelInputLimit(string service, string model) { var key = $"{service}:{model}".ToLowerInvariant(); @@ -59,6 +61,13 @@ public static class ContextCondenser }; } + private static int GetEffectiveContextWindowSize(string service, string model, int configuredLimit) + { + var contextWindow = configuredLimit > 0 ? configuredLimit : GetModelInputLimit(service, model); + var reservedForSummary = Math.Min(SummaryReserveTokens, Math.Max(4_000, contextWindow / 8)); + return Math.Max(8_000, contextWindow - reservedForSummary); + } + /// /// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다. /// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음) @@ -67,7 +76,7 @@ public static class ContextCondenser /// public static async Task CondenseIfNeededAsync( List messages, - LlmService llm, + ILlmService llm, int maxOutputTokens, bool proactiveEnabled = true, int triggerPercent = 80, @@ -80,7 +89,7 @@ public static class ContextCondenser public static async Task CondenseWithStatsAsync( List messages, - LlmService llm, + ILlmService llm, int maxOutputTokens, bool proactiveEnabled = true, int triggerPercent = 80, @@ -94,10 +103,11 @@ public static class ContextCondenser // 현재 모델의 입력 토큰 한도 var settings = llm.GetCurrentModelInfo(); // 사용자가 설정한 컨텍스트 크기를 우선 사용. 미설정 시 모델별 기본값 적용. - var inputLimit = GetModelInputLimit(settings.service, settings.model); - var effectiveMax = maxOutputTokens > 0 ? maxOutputTokens : inputLimit; + var effectiveWindow = GetEffectiveContextWindowSize(settings.service, settings.model, maxOutputTokens); var percent = Math.Clamp(triggerPercent, 50, 95); - var threshold = (int)(effectiveMax * (percent / 100.0)); // 설정 임계치에서 압축 시작 + var percentThreshold = (int)(effectiveWindow * (percent / 100.0)); + var bufferedThreshold = Math.Max(4_000, effectiveWindow - AutoCompactBufferTokens); + var threshold = Math.Min(percentThreshold, bufferedThreshold); var currentTokens = TokenEstimator.EstimateMessages(messages); result.BeforeTokens = currentTokens; @@ -668,7 +678,7 @@ public static class ContextCondenser /// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다. /// private static async Task SummarizeOldMessagesAsync( - List messages, LlmService llm, CancellationToken ct) + List messages, ILlmService llm, CancellationToken ct) { var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var systemCount = systemMsg != null ? 1 : 0; diff --git a/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs b/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs index 5f943dd..d47c8a9 100644 --- a/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs @@ -13,7 +13,6 @@ namespace AxCopilot.Views; public partial class ChatWindow { // 레이아웃 재계산 억제용 캐시: 동일한 높이면 WPF measure/arrange 생략 - private double _cachedInputBoxHeight = -1; private int _cachedInputBoxMaxLines = -1; private void UpdateInputBoxHeight() @@ -21,8 +20,6 @@ public partial class ChatWindow if (InputBox == null) return; - var text = InputBox.Text ?? string.Empty; - var explicitLineCount = 1 + text.Count(ch => ch == '\n'); var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); if (displayMode is not ("rich" or "balanced" or "simple")) displayMode = "balanced"; @@ -33,25 +30,18 @@ public partial class ChatWindow "simple" => 4, _ => 5, }; - const double baseHeight = 42; - const double lineStep = 22; - var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines); - var targetHeight = baseHeight + ((visibleLines - 1) * lineStep); - var needsScroll = explicitLineCount > maxLines; - // 값이 바뀐 경우에만 WPF 속성 쓰기 (매 키입력마다 레이아웃 통과 방지) - if (Math.Abs(targetHeight - _cachedInputBoxHeight) < 0.5 && maxLines == _cachedInputBoxMaxLines) + // MaxLines로 시각적 줄 수 제한 (TextWrapping에 의한 자동 줄바꿈 포함) + // Height를 Auto로 두고 MaxLines + MaxHeight가 자연스럽게 크기를 제어 + if (maxLines == _cachedInputBoxMaxLines) return; - _cachedInputBoxHeight = targetHeight; _cachedInputBoxMaxLines = maxLines; InputBox.MinLines = 1; InputBox.MaxLines = maxLines; - InputBox.Height = targetHeight; - InputBox.VerticalScrollBarVisibility = needsScroll - ? ScrollBarVisibility.Auto - : ScrollBarVisibility.Disabled; + InputBox.Height = double.NaN; // Auto — WPF가 TextWrapping + MaxLines 기반으로 자동 계산 + InputBox.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; } private string BuildComposerDraftText() @@ -89,13 +79,44 @@ public partial class ChatWindow if (InputBox == null) return; + // 코워크/코드 탭에서 작업 폴더 미지정 시 전송 차단 + if (_activeTab is "Cowork" or "Code" && string.IsNullOrWhiteSpace(GetCurrentWorkFolder())) + { + HighlightFolderSelectButton(); + return; + } + var text = BuildComposerDraftText(); if (string.IsNullOrWhiteSpace(text)) return; - // 현재 탭이 스트리밍 중일 때만 우선순위를 "next"로 낮춤 — 다른 탭 스트리밍은 무관 - if (_streamingTabs.Contains(_activeTab) && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase)) - priority = "next"; + // ── 실행 중 메시지 주입 (Claude Code 스타일) ── + // 현재 탭이 스트리밍 중이면 에이전트 루프에 직접 메시지 주입 + if (_streamingTabs.Contains(_activeTab)) + { + HideSlashChip(restoreText: false); + ClearPromptCardPlaceholder(); + + // 에이전트 루프에 메시지 주입 + if (_agentLoops.TryGetValue(_activeTab, out var activeLoop)) + activeLoop.InjectUserMessage(text); + + // 현재 대화에 사용자 메시지 기록 + lock (_convLock) + { + _currentConversation?.Messages.Add(new ChatMessage { Role = "user", Content = text }); + } + + // UI에 사용자 메시지 버블 표시 + RenderMessages(); + + InputBox.Clear(); + InputBox.Focus(); + UpdateInputBoxHeight(); + + ShowToast("메시지가 실행 중인 에이전트에 전달되었습니다.", "\uE8FB"); + return; + } HideSlashChip(restoreText: false); ClearPromptCardPlaceholder(); @@ -248,7 +269,7 @@ public partial class ChatWindow var stateIcon = new TextBlock { Text = GetDraftStateIcon(item), - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 11, Foreground = GetDraftStateIconBrush(item), VerticalAlignment = VerticalAlignment.Center, @@ -326,7 +347,7 @@ public partial class ChatWindow new TextBlock { Text = kindIcon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 10, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, @@ -352,7 +373,7 @@ public partial class ChatWindow Content = new TextBlock { Text = icon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontFamily = s_segoeIconFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,