컨텍스트 전송 뷰와 압축 트리거를 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:
15
README.md
15
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` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다.
|
||||
|
||||
@@ -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`의 미사용 필드를 제거해 빌드 경고를 없앴습니다.
|
||||
|
||||
|
||||
@@ -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)할 때, 프롬프트에 도구 설명을 임베딩하고
|
||||
/// <tool_call> 형식으로 출력하게 유도하여 텍스트 기반 도구 호출을 시도합니다.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
334
src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
Normal file
334
src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user