모델 프로파일 기반 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:
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user