핵심 엔진을 claude-code 기준으로 정렬하고 스트리밍 재시도 경계를 정리한다
- StreamingToolExecutionCoordinator에서 조기 실행 대상을 file_read/document_read 중심으로 축소하고 folder_map 등 구조 탐색 도구를 prefetch 대상에서 제거함 - 스트리밍 재시도 전에 RetryReset 이벤트를 추가해 중간 응답 미리보기 누적을 끊고 AgentLoopService가 재시도 경계를 명확히 표시하도록 조정함 - AxAgentExecutionEngine의 Cowork/Code 빈 응답 합성을 보수적으로 바꿔 실행 근거가 있을 때만 완료 요약을 만들고 근거가 없으면 로그 확인 안내를 반환하도록 정리함 - Code 루프의 post-tool verification과 completion gate도 직전 수정에서 함께 정리해 일반 수정의 과검증을 줄였음 - README.md, docs/DEVELOPMENT.md에 2026-04-09 21:03 (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:
@@ -23,7 +23,7 @@ public partial class AgentLoopService
|
||||
string Content,
|
||||
string? PreviousContent = null);
|
||||
|
||||
private readonly LlmService _llm;
|
||||
private readonly ILlmService _llm;
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
|
||||
@@ -82,7 +82,7 @@ public partial class AgentLoopService
|
||||
/// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary>
|
||||
public event Action<AgentEvent>? EventOccurred;
|
||||
|
||||
public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings)
|
||||
public AgentLoopService(ILlmService llm, ToolRegistry tools, SettingsService settings)
|
||||
{
|
||||
_llm = llm;
|
||||
_tools = tools;
|
||||
@@ -180,6 +180,9 @@ public partial class AgentLoopService
|
||||
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
|
||||
SelectiveHit = true,
|
||||
};
|
||||
var pathAccessState = new PathAccessTrackingState();
|
||||
DateTime? lastToolResultAtUtc = null;
|
||||
string? lastToolResultToolName = null;
|
||||
|
||||
// 워크플로우 상세 로그: 에이전트 루프 시작
|
||||
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
|
||||
@@ -242,6 +245,11 @@ public partial class AgentLoopService
|
||||
var context = BuildContext();
|
||||
InjectTaskTypeGuidance(messages, taskPolicy);
|
||||
InjectExplorationScopeGuidance(messages, explorationState.Scope);
|
||||
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
|
||||
explorationState,
|
||||
taskPolicy,
|
||||
ActiveTab,
|
||||
userQuery);
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
@@ -518,6 +526,19 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
|
||||
if (lastToolResultAtUtc is { } toolResultAt)
|
||||
{
|
||||
var waitMs = Math.Max(0, (long)(DateTime.UtcNow - toolResultAt).TotalMilliseconds);
|
||||
WorkflowLogService.LogTransition(
|
||||
_conversationId,
|
||||
_currentRunId,
|
||||
iteration,
|
||||
"llm_wait_after_tool_result",
|
||||
$"{lastToolResultToolName ?? "unknown"}:{waitMs}ms");
|
||||
lastToolResultAtUtc = null;
|
||||
lastToolResultToolName = null;
|
||||
}
|
||||
|
||||
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
|
||||
|
||||
// Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
|
||||
@@ -554,10 +575,15 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
|
||||
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
||||
var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
|
||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
|
||||
explorationState,
|
||||
userQuery,
|
||||
ActiveTab,
|
||||
totalToolCalls);
|
||||
|
||||
// LLM에 도구 정의와 함께 요청
|
||||
List<LlmService.ContentBlock> blocks;
|
||||
List<ContentBlock> blocks;
|
||||
var llmCallSw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
@@ -612,7 +638,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
switch (evt.Kind)
|
||||
{
|
||||
case LlmService.ToolStreamEventKind.TextDelta:
|
||||
case ToolStreamEventKind.TextDelta:
|
||||
if (!string.IsNullOrWhiteSpace(evt.Text))
|
||||
{
|
||||
streamedTextPreview.Append(evt.Text);
|
||||
@@ -626,7 +652,7 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
break;
|
||||
case LlmService.ToolStreamEventKind.ToolCallReady:
|
||||
case ToolStreamEventKind.ToolCallReady:
|
||||
if (evt.ToolCall != null)
|
||||
{
|
||||
EmitEvent(
|
||||
@@ -635,9 +661,14 @@ public partial class AgentLoopService
|
||||
$"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}");
|
||||
}
|
||||
break;
|
||||
case LlmService.ToolStreamEventKind.Completed:
|
||||
case ToolStreamEventKind.Completed:
|
||||
await Task.CompletedTask;
|
||||
break;
|
||||
case ToolStreamEventKind.RetryReset:
|
||||
streamedTextPreview.Clear();
|
||||
lastStreamUiUpdateAt = DateTime.UtcNow;
|
||||
EmitEvent(AgentEventType.Thinking, "", "스트리밍 중간 응답을 정리하고 재시도합니다.");
|
||||
break;
|
||||
}
|
||||
});
|
||||
runState.ContextRecoveryAttempts = 0;
|
||||
@@ -745,7 +776,7 @@ public partial class AgentLoopService
|
||||
|
||||
// 응답에서 텍스트와 도구 호출 분리
|
||||
var textParts = new List<string>();
|
||||
var toolCalls = new List<LlmService.ContentBlock>();
|
||||
var toolCalls = new List<ContentBlock>();
|
||||
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
@@ -851,13 +882,15 @@ public partial class AgentLoopService
|
||||
"[System:ToolCallRequired] " +
|
||||
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
|
||||
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
|
||||
$"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " +
|
||||
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {activeToolPreview}",
|
||||
_ =>
|
||||
"[System:ToolCallRequired] " +
|
||||
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
|
||||
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" +
|
||||
$"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
|
||||
"반드시 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
|
||||
};
|
||||
@@ -891,12 +924,13 @@ public partial class AgentLoopService
|
||||
{
|
||||
1 =>
|
||||
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
|
||||
$"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " +
|
||||
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {planToolList}",
|
||||
_ =>
|
||||
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
|
||||
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" +
|
||||
$"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {planToolList}"
|
||||
};
|
||||
@@ -1248,7 +1282,7 @@ public partial class AgentLoopService
|
||||
: resolvedToolName;
|
||||
var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase)
|
||||
? call
|
||||
: new LlmService.ContentBlock
|
||||
: new ContentBlock
|
||||
{
|
||||
Type = call.Type,
|
||||
Text = call.Text,
|
||||
@@ -1306,6 +1340,18 @@ public partial class AgentLoopService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryHandleRepeatedPathAccessTransition(
|
||||
effectiveCall,
|
||||
context,
|
||||
pathAccessState,
|
||||
messages,
|
||||
lastModifiedCodeFilePath,
|
||||
requireHighImpactCodeVerification,
|
||||
taskPolicy))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task Decomposition: 단계 진행률 추적
|
||||
if (planSteps.Count > 0)
|
||||
{
|
||||
@@ -1345,7 +1391,7 @@ public partial class AgentLoopService
|
||||
Role = "system",
|
||||
Content =
|
||||
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " +
|
||||
"Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " +
|
||||
"Follow this order: glob/grep -> targeted file_read/document_read -> folder_map only if the user explicitly needs folder structure. " +
|
||||
"Do not repeat folder_map unless repository structure is genuinely required."
|
||||
});
|
||||
EmitEvent(
|
||||
@@ -1405,7 +1451,7 @@ public partial class AgentLoopService
|
||||
if (llm.EnableHookInputMutation &&
|
||||
TryGetHookUpdatedInput(preResults, out var updatedInput))
|
||||
{
|
||||
effectiveCall = new LlmService.ContentBlock
|
||||
effectiveCall = new ContentBlock
|
||||
{
|
||||
Type = effectiveCall.Type,
|
||||
Text = effectiveCall.Text,
|
||||
@@ -1533,6 +1579,8 @@ public partial class AgentLoopService
|
||||
ref statsFailCount,
|
||||
ref statsInputTokens,
|
||||
ref statsOutputTokens);
|
||||
lastToolResultAtUtc = DateTime.UtcNow;
|
||||
lastToolResultToolName = effectiveCall.ToolName;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
@@ -1608,7 +1656,14 @@ public partial class AgentLoopService
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
effectiveCall.ToolId,
|
||||
effectiveCall.ToolName,
|
||||
BuildLoopToolResultMessage(effectiveCall, result, runState)));
|
||||
BuildToolResultFeedbackMessage(
|
||||
effectiveCall,
|
||||
result,
|
||||
runState,
|
||||
context,
|
||||
messages,
|
||||
explorationState,
|
||||
iteration)));
|
||||
|
||||
if (TryHandleReadOnlyStagnationTransition(
|
||||
consecutiveReadOnlySuccessTools,
|
||||
@@ -1909,7 +1964,8 @@ public partial class AgentLoopService
|
||||
SkillRuntimeOverrides? runtimeOverrides)
|
||||
{
|
||||
var mergedDisabled = MergeDisabledTools(disabledToolNames);
|
||||
var active = _tools.GetActiveTools(mergedDisabled);
|
||||
// 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약)
|
||||
var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled);
|
||||
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
|
||||
return active;
|
||||
|
||||
@@ -4136,7 +4192,7 @@ public partial class AgentLoopService
|
||||
|
||||
// 검증 응답 처리
|
||||
var verifyText = new List<string>();
|
||||
var verifyToolCalls = new List<LlmService.ContentBlock>();
|
||||
var verifyToolCalls = new List<ContentBlock>();
|
||||
|
||||
foreach (var block in verifyBlocks)
|
||||
{
|
||||
@@ -4724,7 +4780,7 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
|
||||
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
|
||||
private string? CheckDecisionRequired(ContentBlock call, AgentContext context)
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal";
|
||||
@@ -4811,7 +4867,7 @@ public partial class AgentLoopService
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatToolCallSummary(LlmService.ContentBlock call)
|
||||
private static string FormatToolCallSummary(ContentBlock call)
|
||||
{
|
||||
if (call.ToolInput == null) return call.ToolName;
|
||||
try
|
||||
@@ -4829,7 +4885,7 @@ public partial class AgentLoopService
|
||||
catch { return call.ToolName; }
|
||||
}
|
||||
|
||||
private static string BuildToolCallSignature(LlmService.ContentBlock call)
|
||||
private static string BuildToolCallSignature(ContentBlock call)
|
||||
{
|
||||
var name = (call.ToolName ?? "").Trim().ToLowerInvariant();
|
||||
if (call.ToolInput == null)
|
||||
@@ -4929,7 +4985,7 @@ public partial class AgentLoopService
|
||||
FailureRecoveryKind.Permission =>
|
||||
"권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행",
|
||||
FailureRecoveryKind.Path =>
|
||||
"folder_map/glob -> 절대경로 확인 -> file_read로 존재 검증 -> 원도구 재실행",
|
||||
"glob -> file_read -> folder_map(필요 시만) -> 절대경로 확인 -> 원도구 재실행",
|
||||
FailureRecoveryKind.Command =>
|
||||
"도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구",
|
||||
FailureRecoveryKind.Dependency =>
|
||||
@@ -4940,7 +4996,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
"build_run" or "test_loop" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop",
|
||||
"file_edit" or "file_write" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop",
|
||||
"grep" or "glob" => "file_read -> folder_map -> grep/glob",
|
||||
"grep" or "glob" => "glob/grep 재구성 -> file_read -> folder_map(필요 시만)",
|
||||
_ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private void ApplyDocumentPlanSuccessTransitions(
|
||||
LlmService.ContentBlock call,
|
||||
ContentBlock call,
|
||||
ToolResult result,
|
||||
List<ChatMessage> messages,
|
||||
ref bool documentPlanCalled,
|
||||
@@ -96,9 +96,9 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync(
|
||||
LlmService.ContentBlock call,
|
||||
ContentBlock call,
|
||||
ToolResult result,
|
||||
List<LlmService.ContentBlock> toolCalls,
|
||||
List<ContentBlock> toolCalls,
|
||||
List<ChatMessage> messages,
|
||||
Models.LlmSettings llm,
|
||||
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
|
||||
@@ -134,7 +134,7 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
|
||||
LlmService.ContentBlock call,
|
||||
ContentBlock call,
|
||||
ToolResult result,
|
||||
List<ChatMessage> messages,
|
||||
Models.LlmSettings llm,
|
||||
@@ -156,6 +156,16 @@ public partial class AgentLoopService
|
||||
if (!shouldVerify)
|
||||
return false;
|
||||
|
||||
if (string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
|
||||
var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages);
|
||||
var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages);
|
||||
|
||||
if (!highImpactCodeChange || (hasDiffEvidence && hasRecentBuildOrTestEvidence))
|
||||
return false;
|
||||
}
|
||||
|
||||
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private void ApplyCodeQualityFollowUpTransition(
|
||||
LlmService.ContentBlock call,
|
||||
ContentBlock call,
|
||||
ToolResult result,
|
||||
List<ChatMessage> messages,
|
||||
TaskTypePolicy taskPolicy,
|
||||
@@ -13,9 +13,9 @@ public partial class AgentLoopService
|
||||
ref string? lastModifiedCodeFilePath)
|
||||
{
|
||||
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
|
||||
if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
|
||||
requireHighImpactCodeVerification = highImpactCodeChange;
|
||||
if (highImpactCodeChange && ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
|
||||
{
|
||||
requireHighImpactCodeVerification = highImpactCodeChange;
|
||||
lastModifiedCodeFilePath = result.FilePath;
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
@@ -52,8 +52,12 @@ public partial class AgentLoopService
|
||||
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
|
||||
messages,
|
||||
requireHighImpactCodeVerification);
|
||||
var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages);
|
||||
var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages);
|
||||
var hasSuccessfulBuildAndTestEvidence = HasSuccessfulBuildAndTestAfterLastModification(messages);
|
||||
if (executionPolicy.CodeVerificationGateMaxRetries > 0
|
||||
&& !hasCodeVerificationEvidence
|
||||
&& !(hasDiffEvidence && hasRecentBuildOrTestEvidence && !requireHighImpactCodeVerification)
|
||||
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
|
||||
{
|
||||
runState.CodeVerificationGateRetry++;
|
||||
@@ -89,7 +93,10 @@ public partial class AgentLoopService
|
||||
return true;
|
||||
}
|
||||
|
||||
var hasBlockingCodeEvidenceGap = !hasCodeVerificationEvidence
|
||||
|| (requireHighImpactCodeVerification && !hasSuccessfulBuildAndTestEvidence);
|
||||
if (executionPolicy.FinalReportGateMaxRetries > 0
|
||||
&& !hasBlockingCodeEvidenceGap
|
||||
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
|
||||
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
|
||||
{
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class AxAgentExecutionEngine
|
||||
int completionTokens = 0,
|
||||
long? responseElapsedMs = null,
|
||||
string? metaRunId = null,
|
||||
ChatStorageService? storage = null)
|
||||
IChatStorageService? storage = null)
|
||||
{
|
||||
var assistant = new ChatMessage
|
||||
{
|
||||
@@ -177,7 +177,7 @@ public sealed class AxAgentExecutionEngine
|
||||
int completionTokens = 0,
|
||||
long? responseElapsedMs = null,
|
||||
string? metaRunId = null,
|
||||
ChatStorageService? storage = null)
|
||||
IChatStorageService? storage = null)
|
||||
{
|
||||
var normalized = NormalizeAssistantContentForUi(conversation, tab, content);
|
||||
if (tab is "Cowork" or "Code")
|
||||
@@ -209,6 +209,10 @@ public sealed class AxAgentExecutionEngine
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
|
||||
if (runTab is "Cowork" or "Code")
|
||||
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
|
||||
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
|
||||
|
||||
return BuildFallbackCompletionMessage(conversation, runTab);
|
||||
}
|
||||
|
||||
@@ -230,7 +234,7 @@ public sealed class AxAgentExecutionEngine
|
||||
|
||||
public SessionMutationResult AppendExecutionEvent(
|
||||
ChatSessionStateService session,
|
||||
ChatStorageService storage,
|
||||
IChatStorageService storage,
|
||||
ChatConversation? activeConversation,
|
||||
string activeTab,
|
||||
string targetTab,
|
||||
@@ -247,7 +251,7 @@ public sealed class AxAgentExecutionEngine
|
||||
|
||||
public SessionMutationResult AppendAgentRun(
|
||||
ChatSessionStateService session,
|
||||
ChatStorageService storage,
|
||||
IChatStorageService storage,
|
||||
ChatConversation? activeConversation,
|
||||
string activeTab,
|
||||
string targetTab,
|
||||
@@ -272,6 +276,10 @@ public sealed class AxAgentExecutionEngine
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
|
||||
if (runTab is "Cowork" or "Code")
|
||||
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
|
||||
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
|
||||
|
||||
return BuildFallbackCompletionMessage(conversation, runTab);
|
||||
}
|
||||
|
||||
@@ -280,6 +288,9 @@ public sealed class AxAgentExecutionEngine
|
||||
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
|
||||
/// </summary>
|
||||
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
|
||||
=> TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(빈 응답)";
|
||||
|
||||
private static string? TryBuildEvidenceBackedCompletionMessage(ChatConversation conversation, string runTab)
|
||||
{
|
||||
static bool IsSignificantEventType(string t)
|
||||
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -290,7 +301,8 @@ public sealed class AxAgentExecutionEngine
|
||||
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
|
||||
static bool IsInternalStatusSummary(string? summary)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summary)) return false;
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
return false;
|
||||
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -345,6 +357,68 @@ public sealed class AxAgentExecutionEngine
|
||||
return completionLine ?? "(빈 응답)";
|
||||
}
|
||||
|
||||
private static string? TryBuildStrictExecutionCompletionMessage(ChatConversation conversation, string runTab)
|
||||
{
|
||||
static bool IsSignificantEventType(string t)
|
||||
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsInternalStatusSummary(string? summary)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
return false;
|
||||
|
||||
return summary.StartsWith("LLM", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|
||||
|| summary.Contains("[System:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var completionLine = runTab switch
|
||||
{
|
||||
"Cowork" => "코워크 작업이 완료되었습니다.",
|
||||
"Code" => "코드 작업이 완료되었습니다.",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var artifactEvent = conversation.ExecutionEvents?
|
||||
.Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type))
|
||||
.OrderByDescending(evt => evt.Timestamp)
|
||||
.FirstOrDefault();
|
||||
if (artifactEvent != null)
|
||||
{
|
||||
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
|
||||
? $"생성된 파일: {artifactEvent.FilePath}"
|
||||
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
|
||||
return completionLine != null
|
||||
? $"{completionLine}\n\n{fileLine}"
|
||||
: fileLine;
|
||||
}
|
||||
|
||||
var latestSummary = conversation.ExecutionEvents?
|
||||
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
|
||||
&& IsSignificantEventType(evt.Type)
|
||||
&& !IsInternalStatusSummary(evt.Summary))
|
||||
.OrderByDescending(evt => evt.Timestamp)
|
||||
.Select(evt => evt.Summary.Trim())
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(latestSummary))
|
||||
{
|
||||
return completionLine != null
|
||||
? $"{completionLine}\n\n{latestSummary}"
|
||||
: latestSummary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ChatMessage CloneMessage(ChatMessage source)
|
||||
{
|
||||
return new ChatMessage
|
||||
@@ -370,7 +444,7 @@ public sealed class AxAgentExecutionEngine
|
||||
|
||||
private static SessionMutationResult ApplyConversationMutation(
|
||||
ChatSessionStateService session,
|
||||
ChatStorageService storage,
|
||||
IChatStorageService storage,
|
||||
ChatConversation? activeConversation,
|
||||
string activeTab,
|
||||
string targetTab,
|
||||
|
||||
@@ -11,13 +11,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
{
|
||||
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
|
||||
"search_codebase", "code_search", "env_tool", "datetime_tool",
|
||||
"file_read", "document_read",
|
||||
"env_tool", "datetime_tool",
|
||||
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
|
||||
"hash_tool", "image_analyze", "multi_read"
|
||||
"hash_tool", "image_analyze"
|
||||
};
|
||||
|
||||
private readonly LlmService _llm;
|
||||
private readonly ILlmService _llm;
|
||||
private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName;
|
||||
private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync;
|
||||
private readonly Action<AgentEventType, string, string> _emitEvent;
|
||||
@@ -27,7 +27,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
|
||||
|
||||
public StreamingToolExecutionCoordinator(
|
||||
LlmService llm,
|
||||
ILlmService llm,
|
||||
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
|
||||
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
|
||||
Action<AgentEventType, string, string> emitEvent,
|
||||
@@ -46,8 +46,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
|
||||
}
|
||||
|
||||
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
LlmService.ContentBlock block,
|
||||
public async Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
ContentBlock block,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
AgentContext context,
|
||||
CancellationToken ct)
|
||||
@@ -70,57 +70,60 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||
var result = await _executeToolAsync(resolvedToolName, input, context, null, ct);
|
||||
sw.Stop();
|
||||
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
|
||||
return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new LlmService.ToolPrefetchResult(
|
||||
return new ToolPrefetchResult(
|
||||
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
|
||||
sw.ElapsedMilliseconds,
|
||||
resolvedToolName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||
public async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
CancellationToken ct,
|
||||
string phaseLabel,
|
||||
AgentLoopService.RunState? runState = null,
|
||||
bool forceToolCall = false,
|
||||
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||
Func<ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||
{
|
||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var streamedAnyPartialState = false;
|
||||
try
|
||||
{
|
||||
if (onStreamEventAsync == null)
|
||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
||||
|
||||
var blocks = new List<LlmService.ContentBlock>();
|
||||
var blocks = new List<ContentBlock>();
|
||||
var textBuilder = new StringBuilder();
|
||||
await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct))
|
||||
{
|
||||
await onStreamEventAsync(evt);
|
||||
if (evt.Kind == LlmService.ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
|
||||
if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
|
||||
{
|
||||
streamedAnyPartialState = true;
|
||||
textBuilder.Append(evt.Text);
|
||||
}
|
||||
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||
else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||
{
|
||||
streamedAnyPartialState = true;
|
||||
blocks.Add(evt.ToolCall);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<LlmService.ContentBlock>();
|
||||
var result = new List<ContentBlock>();
|
||||
var text = textBuilder.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
|
||||
result.Add(new ContentBlock { Type = "text", Text = text });
|
||||
result.AddRange(blocks);
|
||||
return result;
|
||||
}
|
||||
@@ -130,6 +133,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
&& contextRecoveryRetries < 2
|
||||
&& _forceContextRecovery(messages))
|
||||
{
|
||||
if (onStreamEventAsync != null && streamedAnyPartialState)
|
||||
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
|
||||
contextRecoveryRetries++;
|
||||
if (runState != null)
|
||||
runState.ContextRecoveryAttempts = contextRecoveryRetries;
|
||||
@@ -146,6 +151,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
|
||||
if (_isTransientLlmError(ex) && transientRetries < 3)
|
||||
{
|
||||
if (onStreamEventAsync != null && streamedAnyPartialState)
|
||||
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
|
||||
transientRetries++;
|
||||
if (runState != null)
|
||||
runState.TransientLlmErrorRetries = transientRetries;
|
||||
|
||||
Reference in New Issue
Block a user