claude-code 기준 provider 호환성과 compact 후속 흐름을 보강한다

- OpenAI 호환 tool_choice 400 오류에 대한 일반 fallback을 추가하고 Qwen·LLaMA·DeepSeek 계열 vLLM의 도구 호출 프로파일을 더 보수적으로 조정

- compact 이후 branch context와 최근 tool state를 query view에 재주입하고 UI 표현 수준에 맞춰 compact 카드/컨텍스트 사용 팝업/최종 보고 밀도를 세분화

- README와 DEVELOPMENT 문서 이력을 2026-04-12 23:45 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 22:32:40 +09:00
parent 58b798d3e4
commit da11029284
7 changed files with 215 additions and 27 deletions

View File

@@ -7,6 +7,12 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서 개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md` `docs/claw-code-parity-plan.md`
- 업데이트: 2026-04-12 23:45 (KST)
- `claude-code` 기준으로 남아 있던 provider/compact/UI 후속 차이를 더 줄였습니다. OpenAI 호환 도구 호출은 이제 모델 계열별 호환 프로파일을 적용해 Qwen·LLaMA·DeepSeek 같은 취약한 vLLM 계열에서 최근 structured tool history 범위를 더 좁히고, 병렬 tool call과 reasoning_effort도 더 보수적으로 전송합니다.
- tool forcing fallback도 보강했습니다. IBM 배포형뿐 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면, plain text 지시를 덧붙인 대체 요청으로 한 번 더 재시도해 `채팅은 되지만 Cowork/Code만 막히는` 조합을 줄였습니다.
- compact 이후 query view에는 복원된 branch context와 최근 tool state까지 함께 다시 주입합니다. UI는 표현 수준(`rich/balanced/simple`)에 맞춰 compact 카드와 컨텍스트 사용 팝업 밀도를 다르게 보여주고, 최종 보고 프롬프트도 같은 수준에 맞춰 더 짧거나 더 구조적으로 정리되도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-10 14:30 (KST) - 업데이트: 2026-04-10 14:30 (KST)
- **UI 프리징 근본 수정**: 스트리밍 중 렌더링 쓰로틀(1.5초 최소 간격), 이중 RenderMessages 제거, 타이머 Stop→Start 무한 루프 차단, 불필요 타이머 4개 일시 정지, 타이머 간격 2-10배 증가(350ms→5s, 500ms→2s 등). 에이전트 이벤트 디스패처 우선순위를 Normal→Background로 하향. - **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`로 변경하여 프로파일 매칭 실패 방지. - **모델 프로파일 도구 사용 버그 수정**: Ollama 모델에 `tool_choice: "required"` 미전달 버그 수정 — `BuildOpenAiToolBody`에서 Ollama 조기 리턴 전에 `tool_choice` 주입. `FindRegisteredModel`의 대소문자 민감 비교를 `OrdinalIgnoreCase`로 변경하여 프로파일 매칭 실패 방지.

View File

@@ -734,3 +734,12 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- compact 뒤 첫 LLM 호출이 복원된 맥락의 종류를 더 안정적으로 전달받습니다. - compact 뒤 첫 LLM 호출이 복원된 맥락의 종류를 더 안정적으로 전달받습니다.
- transcript와 usage UI가 `claw-code`처럼 더 얇고 조용한 운영 메타 표현을 유지합니다. - transcript와 usage UI가 `claw-code`처럼 더 얇고 조용한 운영 메타 표현을 유지합니다.
## claude-code식 provider/compact/UI 후속 정렬
- 업데이트: 2026-04-12 23:45 (KST)
- `LlmService.ToolUse`에 OpenAI 호환 provider별 도구 호출 호환 프로파일을 추가했습니다. Qwen·LLaMA·DeepSeek 계열 vLLM은 최근 structured tool history 범위를 더 작게 잡고, `parallel_tool_calls``reasoning_effort` 전송을 더 보수적으로 사용합니다.
- OpenAI 호환 도구 호출 재시도도 확장했습니다. IBM 배포형만이 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면 `tool_choice` 없이 plain-text tool-only 지시를 덧붙인 fallback body로 한 번 더 재시도합니다.
- `AgentQueryContextBuilder`의 post-compact context에는 compact summary, file/image refs 외에 `branch_context`와 최근 tool state를 함께 싣도록 보강했습니다. compact 직후 첫 턴에서 요약/분기/도구 맥락이 더 자연스럽게 이어지도록 맞춘 변경입니다.
- `AgentLoopService`의 최종 보고 프롬프트는 `AgentUiExpressionLevel`을 반영합니다. `simple`은 매우 짧은 종료 보고, `balanced`는 기본 요약, `rich`는 review/high-impact 중심의 구조화 보고로 밀도를 달리합니다.
- `ChatWindow.TimelinePresentation`, `ChatWindow.ContextUsagePresentation`도 표현 수준에 맞춰 compact 메타 노출량을 다르게 조정했습니다. `simple`은 짧은 한 줄 위주, `rich`는 필요한 경우 compact preview를 덧붙여 보여줍니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0

