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

@@ -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)
{
var expressionLevel = GetAgentUiExpressionLevel();
if (expressionLevel == "simple" && !taskPolicy.IsReviewTask && !highImpact)
{
return "[System:FinalReportQuality] 최종 답변을 매우 짧게 정리하세요.\n" +
"1. 변경 또는 산출물 한 줄\n" +
"2. 확인한 근거 한 줄\n" +
"실제 미해결 문제가 있을 때만 마지막 한 줄을 추가하세요.";
}
if (!taskPolicy.IsReviewTask && !highImpact)
{
return "[System:FinalReportQuality] 최종 답변을 짧고 명확하게 정리하세요.\n" +
@@ -3531,6 +3553,19 @@ public partial class AgentLoopService
var taskLine = taskPolicy.FinalReportTaskLine;
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" +
"1. 무엇을 변경했는지\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, "session_memory_compaction", 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 content = m.Content ?? "";
return content.StartsWith("{\"_tool_use_blocks\"", 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;
var lines = new List<string> { "[post-compact context]" };
if (compactSummaryCount > 0)
lines.Add($"restored compact summaries: {compactSummaryCount}");
if (branchContextCount > 0)
lines.Add($"restored branch context: {branchContextCount}");
if (structuredToolHistoryCount > 0)
lines.Add($"restored tool history blocks: {structuredToolHistoryCount}");
if (recentToolNames.Count > 0)
lines.Add("restored recent tools: " + string.Join(", ", recentToolNames));
if (attachedFiles.Count > 0)
lines.Add("restored file refs: " + string.Join(", ", attachedFiles));
if (imageCount > 0)
@@ -190,4 +208,28 @@ public static class AgentQueryContextBuilder
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);
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가 거부되어 대체 강제 전략으로 재시도합니다.");
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);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
@@ -558,7 +562,7 @@ public partial class LlmService
await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (retryResp.IsSuccessStatusCode)
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct);
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, isIbmDeployment, prefetchToolCallAsync, ct);
}
// 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 activeService = ResolveService();
var activeModel = ResolveModel();
var compatibilityProfile = GetOpenAiToolCompatibilityProfile(activeService, activeModel);
var msgs = new List<object>();
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages);
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages, compatibilityProfile.StructuredHistoryRecentWindow);
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 assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
@@ -822,8 +883,6 @@ public partial class LlmService
};
}).ToArray();
var activeService = ResolveService();
var activeModel = ResolveModel();
var executionPolicy = GetActiveExecutionPolicy();
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama)
@@ -839,7 +898,7 @@ public partial class LlmService
["options"] = new { temperature = ResolveToolTemperature() }
};
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
if (forceToolCall)
if (forceToolCall && useToolChoice)
ollamaBody["tool_choice"] = "required";
return ollamaBody;
}
@@ -852,23 +911,22 @@ public partial class LlmService
["stream"] = true,
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls,
};
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall)
if (forceToolCall && useToolChoice)
body["tool_choice"] = "required";
var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort))
if (compatibilityProfile.IncludeReasoningEffort && !string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort;
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
.Select((message, index) => new { message, index })
.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 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);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)