AX Agent 구조를 claw-code 기준으로 추가 정리해 transcript 렌더와 tool streaming 책임을 분리함
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.TranscriptRendering partial을 추가해 transcript windowing, 증분 렌더, 스크롤 보존 로직을 메인 ChatWindow.xaml.cs에서 분리 - StreamingToolExecutionCoordinator를 도입해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 계층으로 이동 - AgentLoopRuntimeThresholds helper를 추가해 no-tool, plan retry, terminal evidence gate 임계값 계산을 AgentLoopService에서 분리 - AgentLoopTransitions.Execution은 coordinator thin wrapper 중심 구조로 정리해 이후 executor 고도화와 정책 변경이 덜 위험하도록 개선 - README와 docs/DEVELOPMENT.md를 2026-04-09 09:14 (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:
52
src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs
Normal file
52
src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class AgentLoopRuntimeThresholds
|
||||
{
|
||||
public static int GetNoToolCallResponseThreshold(int defaultValue)
|
||||
=> ResolveThresholdValue(
|
||||
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
|
||||
defaultValue,
|
||||
min: 1,
|
||||
max: 6);
|
||||
|
||||
public static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
|
||||
=> ResolveThresholdValue(
|
||||
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
|
||||
defaultValue,
|
||||
min: 0,
|
||||
max: 6);
|
||||
|
||||
public static int GetPlanExecutionRetryMax(int defaultValue)
|
||||
=> ResolveThresholdValue(
|
||||
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
|
||||
defaultValue,
|
||||
min: 0,
|
||||
max: 6);
|
||||
|
||||
public static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
|
||||
=> ResolveThresholdValue(
|
||||
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
|
||||
defaultValue,
|
||||
min: 0,
|
||||
max: 3);
|
||||
|
||||
public static int ResolveNoToolCallResponseThreshold(string? envRaw)
|
||||
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
|
||||
|
||||
public static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
|
||||
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
|
||||
|
||||
public static int ResolvePlanExecutionRetryMax(string? envRaw)
|
||||
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
|
||||
|
||||
public static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
|
||||
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
|
||||
|
||||
private static int ResolveThresholdValue(string? raw, int defaultValue, int min, int max)
|
||||
{
|
||||
if (!int.TryParse(raw, out var value))
|
||||
return defaultValue;
|
||||
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public partial class AgentLoopService
|
||||
private readonly LlmService _llm;
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly StreamingToolExecutionCoordinator _toolExecutionCoordinator;
|
||||
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||
@@ -79,6 +80,21 @@ public partial class AgentLoopService
|
||||
_llm = llm;
|
||||
_tools = tools;
|
||||
_settings = settings;
|
||||
_toolExecutionCoordinator = new StreamingToolExecutionCoordinator(
|
||||
_llm,
|
||||
ResolveRequestedToolName,
|
||||
async (toolName, input, context, messages, ct) =>
|
||||
{
|
||||
var tool = _tools.Get(toolName);
|
||||
return tool == null
|
||||
? ToolResult.Fail($"도구를 찾을 수 없습니다: {toolName}")
|
||||
: await ExecuteToolWithTimeoutAsync(tool, toolName, input, context, messages, ct);
|
||||
},
|
||||
(eventType, toolName, summary) => EmitEvent(eventType, toolName, summary),
|
||||
IsContextOverflowError,
|
||||
ForceContextRecovery,
|
||||
IsTransientLlmError,
|
||||
ComputeTransientLlmBackoffDelayMs);
|
||||
}
|
||||
|
||||
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
|
||||
@@ -194,11 +210,11 @@ public partial class AgentLoopService
|
||||
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
||||
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
||||
var consecutiveNoToolResponses = 0;
|
||||
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
||||
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
||||
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
||||
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
||||
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
||||
var planExecutionRetryMax = AgentLoopRuntimeThresholds.GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
||||
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
|
||||
var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
|
||||
var terminalEvidenceGateRetryMax = AgentLoopRuntimeThresholds.GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
|
||||
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var runState = new RunState();
|
||||
var requireHighImpactCodeVerification = false;
|
||||
@@ -3675,58 +3691,6 @@ public partial class AgentLoopService
|
||||
.Select(x => $"{x.Key}({x.Value})"));
|
||||
}
|
||||
|
||||
private static int GetNoToolCallResponseThreshold()
|
||||
=> 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()
|
||||
=> 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()
|
||||
=> 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: 3, min: 0, max: 6);
|
||||
|
||||
private static int ResolvePlanExecutionRetryMax(string? envRaw)
|
||||
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
|
||||
|
||||
private static int GetTerminalEvidenceGateMaxRetries()
|
||||
=> 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);
|
||||
|
||||
private static string BuildUnknownToolRecoveryPrompt(
|
||||
string unknownToolName,
|
||||
IReadOnlyCollection<string> activeToolNames)
|
||||
|
||||
@@ -1110,53 +1110,12 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
|
||||
"hash_tool", "image_analyze", "multi_read"
|
||||
};
|
||||
|
||||
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
LlmService.ContentBlock block,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
AgentContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var resolvedToolName = ResolveRequestedToolName(block.ToolName, activeToolNames);
|
||||
block.ResolvedToolName = resolvedToolName;
|
||||
|
||||
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
|
||||
return null;
|
||||
|
||||
var tool = _tools.Get(resolvedToolName);
|
||||
if (tool == null)
|
||||
return null;
|
||||
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
resolvedToolName,
|
||||
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||
var result = await ExecuteToolWithTimeoutAsync(tool, resolvedToolName, input, context, null, ct);
|
||||
sw.Stop();
|
||||
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new LlmService.ToolPrefetchResult(
|
||||
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
|
||||
sw.ElapsedMilliseconds,
|
||||
resolvedToolName);
|
||||
}
|
||||
}
|
||||
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct);
|
||||
|
||||
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||
List<ChatMessage> messages,
|
||||
@@ -1167,75 +1126,15 @@ public partial class AgentLoopService
|
||||
bool forceToolCall = false,
|
||||
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||
{
|
||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (onStreamEventAsync == null)
|
||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
||||
|
||||
var blocks = new List<LlmService.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))
|
||||
{
|
||||
textBuilder.Append(evt.Text);
|
||||
}
|
||||
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||
{
|
||||
blocks.Add(evt.ToolCall);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<LlmService.ContentBlock>();
|
||||
var text = textBuilder.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
|
||||
result.AddRange(blocks);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsContextOverflowError(ex.Message)
|
||||
&& contextRecoveryRetries < 2
|
||||
&& ForceContextRecovery(messages))
|
||||
{
|
||||
contextRecoveryRetries++;
|
||||
if (runState != null)
|
||||
runState.ContextRecoveryAttempts = contextRecoveryRetries;
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 사용자 취소(ct)인 경우 재시도하지 않고 즉시 전파
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
|
||||
if (IsTransientLlmError(ex) && transientRetries < 3)
|
||||
{
|
||||
transientRetries++;
|
||||
if (runState != null)
|
||||
runState.TransientLlmErrorRetries = transientRetries;
|
||||
var delayMs = ComputeTransientLlmBackoffDelayMs(transientRetries, ex);
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
|
||||
await Task.Delay(delayMs, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
|
||||
messages,
|
||||
tools,
|
||||
ct,
|
||||
phaseLabel,
|
||||
runState,
|
||||
forceToolCall,
|
||||
prefetchToolCallAsync,
|
||||
onStreamEventAsync);
|
||||
|
||||
private void ApplyToolPostExecutionBookkeeping(
|
||||
LlmService.ContentBlock call,
|
||||
@@ -1651,7 +1550,7 @@ public partial class AgentLoopService
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private sealed class RunState
|
||||
internal sealed class RunState
|
||||
{
|
||||
public int ContextRecoveryAttempts;
|
||||
public int WithheldRecoveryAttempts;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal sealed class StreamingToolExecutionCoordinator
|
||||
{
|
||||
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",
|
||||
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
|
||||
"hash_tool", "image_analyze", "multi_read"
|
||||
};
|
||||
|
||||
private readonly LlmService _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;
|
||||
private readonly Func<string?, bool> _isContextOverflowError;
|
||||
private readonly Func<List<ChatMessage>, bool> _forceContextRecovery;
|
||||
private readonly Func<Exception, bool> _isTransientLlmError;
|
||||
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
|
||||
|
||||
public StreamingToolExecutionCoordinator(
|
||||
LlmService llm,
|
||||
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
|
||||
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
|
||||
Action<AgentEventType, string, string> emitEvent,
|
||||
Func<string?, bool> isContextOverflowError,
|
||||
Func<List<ChatMessage>, bool> forceContextRecovery,
|
||||
Func<Exception, bool> isTransientLlmError,
|
||||
Func<int, Exception, int> computeTransientBackoffDelayMs)
|
||||
{
|
||||
_llm = llm;
|
||||
_resolveRequestedToolName = resolveRequestedToolName;
|
||||
_executeToolAsync = executeToolAsync;
|
||||
_emitEvent = emitEvent;
|
||||
_isContextOverflowError = isContextOverflowError;
|
||||
_forceContextRecovery = forceContextRecovery;
|
||||
_isTransientLlmError = isTransientLlmError;
|
||||
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
|
||||
}
|
||||
|
||||
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
LlmService.ContentBlock block,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
AgentContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var resolvedToolName = _resolveRequestedToolName(block.ToolName, activeToolNames);
|
||||
block.ResolvedToolName = resolvedToolName;
|
||||
|
||||
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
|
||||
return null;
|
||||
|
||||
_emitEvent(
|
||||
AgentEventType.Thinking,
|
||||
resolvedToolName,
|
||||
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new LlmService.ToolPrefetchResult(
|
||||
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
|
||||
sw.ElapsedMilliseconds,
|
||||
resolvedToolName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<LlmService.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)
|
||||
{
|
||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (onStreamEventAsync == null)
|
||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
||||
|
||||
var blocks = new List<LlmService.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))
|
||||
{
|
||||
textBuilder.Append(evt.Text);
|
||||
}
|
||||
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||
{
|
||||
blocks.Add(evt.ToolCall);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<LlmService.ContentBlock>();
|
||||
var text = textBuilder.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
|
||||
result.AddRange(blocks);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_isContextOverflowError(ex.Message)
|
||||
&& contextRecoveryRetries < 2
|
||||
&& _forceContextRecovery(messages))
|
||||
{
|
||||
contextRecoveryRetries++;
|
||||
if (runState != null)
|
||||
runState.ContextRecoveryAttempts = contextRecoveryRetries;
|
||||
|
||||
_emitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
throw;
|
||||
|
||||
if (_isTransientLlmError(ex) && transientRetries < 3)
|
||||
{
|
||||
transientRetries++;
|
||||
if (runState != null)
|
||||
runState.TransientLlmErrorRetries = transientRetries;
|
||||
|
||||
var delayMs = _computeTransientBackoffDelayMs(transientRetries, ex);
|
||||
_emitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
|
||||
await Task.Delay(delayMs, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user