View File

@@ -3517,8 +3517,30 @@ public partial class AgentLoopService
"찾을 수 없"); "찾을 수 없");
} }
private static string GetAgentUiExpressionLevel()
{
var app = System.Windows.Application.Current as App;
var raw = app?.SettingsService?.Settings?.Llm?.AgentUiExpressionLevel;
return (raw ?? "balanced").Trim().ToLowerInvariant() switch
{
"rich" => "rich",
"simple" => "simple",
_ => "balanced",
};
}
private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact) private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact)
{ {
var expressionLevel = GetAgentUiExpressionLevel();
if (expressionLevel == "simple" && !taskPolicy.IsReviewTask && !highImpact)
{
return "[System:FinalReportQuality] 최종 답변을 매우 짧게 정리하세요.\n" +
"1. 변경 또는 산출물 한 줄\n" +
"2. 확인한 근거 한 줄\n" +
"실제 미해결 문제가 있을 때만 마지막 한 줄을 추가하세요.";
}
if (!taskPolicy.IsReviewTask && !highImpact) if (!taskPolicy.IsReviewTask && !highImpact)
{ {
return "[System:FinalReportQuality] 최종 답변을 짧고 명확하게 정리하세요.\n" + return "[System:FinalReportQuality] 최종 답변을 짧고 명확하게 정리하세요.\n" +
@@ -3531,6 +3553,19 @@ public partial class AgentLoopService
var taskLine = taskPolicy.FinalReportTaskLine; var taskLine = taskPolicy.FinalReportTaskLine;
var riskLine = "남은 리스크나 추가 확인 필요 사항이 실제로 남아 있을 때만 짧게 적으세요.\n"; var riskLine = "남은 리스크나 추가 확인 필요 사항이 실제로 남아 있을 때만 짧게 적으세요.\n";
if (expressionLevel == "rich")
{
return "[System:FinalReportQuality] 최종 답변을 구조적으로 정리하세요.\n" +
"1. 무엇을 변경했는지\n" +
"2. 어떤 파일/호출부/자료를 확인했는지\n" +
"3. 어떤 build/test/검증 근거가 있는지\n" +
"4. 실제 파일 경로 또는 파일명 1~3개를 명시하세요\n" +
"5. review 작업이면 이슈별 상태를 구분하세요\n" +
taskLine +
riskLine +
"후속 권유는 실제 미해결 위험이 남아 있을 때만 포함하세요.";
}
return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" + return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" +
"1. 무엇을 변경했는지\n" + "1. 무엇을 변경했는지\n" +
"2. 어떤 파일/호출부를 확인했는지\n" + "2. 어떤 파일/호출부를 확인했는지\n" +

View File

@@ -157,21 +157,39 @@ public static class AgentQueryContextBuilder
string.Equals(m.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) string.Equals(m.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) || string.Equals(m.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase)); || string.Equals(m.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase));
var branchContextCount = messages.Count(m =>
string.Equals(m.MetaKind, "branch_context", StringComparison.OrdinalIgnoreCase));
var structuredToolHistoryCount = messages.Count(m => var structuredToolHistoryCount = messages.Count(m =>
{ {
var content = m.Content ?? ""; var content = m.Content ?? "";
return content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) return content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal); || content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal);
}); });
var recentToolNames = messages
.Where(m => m.Role == "user")
.Select(m => TryExtractToolResultToolName(m, out var toolName) ? toolName : "")
.Where(toolName => !string.IsNullOrWhiteSpace(toolName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(4)
.ToList();
if (attachedFiles.Count == 0 && imageCount == 0 && compactSummaryCount == 0 && structuredToolHistoryCount == 0) if (attachedFiles.Count == 0
&& imageCount == 0
&& compactSummaryCount == 0
&& structuredToolHistoryCount == 0
&& branchContextCount == 0
&& recentToolNames.Count == 0)
return; return;
var lines = new List<string> { "[post-compact context]" }; var lines = new List<string> { "[post-compact context]" };
if (compactSummaryCount > 0) if (compactSummaryCount > 0)
lines.Add($"restored compact summaries: {compactSummaryCount}"); lines.Add($"restored compact summaries: {compactSummaryCount}");
if (branchContextCount > 0)
lines.Add($"restored branch context: {branchContextCount}");
if (structuredToolHistoryCount > 0) if (structuredToolHistoryCount > 0)
lines.Add($"restored tool history blocks: {structuredToolHistoryCount}"); lines.Add($"restored tool history blocks: {structuredToolHistoryCount}");
if (recentToolNames.Count > 0)
lines.Add("restored recent tools: " + string.Join(", ", recentToolNames));
if (attachedFiles.Count > 0) if (attachedFiles.Count > 0)
lines.Add("restored file refs: " + string.Join(", ", attachedFiles)); lines.Add("restored file refs: " + string.Join(", ", attachedFiles));
if (imageCount > 0) if (imageCount > 0)
@@ -190,4 +208,28 @@ public static class AgentQueryContextBuilder
AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null, AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null,
}); });
} }
private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
{
toolName = "";
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(message.Content)
|| !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
{
toolName = toolNameEl.GetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
}
catch
{
}
return false;
}
} }

