모델 프로파일 기반 Cowork/Code 루프와 진행 UX 고도화 반영

- 등록 모델 실행 프로파일을 검증 게이트, 문서 fallback, post-tool verification까지 확장 적용

- Cowork/Code 진행 카드에 계획/도구/검증/압축/폴백/재시도 단계 메타를 추가해 대기 상태 가시성 강화

- OpenAI/vLLM tool 요청에 병렬 도구 호출 힌트를 추가하고 회귀 프롬프트 문서를 프로파일 기준으로 전면 정리

- 검증: 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-08 13:41:57 +09:00
parent b391dfdfb3
commit a2c952879d
552 changed files with 8094 additions and 13595 deletions

View File

@@ -185,16 +185,19 @@ public partial class AgentLoopService
string? documentPlanTitle = null; // document_plan이 제안한 문서 제목
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = _llm.GetActiveExecutionPolicy();
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = GetNoToolCallResponseThreshold();
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries();
var planExecutionRetryMax = GetPlanExecutionRetryMax();
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var runState = new RunState();
var requireHighImpactCodeVerification = false;
string? lastModifiedCodeFilePath = null;
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
maxRetry = ComputeAdaptiveMaxRetry(maxRetry, taskPolicy.TaskType);
var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType);
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
@@ -204,7 +207,8 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides);
var runtimeOverrideApplied = false;
@@ -443,12 +447,17 @@ public partial class AgentLoopService
// Context Condenser: 토큰 초과 시 이전 대화 자동 압축
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
{
var proactiveCompact = llm.EnableProactiveContextCompact
&& !(executionPolicy.ReduceEarlyMemoryPressure && iteration <= 1);
var compactTriggerPercent = executionPolicy.ReduceEarlyMemoryPressure
? Math.Min(95, llm.ContextCompactTriggerPercent + 10)
: llm.ContextCompactTriggerPercent;
var compactionResult = await ContextCondenser.CondenseWithStatsAsync(
messages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
proactiveCompact,
compactTriggerPercent,
false,
ct);
if (compactionResult.Changed)
@@ -509,12 +518,16 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
return "⚠ 현재 스킬 정책에서 허용된 도구가 없어 작업을 진행할 수 없습니다. allowed-tools 설정을 확인하세요.";
}
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
blocks = await SendWithToolsWithRecoveryAsync(
messages,
activeTools,
ct,
$"메인 루프 {iteration}",
runState);
runState,
forceToolCall: forceFirst);
runState.ContextRecoveryAttempts = 0;
runState.TransientLlmErrorRetries = 0;
NotifyPostCompactionTurnIfNeeded(runState);
@@ -601,6 +614,11 @@ public partial class AgentLoopService
return $"⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): {fallbackEx.Message}";
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 사용자가 직접 취소한 경우 — 오류로 처리하지 않고 상위로 전파
throw;
}
catch (Exception ex)
{
if (TryHandleContextOverflowTransition(ex, messages, runState))
@@ -706,13 +724,27 @@ public partial class AgentLoopService
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
messages.Add(new ChatMessage
var retryNum = runState.NoToolCallLoopRetry;
var recoveryContent = retryNum switch
{
Role = "user",
Content = "[System:ToolCallRequired] 현재 응답이 연속으로 도구 호출 없이 종료되고 있습니다. " +
"설명만 하지 말고 즉시 도구를 최소 1회 이상 호출해 실행을 진행하세요. " +
$"사용 가능 도구 예시: {activeToolPreview}"
});
1 =>
"[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
"지금 즉시 도구를 1개 이상 호출하세요. " +
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
$"지금 사용 가능한 도구: {activeToolPreview}",
_ =>
"[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
EmitEvent(
AgentEventType.Thinking,
"",
@@ -725,20 +757,50 @@ public partial class AgentLoopService
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < planExecutionRetryMax)
if (executionPolicy.ForceToolCallAfterPlan
&& planSteps.Count > 0
&& totalToolCalls == 0
&& planExecutionRetry < planExecutionRetryMax)
{
planExecutionRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage { Role = "user",
Content = "도구를 호출하지 않았습니다. 계획 1단계를 지금 즉시 도구(tool call)로 실행하세요. " +
"설명 없이 도구 호출만 하세요." });
var planToolList = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
var planRecoveryContent = planExecutionRetry switch
{
1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
$"사용 가능한 도구: {planToolList}",
_ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
$"사용 가능한 도구: {planToolList}"
};
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
EmitEvent(AgentEventType.Thinking, "", $"도구 미호출 감지 — 실행 재시도 {planExecutionRetry}/{planExecutionRetryMax}...");
continue; // 루프 재시작
}
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 재시도 (최대 2회)
if (documentPlanCalled && postDocumentPlanRetry < 2)
// 문서 생성 우선 프로파일은 재시도보다 빠른 fallback을 우선합니다.
if (documentPlanCalled
&& executionPolicy.PreferAggressiveDocumentFallback
&& !string.IsNullOrEmpty(documentPlanScaffold)
&& !_docFallbackAttempted)
{
postDocumentPlanRetry = documentPlanRetryMax;
}
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 프로파일 기준 재시도
if (documentPlanCalled && postDocumentPlanRetry < documentPlanRetryMax)
{
postDocumentPlanRetry++;
if (!string.IsNullOrEmpty(textResponse))
@@ -747,7 +809,7 @@ public partial class AgentLoopService
Content = "html_create 도구를 호출하지 않았습니다. " +
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." });
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/2...");
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
continue; // 루프 재시작
}
@@ -845,36 +907,41 @@ public partial class AgentLoopService
textResponse,
taskPolicy,
lastArtifactFilePath,
runState))
runState,
executionPolicy))
continue;
if (TryApplyCodeDiffEvidenceGateTransition(
messages,
textResponse,
runState))
runState,
executionPolicy))
continue;
if (TryApplyRecentExecutionEvidenceGateTransition(
messages,
textResponse,
taskPolicy,
runState))
runState,
executionPolicy))
continue;
if (TryApplyExecutionSuccessGateTransition(
messages,
textResponse,
taskPolicy,
runState))
runState,
executionPolicy))
continue;
if (TryApplyCodeCompletionGateTransition(
if (executionPolicy.EnableCodeQualityGates && TryApplyCodeCompletionGateTransition(
messages,
textResponse,
taskPolicy,
requireHighImpactCodeVerification,
totalToolCalls,
runState))
runState,
executionPolicy))
continue;
if (TryApplyTerminalEvidenceGateTransition(
@@ -884,7 +951,8 @@ public partial class AgentLoopService
userQuery,
totalToolCalls,
lastArtifactFilePath,
runState))
runState,
terminalEvidenceGateRetryMax))
continue;
if (!string.IsNullOrEmpty(textResponse))
@@ -904,7 +972,10 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
var parallelPlan = CreateParallelExecutionPlan(llm.EnableParallelTools, toolCalls);
var parallelPlan = CreateParallelExecutionPlan(
llm.EnableParallelTools && executionPolicy.EnableParallelReadBatch,
toolCalls,
executionPolicy.MaxParallelReadBatch);
if (parallelPlan.ShouldRun)
{
if (parallelPlan.ParallelBatch.Count > 1)
@@ -1398,6 +1469,7 @@ public partial class AgentLoopService
toolCalls,
messages,
llm,
executionPolicy,
context,
ct);
if (terminalCompleted)
@@ -1412,6 +1484,7 @@ public partial class AgentLoopService
result,
messages,
llm,
executionPolicy,
context,
ct);
if (consumedVerificationIteration)
@@ -3474,29 +3547,53 @@ public partial class AgentLoopService
}
private static int GetNoToolCallResponseThreshold()
=> ResolveNoToolCallResponseThreshold(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"));
=> GetNoToolCallResponseThreshold(defaultValue: 2);
private static int GetNoToolCallResponseThreshold(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
defaultValue,
min: 1,
max: 6);
private static int GetNoToolCallRecoveryMaxRetries()
=> ResolveNoToolCallRecoveryMaxRetries(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"));
=> GetNoToolCallRecoveryMaxRetries(defaultValue: 3);
private static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
defaultValue,
min: 0,
max: 6);
private static int GetPlanExecutionRetryMax()
=> ResolvePlanExecutionRetryMax(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"));
=> GetPlanExecutionRetryMax(defaultValue: 2);
private static int GetPlanExecutionRetryMax(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
defaultValue,
min: 0,
max: 6);
private static int ResolveNoToolCallResponseThreshold(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
private static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
private static int ResolvePlanExecutionRetryMax(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
private static int GetTerminalEvidenceGateMaxRetries()
=> ResolveTerminalEvidenceGateMaxRetries(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"));
=> GetTerminalEvidenceGateMaxRetries(defaultValue: 1);
private static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
defaultValue,
min: 0,
max: 3);
private static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
@@ -3957,6 +4054,10 @@ public partial class AgentLoopService
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
EmitEvent(AgentEventType.Error, "", $"검증 LLM 호출 실패: {ex.Message}");
@@ -4592,9 +4693,15 @@ public partial class AgentLoopService
}
// 문서 생성 (Excel, Word, HTML 등)
if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create")
// Cowork 탭에서 문서 생성은 핵심 목적이므로 승인 불필요
if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create"
or "markdown_create" or "pptx_create")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return null;
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
@@ -4833,4 +4940,3 @@ public partial class AgentLoopService
}
}