컨텍스트 전송 뷰와 압축 트리거를 claw-code 기준으로 정리

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
This commit is contained in:
2026-04-12 21:36:50 +09:00
parent 9175dfe657
commit 0f83dc802c
6 changed files with 844 additions and 42 deletions

View File

@@ -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` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다.

View File

@@ -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`의 미사용 필드를 제거해 빌드 경고를 없앴습니다.

View File

@@ -36,6 +36,16 @@ public partial class AgentLoopService
};
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>실행 중 사용자 메시지 주입 큐 (Claude Code 스타일 mid-execution steering).</summary>
private readonly ConcurrentQueue<string> _pendingUserMessages = new();
/// <summary>실행 중인 에이전트 루프에 사용자 메시지를 주입합니다. 다음 LLM 호출 전에 반영됩니다.</summary>
public void InjectUserMessage(string message)
{
if (!string.IsNullOrWhiteSpace(message))
_pendingUserMessages.Enqueue(message);
}
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
public ObservableCollection<AgentEvent> Events { get; } = new();
@@ -59,6 +69,7 @@ public partial class AgentLoopService
/// <summary>문서 생성 폴백 재시도 여부 (루프당 1회만).</summary>
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<string>(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<ChatMessage> sendMessages = messages;
List<ChatMessage> sendMessages = queryMessages;
if (forceFirst
&& executionPolicy.InjectPreCallToolReminder
&& runState.NoToolCallLoopRetry == 0)
{
sendMessages = [.. messages, new ChatMessage
sendMessages = [.. queryMessages, new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\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<string> { "승인", "수정 요청", "취소" });
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";
}
/// <summary>파일을 생성하거나 수정하는 도구인지 확인합니다.</summary>
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";
}
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
private static bool IsCodeVerificationTarget(string toolName)
{
@@ -3647,6 +3757,180 @@ public partial class AgentLoopService
.ToList();
}
/// <summary>
/// 서버가 OpenAI tools 파라미터를 거부(400)할 때, 프롬프트에 도구 설명을 임베딩하고
/// &lt;tool_call&gt; 형식으로 출력하게 유도하여 텍스트 기반 도구 호출을 시도합니다.
/// </summary>
private async Task<string?> TryTextBasedToolCallFallbackAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> 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("<tool_call>");
toolDesc.AppendLine("{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}");
toolDesc.AppendLine("</tool_call>");
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<ChatMessage>(messages);
enhancedMessages.Add(new ChatMessage
{
Role = "user",
Content = $"[SYSTEM] The API tool-calling endpoint is unavailable. " +
$"You MUST call tools by outputting <tool_call> tags as shown below.\n\n" +
$"{toolDesc}\n" +
$"Now call the appropriate tool(s) using <tool_call> format to complete the task."
});
EmitEvent(AgentEventType.Thinking, "", "텍스트 기반 도구 호출로 재시도 중…");
var textResp = await _llm.SendAsync(enhancedMessages, ct);
if (string.IsNullOrEmpty(textResp)) return null;
// <tool_call> 태그에서 도구 호출 파싱
var extractedCalls = LlmService.TryExtractToolCallsFromText(textResp);
if (extractedCalls.Count == 0)
{
LogService.Debug("[AgentLoop] 텍스트 기반 폴백: <tool_call> 태그를 찾지 못함");
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<ChatMessage>(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;
}
}
/// <summary>정상 완료 시 작업 요약 문자열 생성.</summary>
private static string BuildCompletionSummary(
int iterations,
int totalToolCalls,
int successCount,
int failCount,
string? lastArtifactFilePath,
List<string> usedTools,
HashSet<string>? modifiedFiles = null)
{
var parts = new List<string>();
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
};
}
/// <summary>
/// 탭(Cowork/Code)별 작업 폴더를 결정합니다.
/// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백.
/// 최종 경로가 비어있으면 기본 경로를 생성합니다.
/// </summary>
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;
}
/// <summary>디렉토리가 없으면 생성합니다.</summary>
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);
}
/// <summary>document_plan 도구 결과에서 섹션 목록을 추출합니다.</summary>
private static List<string> ExtractDocumentPlanSections(string planOutput)
{
var sections = new List<string>();
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 <h2> 태그
sections.Clear();
var h2Matches = System.Text.RegularExpressions.Regex.Matches(
planOutput, @"<h2>([^<]+)</h2>");
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;
}
}

View File

@@ -0,0 +1,334 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public sealed class AgentQueryContextWindowResult
{
public required List<ChatMessage> 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; }
}
/// <summary>
/// claude-code의 messagesForQuery 계층처럼, 저장된 전체 대화와
/// 실제 LLM에 전송할 컨텍스트 뷰를 분리합니다.
/// </summary>
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<ChatMessage> sourceMessages)
{
if (sourceMessages.Count == 0)
{
return new AgentQueryContextWindowResult
{
Messages = new List<ChatMessage>(),
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<ChatMessage>(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<ChatMessage> 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<ChatMessage> sourceMessages,
int startIndex,
out int preservedToolPairs)
{
preservedToolPairs = 0;
if (startIndex <= 0)
return startIndex;
var toolResultIds = new HashSet<string>(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<string>(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<string> 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<ChatMessage> 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(),
};
}
}

View File

@@ -40,8 +40,10 @@ public static class ContextCondenser
/// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6;
private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000;
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</summary>
/// <summary>모델별 입력 토큰 한도 (대략).</summary>
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);
}
/// <summary>
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
@@ -67,7 +76,7 @@ public static class ContextCondenser
/// </summary>
public static async Task<bool> CondenseIfNeededAsync(
List<ChatMessage> 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<ContextCompactionResult> CondenseWithStatsAsync(
List<ChatMessage> 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개는 유지하고, 나머지를 요약으로 교체합니다.
/// </summary>
private static async Task<bool> SummarizeOldMessagesAsync(
List<ChatMessage> messages, LlmService llm, CancellationToken ct)
List<ChatMessage> messages, ILlmService llm, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var systemCount = systemMsg != null ? 1 : 0;

View File

@@ -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,