View File

@@ -545,10 +545,14 @@ public partial class LlmService
var detail = ExtractErrorDetail(errBody); var detail = ExtractErrorDetail(errBody);
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) if (forceToolCall && (int)resp.StatusCode == 400)
{ {
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."); LogService.Warn(isIbmDeployment
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); ? "[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
: "[ToolUse] OpenAI 호환 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
var fallbackBody = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
: BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody); var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
@@ -558,7 +562,7 @@ public partial class LlmService
await ApplyAuthHeaderAsync(retryReq, ct); await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (retryResp.IsSuccessStatusCode) if (retryResp.IsSuccessStatusCode)
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct); return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, isIbmDeployment, prefetchToolCallAsync, ct);
} }
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
@@ -686,11 +690,59 @@ public partial class LlmService
} }
} }
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false) private sealed record OpenAiToolCompatibilityProfile(
int StructuredHistoryRecentWindow,
bool AllowParallelToolCalls,
bool IncludeReasoningEffort,
bool AddToolOnlyDirectiveOnFallback);
private OpenAiToolCompatibilityProfile GetOpenAiToolCompatibilityProfile(string service, string model)
{
var normalizedService = NormalizeServiceName(service);
var normalizedModel = (model ?? "").Trim().ToLowerInvariant();
var isFragileVllmFamily =
normalizedService == "vllm" &&
(normalizedModel.Contains("qwen", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("llama", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("deepseek", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("mistral", StringComparison.OrdinalIgnoreCase));
if (isFragileVllmFamily)
{
return new OpenAiToolCompatibilityProfile(
StructuredHistoryRecentWindow: 4,
AllowParallelToolCalls: false,
IncludeReasoningEffort: false,
AddToolOnlyDirectiveOnFallback: true);
}
return new OpenAiToolCompatibilityProfile(
StructuredHistoryRecentWindow: 8,
AllowParallelToolCalls: true,
IncludeReasoningEffort: true,
AddToolOnlyDirectiveOnFallback: true);
}
private static string BuildOpenAiToolOnlyDirective(IReadOnlyCollection<IAgentTool> tools)
{
var toolNames = string.Join(", ", tools.Select(t => t.Name).Take(12));
return "[TOOL_ONLY] 텍스트로 설명하지 말고 지금 바로 도구를 호출하세요. " +
$"사용 가능한 도구: {toolNames}. " +
"plain text 대신 function/tool call을 사용하세요.";
}
private object BuildOpenAiToolBody(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
bool useToolChoice = true)
{ {
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var activeService = ResolveService();
var activeModel = ResolveModel();
var compatibilityProfile = GetOpenAiToolCompatibilityProfile(activeService, activeModel);
var msgs = new List<object>(); var msgs = new List<object>();
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages); var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages, compatibilityProfile.StructuredHistoryRecentWindow);
for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{ {
@@ -790,6 +842,15 @@ public partial class LlmService
} }
} }
if (forceToolCall && !useToolChoice && compatibilityProfile.AddToolOnlyDirectiveOnFallback)
{
msgs.Add(new
{
role = "user",
content = BuildOpenAiToolOnlyDirective(tools),
});
}
// ── tool_calls ↔ tool 메시지 쌍 검증 ── // ── tool_calls ↔ tool 메시지 쌍 검증 ──
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데 // 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함. // 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
@@ -822,8 +883,6 @@ public partial class LlmService
}; };
}).ToArray(); }).ToArray();
var activeService = ResolveService();
var activeModel = ResolveModel();
var executionPolicy = GetActiveExecutionPolicy(); var executionPolicy = GetActiveExecutionPolicy();
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama) if (isOllama)
@@ -839,7 +898,7 @@ public partial class LlmService
["options"] = new { temperature = ResolveToolTemperature() } ["options"] = new { temperature = ResolveToolTemperature() }
}; };
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용 // Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
if (forceToolCall) if (forceToolCall && useToolChoice)
ollamaBody["tool_choice"] = "required"; ollamaBody["tool_choice"] = "required";
return ollamaBody; return ollamaBody;
} }
@@ -852,23 +911,22 @@ public partial class LlmService
["stream"] = true, ["stream"] = true,
["temperature"] = ResolveToolTemperature(), ["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch, ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls,
}; };
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환) // 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true }; body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제 // tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응) // 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall) if (forceToolCall && useToolChoice)
body["tool_choice"] = "required"; body["tool_choice"] = "required";
var effort = ResolveReasoningEffort(); var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort)) if (compatibilityProfile.IncludeReasoningEffort && !string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort; body["reasoning_effort"] = effort;
return body; return body;
} }
private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages) private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages, int protectedRecentNonSystemMessages)
{ {
const int protectedRecentNonSystemMessages = 8;
var nonSystemMessages = messages var nonSystemMessages = messages
.Select((message, index) => new { message, index }) .Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
@@ -1229,9 +1287,14 @@ public partial class LlmService
{ {
var errBody = await resp.Content.ReadAsStringAsync(ct); var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody); var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) if (forceToolCall && (int)resp.StatusCode == 400)
{ {
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); LogService.Warn(isIbmDeployment
? "[ToolUse] IBM 배포형 스트리밍 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
: "[ToolUse] OpenAI 호환 스트리밍 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
var fallbackBody = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
: BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody); var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)

View File

@@ -6,7 +6,7 @@ namespace AxCopilot.Views;
public partial class ChatWindow public partial class ChatWindow
{ {
// 토큰 추정 캐시: 메시지 수나 대화 ID가 달라질 때만 재계산 // 메시지 개수와 대화 ID가 바뀔 때만 재계산한다.
private int _cachedMessageTokens; private int _cachedMessageTokens;
private int _cachedMessageCountForTokens = -1; private int _cachedMessageCountForTokens = -1;
private string? _cachedConvIdForTokens; private string? _cachedConvIdForTokens;
@@ -28,6 +28,7 @@ public partial class ChatWindow
} }
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var expressionLevel = GetAgentUiExpressionLevel();
var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
var triggerRatio = triggerPercent / 100.0; var triggerRatio = triggerPercent / 100.0;
@@ -45,6 +46,7 @@ public partial class ChatWindow
_cachedConvIdForTokens = convId; _cachedConvIdForTokens = convId;
_cachedMessageCountForTokens = msgCount; _cachedMessageCountForTokens = msgCount;
} }
messageTokens = _cachedMessageTokens; messageTokens = _cachedMessageTokens;
} }
@@ -52,7 +54,7 @@ public partial class ChatWindow
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
var hasAnyMessages = messageTokens > 0 || _isStreaming; var hasAnyMessages = messageTokens > 0 || _isStreaming;
int baseOverhead = 0; var baseOverhead = 0;
if (hasAnyMessages) if (hasAnyMessages)
{ {
var sysPromptLen = _llm?.SystemPrompt?.Length ?? 0; var sysPromptLen = _llm?.SystemPrompt?.Length ?? 0;
@@ -118,11 +120,21 @@ public partial class ChatWindow
if (TokenUsagePopupUsage != null) if (TokenUsagePopupUsage != null)
TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}"; TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}";
if (TokenUsagePopupDetail != null) if (TokenUsagePopupDetail != null)
TokenUsagePopupDetail.Text = detailText; TokenUsagePopupDetail.Text = expressionLevel == "simple"
? detailText
: $"{detailText} · 임계 {triggerPercent}%";
if (TokenUsagePopupCompact != null) if (TokenUsagePopupCompact != null)
{
TokenUsagePopupCompact.Text = _sessionCompactionCount > 0 TokenUsagePopupCompact.Text = _sessionCompactionCount > 0
? $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)}" ? expressionLevel switch
: "AX Agent가 컨텍스트를 자동 관리합니다"; {
"simple" => $"압축 {_sessionCompactionCount}회 · {FormatTokenCount(_sessionCompactionSavedTokens)} 절감",
_ => $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)}"
}
: expressionLevel == "rich"
? "AX Agent가 컨텍스트를 자동 관리합니다"
: "컨텍스트 자동 관리";
}
TokenUsageCard.ToolTip = null; TokenUsageCard.ToolTip = null;

