핵심 엔진을 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:
2026-04-09 21:07:49 +09:00
parent 227f5ab0d3
commit 3c6d2f1ce4
8 changed files with 280 additions and 100 deletions

View File

@@ -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"
}
};

View File

@@ -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;
}

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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;

View File

@@ -8,6 +8,36 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock
{
public string Type { get; init; } = "text"; // "text" | "tool_use"
public string Text { get; init; } = ""; // text 타입일 때
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
public enum ToolStreamEventKind
{
TextDelta,
ToolCallReady,
RetryReset,
Completed
}
public sealed record ToolStreamEvent(
ToolStreamEventKind Kind,
string Text = "",
ContentBlock? ToolCall = null);
/// <summary>
/// LlmService의 Function Calling (tool_use) 확장.
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
@@ -15,35 +45,6 @@ namespace AxCopilot.Services;
/// </summary>
public partial class LlmService
{
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock
{
public string Type { get; init; } = "text"; // "text" | "tool_use"
public string Text { get; init; } = ""; // text 타입일 때
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
public enum ToolStreamEventKind
{
TextDelta,
ToolCallReady,
Completed
}
public sealed record ToolStreamEvent(
ToolStreamEventKind Kind,
string Text = "",
ContentBlock? ToolCall = null);
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -150,14 +151,14 @@ public partial class LlmService
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion);
using var resp = await _http.SendAsync(req, ct);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
var respJson = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;