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 : IToolExecutionCoordinator { private static readonly HashSet PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) { "file_read", "document_read", "env_tool", "datetime_tool", "dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool", "hash_tool", "image_analyze" }; private readonly ILlmService _llm; private readonly Func, string> _resolveRequestedToolName; private readonly Func?, CancellationToken, Task> _executeToolAsync; private readonly Action _emitEvent; private readonly Func _isContextOverflowError; private readonly Func, bool> _forceContextRecovery; private readonly Func _isTransientLlmError; private readonly Func _computeTransientBackoffDelayMs; public StreamingToolExecutionCoordinator( ILlmService llm, Func, string> resolveRequestedToolName, Func?, CancellationToken, Task> executeToolAsync, Action emitEvent, Func isContextOverflowError, Func, bool> forceContextRecovery, Func isTransientLlmError, Func computeTransientBackoffDelayMs) { _llm = llm; _resolveRequestedToolName = resolveRequestedToolName; _executeToolAsync = executeToolAsync; _emitEvent = emitEvent; _isContextOverflowError = isContextOverflowError; _forceContextRecovery = forceContextRecovery; _isTransientLlmError = isTransientLlmError; _computeTransientBackoffDelayMs = computeTransientBackoffDelayMs; } public async Task TryPrefetchReadOnlyToolAsync( ContentBlock block, IReadOnlyCollection 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 ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); } catch (Exception ex) { sw.Stop(); return new ToolPrefetchResult( ToolResult.Fail($"조기 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds, resolvedToolName); } } public async Task> SendWithToolsWithRecoveryAsync( List messages, IReadOnlyCollection tools, CancellationToken ct, string phaseLabel, AgentLoopService.RunState? runState = null, bool forceToolCall = false, Func>? prefetchToolCallAsync = null, Func? 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(); var textBuilder = new StringBuilder(); await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct)) { await onStreamEventAsync(evt); if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) { streamedAnyPartialState = true; textBuilder.Append(evt.Text); } else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null) { streamedAnyPartialState = true; blocks.Add(evt.ToolCall); } } var result = new List(); var text = textBuilder.ToString().Trim(); if (!string.IsNullOrWhiteSpace(text)) result.Add(new ContentBlock { Type = "text", Text = text }); result.AddRange(blocks); return result; } catch (Exception ex) { if (_isContextOverflowError(ex.Message) && contextRecoveryRetries < 2 && _forceContextRecovery(messages)) { if (onStreamEventAsync != null && streamedAnyPartialState) await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); contextRecoveryRetries++; if (runState != null) runState.ContextRecoveryAttempts = contextRecoveryRetries; _emitEvent( AgentEventType.Thinking, "", $"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)"); continue; } if (ct.IsCancellationRequested) throw; if (_isTransientLlmError(ex) && transientRetries < 3) { if (onStreamEventAsync != null && streamedAnyPartialState) await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); 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; } } } }