View File

@@ -350,6 +350,7 @@ public partial class ChatWindow
private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
{ {
var expressionLevel = GetAgentUiExpressionLevel();
var icon = "\uE9CE"; var icon = "\uE9CE";
var title = message.MetaKind switch var title = message.MetaKind switch
{ {
@@ -399,20 +400,40 @@ public partial class ChatWindow
}; };
var detailBits = new List<string>(); var detailBits = new List<string>();
if (message.AttachedFiles?.Count > 0) if (message.AttachedFiles?.Count > 0 && expressionLevel != "simple")
detailBits.Add($"파일 {message.AttachedFiles.Count}개"); detailBits.Add($"파일 {message.AttachedFiles.Count}개");
var compactDetail = detailBits.Count > 0 var compactDetail = expressionLevel switch
? $"{summary} · {string.Join(", ", detailBits)}" {
: summary; "simple" => summary,
_ when detailBits.Count > 0 => $"{summary} · {string.Join(", ", detailBits)}",
_ => summary
};
stack.Children.Add(new TextBlock stack.Children.Add(new TextBlock
{ {
Text = compactDetail, Text = compactDetail,
FontSize = 10.5, FontSize = expressionLevel == "simple" ? 10 : 10.5,
Foreground = secondaryText, Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap, TextWrapping = TextWrapping.Wrap,
}); });
if (expressionLevel == "rich" && !string.IsNullOrWhiteSpace(message.Content))
{
var preview = message.Content.Trim();
if (preview.Length > 120)
preview = preview[..120] + "...";
stack.Children.Add(new TextBlock
{
Text = preview,
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.88,
Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
wrapper.Child = stack; wrapper.Child = stack;
return wrapper; return wrapper;
} }