1574 lines
58 KiB
C#
1574 lines
58 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
public class AgentLoopService
|
|
{
|
|
private class ParallelState
|
|
{
|
|
public int CurrentStep;
|
|
|
|
public int TotalToolCalls;
|
|
|
|
public int MaxIterations;
|
|
|
|
public int ConsecutiveErrors;
|
|
|
|
public int StatsSuccessCount;
|
|
|
|
public int StatsFailCount;
|
|
|
|
public int StatsInputTokens;
|
|
|
|
public int StatsOutputTokens;
|
|
}
|
|
|
|
private readonly LlmService _llm;
|
|
|
|
private readonly ToolRegistry _tools;
|
|
|
|
private readonly SettingsService _settings;
|
|
|
|
private string _conversationId = "";
|
|
|
|
private bool _docFallbackAttempted;
|
|
|
|
private readonly SemaphoreSlim _pauseSemaphore = new SemaphoreSlim(1, 1);
|
|
|
|
private static readonly HashSet<string> VerificationAllowedTools = new HashSet<string> { "file_read", "directory_list" };
|
|
|
|
private static readonly HashSet<string> ReadOnlyTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"file_read", "glob", "grep_tool", "folder_map", "document_read", "search_codebase", "code_search", "env_tool", "datetime_tool", "dev_env_detect",
|
|
"memory", "skill_manager", "json_tool", "regex_tool", "base64_tool", "hash_tool", "image_analyze"
|
|
};
|
|
|
|
public ObservableCollection<AgentEvent> Events { get; } = new ObservableCollection<AgentEvent>();
|
|
|
|
public bool IsRunning { get; private set; }
|
|
|
|
public Action<Action>? Dispatcher { get; set; }
|
|
|
|
public Func<string, string, Task<bool>>? AskPermissionCallback { get; set; }
|
|
|
|
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; set; }
|
|
|
|
public string ActiveTab { get; set; } = "Chat";
|
|
|
|
public bool IsPaused { get; private set; }
|
|
|
|
public Func<string, List<string>, Task<string?>>? UserDecisionCallback { get; set; }
|
|
|
|
public event Action<AgentEvent>? EventOccurred;
|
|
|
|
public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings)
|
|
{
|
|
_llm = llm;
|
|
_tools = tools;
|
|
_settings = settings;
|
|
}
|
|
|
|
public async Task PauseAsync()
|
|
{
|
|
if (!IsPaused && IsRunning)
|
|
{
|
|
await _pauseSemaphore.WaitAsync().ConfigureAwait(continueOnCapturedContext: false);
|
|
IsPaused = true;
|
|
EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다", null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
|
|
public void Resume()
|
|
{
|
|
if (IsPaused)
|
|
{
|
|
IsPaused = false;
|
|
try
|
|
{
|
|
_pauseSemaphore.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
}
|
|
EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다", null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
|
|
public async Task<string> RunAsync(List<ChatMessage> messages, CancellationToken ct = default(CancellationToken))
|
|
{
|
|
if (IsRunning)
|
|
{
|
|
throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
|
}
|
|
IsRunning = true;
|
|
_docFallbackAttempted = false;
|
|
LlmSettings llm = _settings.Settings.Llm;
|
|
int baseMax = ((llm.MaxAgentIterations > 0) ? llm.MaxAgentIterations : 25);
|
|
int maxIterations = baseMax;
|
|
int maxRetry = ((llm.MaxRetryOnError > 0) ? llm.MaxRetryOnError : 3);
|
|
int iteration = 0;
|
|
string userQuery = messages.LastOrDefault((ChatMessage m) => m.Role == "user")?.Content ?? "";
|
|
int consecutiveErrors = 0;
|
|
int totalToolCalls = 0;
|
|
DateTime statsStart = DateTime.Now;
|
|
int statsSuccessCount = 0;
|
|
int statsFailCount = 0;
|
|
int statsInputTokens = 0;
|
|
int statsOutputTokens = 0;
|
|
List<string> statsUsedTools = new List<string>();
|
|
List<string> planSteps = new List<string>();
|
|
int currentStep = 0;
|
|
bool planExtracted = false;
|
|
int planExecutionRetry = 0;
|
|
bool documentPlanCalled = false;
|
|
int postDocumentPlanRetry = 0;
|
|
string documentPlanPath = null;
|
|
string documentPlanTitle = null;
|
|
string documentPlanScaffold = null;
|
|
string planMode = llm.PlanMode ?? "off";
|
|
AgentContext context = BuildContext();
|
|
try
|
|
{
|
|
if (planMode == "always")
|
|
{
|
|
iteration++;
|
|
EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중...", null, 0, 0, null, 0L);
|
|
ChatMessage planInstruction = new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. 각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. 계획만 제시하고 실행은 하지 마세요."
|
|
};
|
|
messages.Add(planInstruction);
|
|
string planText;
|
|
try
|
|
{
|
|
planText = await _llm.SendAsync(messages, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Exception ex2 = ex;
|
|
EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex2.Message, null, 0, 0, null, 0L);
|
|
return "⚠ LLM 오류: " + ex2.Message;
|
|
}
|
|
messages.Remove(planInstruction);
|
|
planSteps = TaskDecomposer.ExtractSteps(planText);
|
|
planExtracted = true;
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L);
|
|
if (UserDecisionCallback != null)
|
|
{
|
|
string decision = await UserDecisionCallback(planText, new List<string> { "승인", "수정 요청", "취소" });
|
|
if (decision == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L);
|
|
return "작업이 취소되었습니다.";
|
|
}
|
|
if (decision != null && decision != "승인")
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = planText
|
|
});
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요."
|
|
});
|
|
for (int retry = 0; retry < 3; retry++)
|
|
{
|
|
try
|
|
{
|
|
planText = await _llm.SendAsync(messages, ct);
|
|
}
|
|
catch
|
|
{
|
|
break;
|
|
}
|
|
planSteps = TaskDecomposer.ExtractSteps(planText);
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"수정된 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L);
|
|
}
|
|
decision = await UserDecisionCallback(planText, new List<string> { "승인", "수정 요청", "취소" });
|
|
if (decision == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L);
|
|
return "작업이 취소되었습니다.";
|
|
}
|
|
if (decision == null || decision == "승인")
|
|
{
|
|
break;
|
|
}
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = planText
|
|
});
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요."
|
|
});
|
|
}
|
|
}
|
|
}
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = planText
|
|
});
|
|
string planSectionsHint = ((planSteps.Count > 0) ? string.Join(", ", planSteps) : "");
|
|
string sectionInstruction = ((!string.IsNullOrEmpty(planSectionsHint)) ? ("document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"" + planSectionsHint + "\"") : "");
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. 텍스트로 설명하지 말고 반드시 도구를 호출하세요." + (string.IsNullOrEmpty(sectionInstruction) ? "" : ("\n" + sectionInstruction))
|
|
});
|
|
}
|
|
else if (!string.IsNullOrEmpty(planText))
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = planText
|
|
});
|
|
}
|
|
}
|
|
while (iteration < maxIterations && !ct.IsCancellationRequested)
|
|
{
|
|
iteration++;
|
|
await _pauseSemaphore.WaitAsync(ct).ConfigureAwait(continueOnCapturedContext: false);
|
|
try
|
|
{
|
|
_pauseSemaphore.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
}
|
|
if (await ContextCondenser.CondenseIfNeededAsync(messages, _llm, llm.MaxContextTokens, ct))
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다", null, 0, 0, null, 0L);
|
|
}
|
|
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})", null, 0, 0, null, 0L);
|
|
if (llm.FreeTierMode && iteration > 1)
|
|
{
|
|
int delaySec = ((llm.FreeTierDelaySeconds > 0) ? llm.FreeTierDelaySeconds : 4);
|
|
EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중...", null, 0, 0, null, 0L);
|
|
await Task.Delay(delaySec * 1000, ct);
|
|
}
|
|
List<LlmService.ContentBlock> blocks;
|
|
try
|
|
{
|
|
IReadOnlyCollection<IAgentTool> activeTools = _tools.GetActiveTools(llm.DisabledTools);
|
|
blocks = await _llm.SendWithToolsAsync(messages, activeTools, ct);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
return await _llm.SendAsync(messages, ct);
|
|
}
|
|
catch (ToolCallNotSupportedException ex5)
|
|
{
|
|
LogService.Warn("[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: " + ex5.Message);
|
|
EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…", null, 0, 0, null, 0L);
|
|
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
|
|
{
|
|
_docFallbackAttempted = true;
|
|
EmitEvent(AgentEventType.Thinking, "", "앱에서 직접 문서를 생성합니다...", null, 0, 0, null, 0L);
|
|
try
|
|
{
|
|
List<ChatMessage> bodyRequest = new List<ChatMessage>
|
|
{
|
|
new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = $"아래 HTML 골격의 각 h2 섹션에 주석의 핵심 항목을 참고하여 풍부한 내용을 채워 완전한 HTML body를 출력하세요. 도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n주제: {documentPlanTitle ?? userQuery}\n\n골격:\n{documentPlanScaffold}"
|
|
}
|
|
};
|
|
string bodyText = await _llm.SendAsync(bodyRequest, ct);
|
|
if (!string.IsNullOrEmpty(bodyText))
|
|
{
|
|
IAgentTool htmlTool = _tools.Get("html_create");
|
|
if (htmlTool != null)
|
|
{
|
|
string fallbackPath = documentPlanPath;
|
|
if (string.IsNullOrEmpty(fallbackPath))
|
|
{
|
|
string safe = ((userQuery.Length > 40) ? userQuery.Substring(0, 40) : userQuery);
|
|
char[] invalidFileNameChars = Path.GetInvalidFileNameChars();
|
|
foreach (char c in invalidFileNameChars)
|
|
{
|
|
safe = safe.Replace(c, '_');
|
|
}
|
|
fallbackPath = safe.Trim() + ".html";
|
|
}
|
|
JsonElement argsJson = JsonSerializer.SerializeToElement(new
|
|
{
|
|
path = fallbackPath,
|
|
title = (documentPlanTitle ?? userQuery),
|
|
body = bodyText,
|
|
toc = true,
|
|
numbered = true,
|
|
mood = "professional",
|
|
cover = new
|
|
{
|
|
title = (documentPlanTitle ?? userQuery),
|
|
author = "AX Copilot Agent"
|
|
}
|
|
});
|
|
ToolResult htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct);
|
|
if (htmlResult.Success)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 생성: " + Path.GetFileName(htmlResult.FilePath ?? ""), htmlResult.FilePath, 0, 0, null, 0L);
|
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L);
|
|
return htmlResult.Output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Exception docEx = ex;
|
|
LogService.Warn("[AgentLoop] document_plan 직접 생성 실패: " + docEx.Message);
|
|
}
|
|
}
|
|
try
|
|
{
|
|
return await _llm.SendAsync(messages, ct);
|
|
}
|
|
catch (Exception ex6)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex6.Message, null, 0, 0, null, 0L);
|
|
return "⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): " + ex6.Message;
|
|
}
|
|
}
|
|
catch (Exception ex7)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex7.Message, null, 0, 0, null, 0L);
|
|
return "⚠ LLM 오류: " + ex7.Message;
|
|
}
|
|
List<string> textParts = new List<string>();
|
|
List<LlmService.ContentBlock> toolCalls = new List<LlmService.ContentBlock>();
|
|
foreach (LlmService.ContentBlock block in blocks)
|
|
{
|
|
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
|
|
{
|
|
textParts.Add(block.Text);
|
|
}
|
|
else if (block.Type == "tool_use")
|
|
{
|
|
toolCalls.Add(block);
|
|
}
|
|
}
|
|
string textResponse = string.Join("\n", textParts);
|
|
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
|
|
{
|
|
planSteps = TaskDecomposer.ExtractSteps(textResponse);
|
|
planExtracted = true;
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L);
|
|
if (planMode == "auto" && toolCalls.Count == 0 && UserDecisionCallback != null)
|
|
{
|
|
string decision2 = await UserDecisionCallback(textResponse, new List<string> { "승인", "수정 요청", "취소" });
|
|
if (decision2 == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L);
|
|
return "작업이 취소되었습니다.";
|
|
}
|
|
if (decision2 != null && decision2 != "승인")
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = decision2
|
|
});
|
|
EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중...", null, 0, 0, null, 0L);
|
|
planExtracted = false;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!string.IsNullOrEmpty(textResponse) && toolCalls.Count > 0)
|
|
{
|
|
string thinkingSummary = ((textResponse.Length > 150) ? (textResponse.Substring(0, 150) + "…") : textResponse);
|
|
EmitEvent(AgentEventType.Thinking, "", thinkingSummary, null, 0, 0, null, 0L);
|
|
}
|
|
if (toolCalls.Count == 0)
|
|
{
|
|
if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < 2)
|
|
{
|
|
planExecutionRetry++;
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = textResponse
|
|
});
|
|
}
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "도구를 호출하지 않았습니다. 계획 1단계를 지금 즉시 도구(tool call)로 실행하세요. 설명 없이 도구 호출만 하세요."
|
|
});
|
|
EmitEvent(AgentEventType.Thinking, "", $"도구 미호출 감지 — 실행 재시도 {planExecutionRetry}/2...", null, 0, 0, null, 0L);
|
|
continue;
|
|
}
|
|
if (documentPlanCalled && postDocumentPlanRetry < 2)
|
|
{
|
|
postDocumentPlanRetry++;
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = textResponse
|
|
});
|
|
}
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "html_create 도구를 호출하지 않았습니다. document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요."
|
|
});
|
|
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/2...", null, 0, 0, null, 0L);
|
|
continue;
|
|
}
|
|
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
|
|
{
|
|
_docFallbackAttempted = true;
|
|
EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다...", null, 0, 0, null, 0L);
|
|
try
|
|
{
|
|
List<ChatMessage> bodyRequest2 = new List<ChatMessage>
|
|
{
|
|
new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = $"아래 HTML 골격의 각 h2 섹션에 주석(<!-- -->)의 핵심 항목을 참고하여 풍부한 내용을 채워 완전한 HTML body를 출력하세요. 도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n주제: {documentPlanTitle ?? userQuery}\n\n골격:\n{documentPlanScaffold}"
|
|
}
|
|
};
|
|
string bodyText2 = await _llm.SendAsync(bodyRequest2, ct);
|
|
if (!string.IsNullOrEmpty(bodyText2))
|
|
{
|
|
IAgentTool htmlTool2 = _tools.Get("html_create");
|
|
if (htmlTool2 != null)
|
|
{
|
|
string fallbackPath2 = documentPlanPath;
|
|
if (string.IsNullOrEmpty(fallbackPath2))
|
|
{
|
|
string safe2 = ((userQuery.Length > 40) ? userQuery.Substring(0, 40) : userQuery);
|
|
char[] invalidFileNameChars2 = Path.GetInvalidFileNameChars();
|
|
foreach (char c2 in invalidFileNameChars2)
|
|
{
|
|
safe2 = safe2.Replace(c2, '_');
|
|
}
|
|
fallbackPath2 = safe2.Trim() + ".html";
|
|
}
|
|
JsonElement argsJson2 = JsonSerializer.SerializeToElement(new
|
|
{
|
|
path = fallbackPath2,
|
|
title = (documentPlanTitle ?? userQuery),
|
|
body = bodyText2,
|
|
toc = true,
|
|
numbered = true,
|
|
mood = "professional",
|
|
cover = new
|
|
{
|
|
title = (documentPlanTitle ?? userQuery),
|
|
author = "AX Copilot Agent"
|
|
}
|
|
});
|
|
ToolResult htmlResult2 = await htmlTool2.ExecuteAsync(argsJson2, context, ct);
|
|
if (htmlResult2.Success)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 생성: " + Path.GetFileName(htmlResult2.FilePath ?? ""), htmlResult2.FilePath, 0, 0, null, 0L);
|
|
textResponse = htmlResult2.Output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Exception ex8 = ex;
|
|
EmitEvent(AgentEventType.Thinking, "", "직접 생성 실패: " + ex8.Message, null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
if (!_docFallbackAttempted && totalToolCalls == 0 && !string.IsNullOrEmpty(textResponse) && IsDocumentCreationRequest(userQuery))
|
|
{
|
|
_docFallbackAttempted = true;
|
|
string savedPath = AutoSaveAsHtml(textResponse, userQuery, context);
|
|
if (savedPath != null)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 자동 생성: " + Path.GetFileName(savedPath), savedPath, 0, 0, null, 0L);
|
|
textResponse = textResponse + "\n\n\ud83d\udcc4 파일이 저장되었습니다: " + savedPath;
|
|
}
|
|
}
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
{
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = textResponse
|
|
});
|
|
}
|
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L);
|
|
return textResponse;
|
|
}
|
|
List<object> contentBlocks = new List<object>();
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
{
|
|
contentBlocks.Add(new
|
|
{
|
|
type = "text",
|
|
text = textResponse
|
|
});
|
|
}
|
|
foreach (LlmService.ContentBlock tc in toolCalls)
|
|
{
|
|
contentBlocks.Add(new
|
|
{
|
|
type = "tool_use",
|
|
id = tc.ToolId,
|
|
name = tc.ToolName,
|
|
input = tc.ToolInput
|
|
});
|
|
}
|
|
string assistantContent = JsonSerializer.Serialize(new
|
|
{
|
|
_tool_use_blocks = contentBlocks
|
|
});
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = assistantContent
|
|
});
|
|
if (llm.EnableParallelTools && toolCalls.Count > 1)
|
|
{
|
|
List<LlmService.ContentBlock> parallelBatch;
|
|
List<LlmService.ContentBlock> sequentialBatch;
|
|
(parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
|
|
if (parallelBatch.Count > 1)
|
|
{
|
|
ParallelState pState = new ParallelState
|
|
{
|
|
CurrentStep = currentStep,
|
|
TotalToolCalls = totalToolCalls,
|
|
MaxIterations = maxIterations,
|
|
ConsecutiveErrors = consecutiveErrors,
|
|
StatsSuccessCount = statsSuccessCount,
|
|
StatsFailCount = statsFailCount,
|
|
StatsInputTokens = statsInputTokens,
|
|
StatsOutputTokens = statsOutputTokens
|
|
};
|
|
await ExecuteToolsInParallelAsync(parallelBatch, messages, context, planSteps, pState, baseMax, maxRetry, llm, iteration, ct, statsUsedTools);
|
|
currentStep = pState.CurrentStep;
|
|
totalToolCalls = pState.TotalToolCalls;
|
|
maxIterations = pState.MaxIterations;
|
|
consecutiveErrors = pState.ConsecutiveErrors;
|
|
statsSuccessCount = pState.StatsSuccessCount;
|
|
statsFailCount = pState.StatsFailCount;
|
|
statsInputTokens = pState.StatsInputTokens;
|
|
statsOutputTokens = pState.StatsOutputTokens;
|
|
}
|
|
toolCalls = sequentialBatch;
|
|
if (toolCalls.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
foreach (LlmService.ContentBlock call in toolCalls)
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
IAgentTool tool = _tools.Get(call.ToolName);
|
|
if (tool == null)
|
|
{
|
|
string errResult = "알 수 없는 도구: " + call.ToolName;
|
|
EmitEvent(AgentEventType.Error, call.ToolName, errResult, null, 0, 0, null, 0L);
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult));
|
|
continue;
|
|
}
|
|
if (planSteps.Count > 0)
|
|
{
|
|
int newStep = TaskDecomposer.EstimateCurrentStep(toolSummary: FormatToolCallSummary(call), steps: planSteps, toolName: call.ToolName, lastStep: currentStep);
|
|
if (newStep != currentStep)
|
|
{
|
|
currentStep = newStep;
|
|
EmitEvent(AgentEventType.StepStart, "", planSteps[currentStep], null, currentStep + 1, planSteps.Count, null, 0L);
|
|
}
|
|
}
|
|
if (context.DevMode)
|
|
{
|
|
string paramJson = call.ToolInput?.ToString() ?? "{}";
|
|
if (paramJson.Length > 500)
|
|
{
|
|
paramJson = paramJson.Substring(0, 500) + "...";
|
|
}
|
|
EmitEvent(AgentEventType.Thinking, call.ToolName, "[DEV] 도구 호출: " + call.ToolName + "\n파라미터: " + paramJson, null, 0, 0, null, 0L);
|
|
}
|
|
EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call), null, 0, 0, null, 0L);
|
|
if (context.DevModeStepApproval && UserDecisionCallback != null)
|
|
{
|
|
string decision3 = await UserDecisionCallback("[DEV] 도구 '" + call.ToolName + "' 실행을 승인하시겠습니까?\n" + FormatToolCallSummary(call), new List<string> { "승인", "건너뛰기", "중단" });
|
|
if (decision3 == "중단")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다", null, 0, 0, null, 0L);
|
|
return "사용자가 개발자 모드에서 실행을 중단했습니다.";
|
|
}
|
|
if (decision3 == "건너뛰기")
|
|
{
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
|
|
continue;
|
|
}
|
|
}
|
|
string decisionRequired = CheckDecisionRequired(call, context);
|
|
if (decisionRequired != null && UserDecisionCallback != null)
|
|
{
|
|
string decision4 = await UserDecisionCallback(decisionRequired, new List<string> { "승인", "건너뛰기", "취소" });
|
|
if (decision4 == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L);
|
|
return "사용자가 작업을 취소했습니다.";
|
|
}
|
|
if (decision4 == "건너뛰기")
|
|
{
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
|
|
continue;
|
|
}
|
|
}
|
|
if (llm.EnableToolHooks && llm.AgentHooks.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
foreach (HookExecutionResult pr in (await AgentHookRunner.RunAsync(llm.AgentHooks, call.ToolName, "pre", call.ToolInput.ToString(), null, success: true, context.WorkFolder, llm.ToolHookTimeoutMs, ct)).Where((HookExecutionResult r) => !r.Success))
|
|
{
|
|
EmitEvent(AgentEventType.Error, call.ToolName, "[Hook:" + pr.HookName + "] " + pr.Output, null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
ToolResult result;
|
|
try
|
|
{
|
|
JsonElement input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
|
result = await tool.ExecuteAsync(input, context, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.", null, 0, 0, null, 0L);
|
|
return "사용자가 작업을 취소했습니다.";
|
|
}
|
|
catch (Exception ex10)
|
|
{
|
|
result = ToolResult.Fail("도구 실행 오류: " + ex10.Message);
|
|
}
|
|
sw.Stop();
|
|
if (llm.EnableToolHooks && llm.AgentHooks.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
foreach (HookExecutionResult pr2 in (await AgentHookRunner.RunAsync(llm.AgentHooks, call.ToolName, "post", call.ToolInput.ToString(), TruncateOutput(result.Output, 2048), result.Success, context.WorkFolder, llm.ToolHookTimeoutMs, ct)).Where((HookExecutionResult r) => !r.Success))
|
|
{
|
|
EmitEvent(AgentEventType.Error, call.ToolName, "[Hook:" + pr2.HookName + "] " + pr2.Output, null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
if (context.DevMode)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, call.ToolName, "[DEV] 결과: " + (result.Success ? "성공" : "실패") + "\n" + TruncateOutput(result.Output, 500), null, 0, 0, null, 0L);
|
|
}
|
|
TokenUsage tokenUsage = _llm.LastTokenUsage;
|
|
EmitEvent(result.Success ? AgentEventType.ToolResult : AgentEventType.Error, call.ToolName, TruncateOutput(result.Output, 200), result.FilePath, 0, 0, null, sw.ElapsedMilliseconds, tokenUsage?.PromptTokens ?? 0, tokenUsage?.CompletionTokens ?? 0, call.ToolInput?.ToString(), iteration);
|
|
if (result.Success)
|
|
{
|
|
statsSuccessCount++;
|
|
}
|
|
else
|
|
{
|
|
statsFailCount++;
|
|
}
|
|
statsInputTokens += tokenUsage?.PromptTokens ?? 0;
|
|
statsOutputTokens += tokenUsage?.CompletionTokens ?? 0;
|
|
if (!statsUsedTools.Contains(call.ToolName))
|
|
{
|
|
statsUsedTools.Add(call.ToolName);
|
|
}
|
|
if (llm.EnableAuditLog)
|
|
{
|
|
AuditLogService.LogToolCall(_conversationId, ActiveTab ?? "", call.ToolName, call.ToolInput.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success);
|
|
}
|
|
totalToolCalls++;
|
|
if (totalToolCalls > 15 && maxIterations < baseMax * 2)
|
|
{
|
|
maxIterations = Math.Min(baseMax * 2, 50);
|
|
}
|
|
if (call.ToolName == "test_loop" && result.Output.Contains("[AUTO_FIX:"))
|
|
{
|
|
int testFixMax = ((llm.MaxTestFixIterations > 0) ? llm.MaxTestFixIterations : 5);
|
|
int testFixBudget = baseMax + testFixMax * 3;
|
|
if (maxIterations < testFixBudget)
|
|
{
|
|
maxIterations = Math.Min(testFixBudget, 60);
|
|
}
|
|
}
|
|
await Task.Delay(80, ct);
|
|
if (!result.Success)
|
|
{
|
|
consecutiveErrors++;
|
|
if (consecutiveErrors <= maxRetry)
|
|
{
|
|
messages.Add(LlmService.CreateToolResultMessage(result: $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output, 500)}]\nAnalyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. Try a different approach. (Error {consecutiveErrors}/{maxRetry})", toolId: call.ToolId, toolName: call.ToolName));
|
|
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({consecutiveErrors}/{maxRetry})", null, 0, 0, null, 0L);
|
|
continue;
|
|
}
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, $"[FAILED after {maxRetry} retries] {TruncateOutput(result.Output, 500)}\n" + "Stop retrying this tool. Explain the error to the user and suggest alternative approaches."));
|
|
try
|
|
{
|
|
App app = Application.Current as App;
|
|
AgentMemoryService memSvc = app?.MemoryService;
|
|
if (memSvc != null && app?.SettingsService?.Settings.Llm.EnableAgentMemory == true)
|
|
{
|
|
memSvc.Add("correction", "도구 '" + call.ToolName + "' 반복 실패: " + TruncateOutput(result.Output, 200), "conv:" + _conversationId, context.WorkFolder);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
continue;
|
|
}
|
|
consecutiveErrors = 0;
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000)));
|
|
if (call.ToolName == "document_plan")
|
|
{
|
|
documentPlanCalled = true;
|
|
string po = result.Output;
|
|
Match pm = Regex.Match(po, "path:\\s*\"([^\"]+)\"");
|
|
if (pm.Success)
|
|
{
|
|
documentPlanPath = pm.Groups[1].Value;
|
|
}
|
|
Match tm = Regex.Match(po, "title:\\s*\"([^\"]+)\"");
|
|
if (tm.Success)
|
|
{
|
|
documentPlanTitle = tm.Groups[1].Value;
|
|
}
|
|
int bs = po.IndexOf("--- body 시작 ---", StringComparison.Ordinal);
|
|
int be = po.IndexOf("--- body 끝 ---", StringComparison.Ordinal);
|
|
if (bs >= 0 && be > bs)
|
|
{
|
|
int num3 = bs + "--- body 시작 ---".Length;
|
|
documentPlanScaffold = po.Substring(num3, be - num3).Trim();
|
|
}
|
|
}
|
|
if (call.ToolName == "document_plan" && result.Output.Contains("즉시 실행:"))
|
|
{
|
|
string toolHint = (result.Output.Contains("html_create") ? "html_create" : (result.Output.Contains("document_assemble") ? "document_assemble" : (result.Output.Contains("file_write") ? "file_write" : "html_create")));
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = $"document_plan이 완료되었습니다. 위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 {toolHint} 도구를 지금 즉시 호출하세요. 각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요."
|
|
});
|
|
EmitEvent(AgentEventType.Thinking, "", "문서 개요 완성 — " + toolHint + " 호출 중...", null, 0, 0, null, 0L);
|
|
}
|
|
if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1)
|
|
{
|
|
if ((!(ActiveTab == "Code")) ? (llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName)) : (llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)))
|
|
{
|
|
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
|
|
iteration++;
|
|
}
|
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L);
|
|
return result.Output;
|
|
}
|
|
if (((!(ActiveTab == "Code")) ? (llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName)) : (llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName))) && result.Success)
|
|
{
|
|
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
|
|
iteration++;
|
|
}
|
|
}
|
|
}
|
|
if (iteration >= maxIterations)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)", null, 0, 0, null, 0L);
|
|
return "⚠ 에이전트가 최대 반복 횟수에 도달했습니다.";
|
|
}
|
|
return "(취소됨)";
|
|
}
|
|
finally
|
|
{
|
|
IsRunning = false;
|
|
if (IsPaused)
|
|
{
|
|
IsPaused = false;
|
|
try
|
|
{
|
|
_pauseSemaphore.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
}
|
|
}
|
|
if (totalToolCalls > 0)
|
|
{
|
|
long durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
|
|
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
|
|
{
|
|
Timestamp = statsStart,
|
|
Tab = (ActiveTab ?? ""),
|
|
Model = (_settings.Settings.Llm.Model ?? ""),
|
|
ToolCalls = totalToolCalls,
|
|
SuccessCount = statsSuccessCount,
|
|
FailCount = statsFailCount,
|
|
InputTokens = statsInputTokens,
|
|
OutputTokens = statsOutputTokens,
|
|
DurationMs = durationMs,
|
|
UsedTools = statsUsedTools
|
|
});
|
|
if (llm.ShowTotalCallStats)
|
|
{
|
|
int totalTokens = statsInputTokens + statsOutputTokens;
|
|
double durationSec = (double)durationMs / 1000.0;
|
|
string toolList = string.Join(", ", statsUsedTools);
|
|
string summary = $"\ud83d\udcca 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | 토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | 소요 {durationSec:F1}초 | 사용 도구: {toolList}";
|
|
EmitEvent(AgentEventType.StepDone, "total_stats", summary, null, 0, 0, null, 0L);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private string? AutoSaveAsHtml(string textContent, string userQuery, AgentContext context)
|
|
{
|
|
try
|
|
{
|
|
string text = ((userQuery.Length > 60) ? userQuery.Substring(0, 60) : userQuery);
|
|
string[] array = new string[14]
|
|
{
|
|
"작성해줘", "작성해 줘", "만들어줘", "만들어 줘", "써줘", "써 줘", "생성해줘", "생성해 줘", "작성해", "만들어",
|
|
"생성해", "해줘", "해 줘", "부탁해"
|
|
};
|
|
string text2 = text;
|
|
string[] array2 = array;
|
|
foreach (string oldValue in array2)
|
|
{
|
|
text2 = text2.Replace(oldValue, "", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
char[] invalidFileNameChars = Path.GetInvalidFileNameChars();
|
|
foreach (char oldChar in invalidFileNameChars)
|
|
{
|
|
text2 = text2.Replace(oldChar, '_');
|
|
}
|
|
text2 = text2.Trim().TrimEnd('.').Trim();
|
|
string path = text2 + ".html";
|
|
string text3 = FileReadTool.ResolvePath(path, context.WorkFolder);
|
|
if (context.ActiveTab == "Cowork")
|
|
{
|
|
text3 = AgentContext.EnsureTimestampedPath(text3);
|
|
}
|
|
string directoryName = Path.GetDirectoryName(text3);
|
|
if (!string.IsNullOrEmpty(directoryName))
|
|
{
|
|
Directory.CreateDirectory(directoryName);
|
|
}
|
|
string css = TemplateService.GetCss("professional");
|
|
string value = ConvertTextToHtml(textContent);
|
|
string contents = $"<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\">\n<title>{EscapeHtml(text)}</title>\n<style>\n{css}\n.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}\n.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}\n.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}\n.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}\n.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}\n.doc p {{ line-height: 1.8; margin-bottom: 12px; }}\n.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}\n.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}\n.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}\n.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}\n.doc tr:nth-child(even) {{ background: #f8f9fa; }}\n</style>\n</head>\n<body>\n<div class=\"doc\">\n<h1>{EscapeHtml(text)}</h1>\n<div class=\"meta\">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>\n{value}\n</div>\n</body>\n</html>";
|
|
File.WriteAllText(text3, contents, Encoding.UTF8);
|
|
LogService.Info("[AgentLoop] 문서 자동 저장 완료: " + text3);
|
|
return text3;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn("[AgentLoop] 문서 자동 저장 실패: " + ex.Message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string ConvertTextToHtml(string text)
|
|
{
|
|
StringBuilder stringBuilder = new StringBuilder();
|
|
string[] array = text.Split('\n');
|
|
bool flag = false;
|
|
string value = "ul";
|
|
string[] array2 = array;
|
|
foreach (string text2 in array2)
|
|
{
|
|
string text3 = text2.TrimEnd();
|
|
if (string.IsNullOrWhiteSpace(text3))
|
|
{
|
|
if (flag)
|
|
{
|
|
StringBuilder stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder3 = stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder3.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
}
|
|
else if (text3.StartsWith("### "))
|
|
{
|
|
StringBuilder stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler;
|
|
if (flag)
|
|
{
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder4 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder4.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder5 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<h3>");
|
|
string text4 = text3;
|
|
handler.AppendFormatted(EscapeHtml(text4.Substring(4, text4.Length - 4)));
|
|
handler.AppendLiteral("</h3>");
|
|
stringBuilder5.AppendLine(ref handler);
|
|
}
|
|
else if (text3.StartsWith("## "))
|
|
{
|
|
StringBuilder stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler;
|
|
if (flag)
|
|
{
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder6 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder6.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder7 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<h2>");
|
|
string text4 = text3;
|
|
handler.AppendFormatted(EscapeHtml(text4.Substring(3, text4.Length - 3)));
|
|
handler.AppendLiteral("</h2>");
|
|
stringBuilder7.AppendLine(ref handler);
|
|
}
|
|
else if (text3.StartsWith("# "))
|
|
{
|
|
StringBuilder stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler;
|
|
if (flag)
|
|
{
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder8 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder8.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder9 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<h2>");
|
|
string text4 = text3;
|
|
handler.AppendFormatted(EscapeHtml(text4.Substring(2, text4.Length - 2)));
|
|
handler.AppendLiteral("</h2>");
|
|
stringBuilder9.AppendLine(ref handler);
|
|
}
|
|
else if (Regex.IsMatch(text3, "^\\d+\\.\\s+\\S"))
|
|
{
|
|
string text5 = Regex.Replace(text3, "^\\d+\\.\\s+", "");
|
|
if (text5.Length < 80 && !text5.Contains('.') && !text3.StartsWith(" "))
|
|
{
|
|
StringBuilder stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler;
|
|
if (flag)
|
|
{
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder10 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder10.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder11 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<h2>");
|
|
handler.AppendFormatted(EscapeHtml(text3));
|
|
handler.AppendLiteral("</h2>");
|
|
stringBuilder11.AppendLine(ref handler);
|
|
}
|
|
else
|
|
{
|
|
if (!flag)
|
|
{
|
|
stringBuilder.AppendLine("<ol>");
|
|
flag = true;
|
|
value = "ol";
|
|
}
|
|
StringBuilder stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder12 = stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<li>");
|
|
handler.AppendFormatted(EscapeHtml(text5));
|
|
handler.AppendLiteral("</li>");
|
|
stringBuilder12.AppendLine(ref handler);
|
|
}
|
|
}
|
|
else if (text3.TrimStart().StartsWith("- ") || text3.TrimStart().StartsWith("* ") || text3.TrimStart().StartsWith("• "))
|
|
{
|
|
string text4 = text3.TrimStart();
|
|
string text6 = text4.Substring(2, text4.Length - 2).Trim();
|
|
if (!flag)
|
|
{
|
|
stringBuilder.AppendLine("<ul>");
|
|
flag = true;
|
|
value = "ul";
|
|
}
|
|
StringBuilder stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder13 = stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
|
|
handler.AppendLiteral("<li>");
|
|
handler.AppendFormatted(EscapeHtml(text6));
|
|
handler.AppendLiteral("</li>");
|
|
stringBuilder13.AppendLine(ref handler);
|
|
}
|
|
else
|
|
{
|
|
StringBuilder stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler;
|
|
if (flag)
|
|
{
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder14 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder14.AppendLine(ref handler);
|
|
flag = false;
|
|
}
|
|
stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder15 = stringBuilder2;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
|
|
handler.AppendLiteral("<p>");
|
|
handler.AppendFormatted(EscapeHtml(text3));
|
|
handler.AppendLiteral("</p>");
|
|
stringBuilder15.AppendLine(ref handler);
|
|
}
|
|
}
|
|
if (flag)
|
|
{
|
|
StringBuilder stringBuilder2 = stringBuilder;
|
|
StringBuilder stringBuilder16 = stringBuilder2;
|
|
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
|
|
handler.AppendLiteral("</");
|
|
handler.AppendFormatted(value);
|
|
handler.AppendLiteral(">");
|
|
stringBuilder16.AppendLine(ref handler);
|
|
}
|
|
return stringBuilder.ToString();
|
|
}
|
|
|
|
private static string EscapeHtml(string text)
|
|
{
|
|
return text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
|
}
|
|
|
|
private static bool IsDocumentCreationRequest(string query)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
return false;
|
|
}
|
|
string[] source = new string[24]
|
|
{
|
|
"보고서", "리포트", "report", "문서", "작성해", "써줘", "써 줘", "만들어", "분석서", "제안서",
|
|
"기획서", "회의록", "매뉴얼", "가이드", "excel", "엑셀", "docx", "word", "html", "pptx",
|
|
"ppt", "프레젠테이션", "발표자료", "슬라이드"
|
|
};
|
|
string q = query.ToLowerInvariant();
|
|
return source.Any((string k) => q.Contains(k, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static bool IsDocumentCreationTool(string toolName)
|
|
{
|
|
switch (toolName)
|
|
{
|
|
case "file_write":
|
|
case "docx_create":
|
|
case "html_create":
|
|
case "excel_create":
|
|
case "csv_create":
|
|
case "script_create":
|
|
case "pptx_create":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsTerminalDocumentTool(string toolName)
|
|
{
|
|
switch (toolName)
|
|
{
|
|
case "html_create":
|
|
case "docx_create":
|
|
case "excel_create":
|
|
case "pptx_create":
|
|
case "document_assemble":
|
|
case "csv_create":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsCodeVerificationTarget(string toolName)
|
|
{
|
|
switch (toolName)
|
|
{
|
|
case "file_write":
|
|
case "file_edit":
|
|
case "script_create":
|
|
case "process":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task RunPostToolVerificationAsync(List<ChatMessage> messages, string toolName, ToolResult result, AgentContext context, CancellationToken ct)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", "\ud83d\udd0d 생성 결과물 검증 중...", null, 0, 0, null, 0L);
|
|
string filePath = result.FilePath ?? "";
|
|
string fileRef = (string.IsNullOrEmpty(filePath) ? "방금 생성한 결과물" : ("파일 '" + filePath + "'"));
|
|
string checkList = ((ActiveTab == "Code") ? " - 구문 오류가 없는가?\n - 참조하는 클래스/메서드/변수가 존재하는가?\n - 코딩 컨벤션이 일관적인가?\n - 에지 케이스 처리가 누락되지 않았는가?" : " - 사용자 요청에 맞는 내용이 모두 포함되었는가?\n - 구조와 형식이 올바른가?\n - 누락된 섹션이나 불완전한 내용이 없는가?\n - 한국어 맞춤법/표현이 자연스러운가?");
|
|
ChatMessage verificationPrompt = new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "[System:Verification] " + fileRef + "을 검증하세요.\n1. file_read 도구로 생성된 파일의 내용을 읽으세요.\n2. 다음 항목을 확인하세요:\n" + checkList + "\n3. 결과를 간단히 보고하세요. 문제가 있으면 구체적으로 무엇이 잘못되었는지 설명하세요.\n⚠\ufe0f 중요: 이 단계에서는 파일을 직접 수정하지 마세요. 보고만 하세요."
|
|
};
|
|
_ = messages.Count;
|
|
messages.Add(verificationPrompt);
|
|
List<ChatMessage> addedMessages = new List<ChatMessage> { verificationPrompt };
|
|
try
|
|
{
|
|
IReadOnlyCollection<IAgentTool> allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools);
|
|
List<IAgentTool> readOnlyTools = allTools.Where((IAgentTool t) => VerificationAllowedTools.Contains(t.Name)).ToList();
|
|
List<LlmService.ContentBlock> verifyBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct);
|
|
List<string> verifyText = new List<string>();
|
|
List<LlmService.ContentBlock> verifyToolCalls = new List<LlmService.ContentBlock>();
|
|
foreach (LlmService.ContentBlock block in verifyBlocks)
|
|
{
|
|
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
|
|
{
|
|
verifyText.Add(block.Text);
|
|
}
|
|
else if (block.Type == "tool_use")
|
|
{
|
|
verifyToolCalls.Add(block);
|
|
}
|
|
}
|
|
string verifyResponse = string.Join("\n", verifyText);
|
|
if (verifyToolCalls.Count > 0)
|
|
{
|
|
List<object> contentBlocks = new List<object>();
|
|
if (!string.IsNullOrEmpty(verifyResponse))
|
|
{
|
|
contentBlocks.Add(new
|
|
{
|
|
type = "text",
|
|
text = verifyResponse
|
|
});
|
|
}
|
|
foreach (LlmService.ContentBlock tc in verifyToolCalls)
|
|
{
|
|
contentBlocks.Add(new
|
|
{
|
|
type = "tool_use",
|
|
id = tc.ToolId,
|
|
name = tc.ToolName,
|
|
input = tc.ToolInput
|
|
});
|
|
}
|
|
string assistantContent = JsonSerializer.Serialize(new
|
|
{
|
|
_tool_use_blocks = contentBlocks
|
|
});
|
|
ChatMessage assistantMsg = new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = assistantContent
|
|
};
|
|
messages.Add(assistantMsg);
|
|
addedMessages.Add(assistantMsg);
|
|
foreach (LlmService.ContentBlock tc2 in verifyToolCalls)
|
|
{
|
|
IAgentTool tool = _tools.Get(tc2.ToolName);
|
|
if (tool == null)
|
|
{
|
|
ChatMessage errMsg = LlmService.CreateToolResultMessage(tc2.ToolId, tc2.ToolName, "검증 단계에서는 읽기 도구만 사용 가능합니다.");
|
|
messages.Add(errMsg);
|
|
addedMessages.Add(errMsg);
|
|
continue;
|
|
}
|
|
EmitEvent(AgentEventType.ToolCall, tc2.ToolName, "[검증] " + FormatToolCallSummary(tc2), null, 0, 0, null, 0L);
|
|
try
|
|
{
|
|
JsonElement input = tc2.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
|
ChatMessage toolMsg = LlmService.CreateToolResultMessage(result: TruncateOutput((await tool.ExecuteAsync(input, context, ct)).Output, 4000), toolId: tc2.ToolId, toolName: tc2.ToolName);
|
|
messages.Add(toolMsg);
|
|
addedMessages.Add(toolMsg);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Exception ex2 = ex;
|
|
ChatMessage errMsg2 = LlmService.CreateToolResultMessage(tc2.ToolId, tc2.ToolName, "검증 도구 실행 오류: " + ex2.Message);
|
|
messages.Add(errMsg2);
|
|
addedMessages.Add(errMsg2);
|
|
}
|
|
}
|
|
verifyResponse = string.Join("\n", from b in await _llm.SendWithToolsAsync(messages, readOnlyTools, ct)
|
|
where b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text)
|
|
select b.Text);
|
|
}
|
|
if (!string.IsNullOrEmpty(verifyResponse))
|
|
{
|
|
string summary = ((verifyResponse.Length > 300) ? (verifyResponse.Substring(0, 300) + "…") : verifyResponse);
|
|
EmitEvent(AgentEventType.Thinking, "", "✅ 검증 결과: " + summary, null, 0, 0, null, 0L);
|
|
if (verifyResponse.Contains("문제") || verifyResponse.Contains("수정") || verifyResponse.Contains("누락") || verifyResponse.Contains("오류") || verifyResponse.Contains("잘못") || verifyResponse.Contains("부족"))
|
|
{
|
|
foreach (ChatMessage msg in addedMessages)
|
|
{
|
|
messages.Remove(msg);
|
|
}
|
|
messages.Add(new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = $"[System] 방금 생성한 {fileRef}에 대한 자동 검증 결과, 다음 문제가 발견되었습니다:\n{verifyResponse}\n\n위 문제를 수정해 주세요."
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex3)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", "검증 LLM 호출 실패: " + ex3.Message, null, 0, 0, null, 0L);
|
|
}
|
|
foreach (ChatMessage msg2 in addedMessages)
|
|
{
|
|
messages.Remove(msg2);
|
|
}
|
|
}
|
|
|
|
private AgentContext BuildContext()
|
|
{
|
|
LlmSettings llm = _settings.Settings.Llm;
|
|
return new AgentContext
|
|
{
|
|
WorkFolder = llm.WorkFolder,
|
|
Permission = llm.FilePermission,
|
|
BlockedPaths = llm.BlockedPaths,
|
|
BlockedExtensions = llm.BlockedExtensions,
|
|
AskPermission = AskPermissionCallback,
|
|
UserDecision = UserDecisionCallback,
|
|
UserAskCallback = UserAskCallback,
|
|
ToolPermissions = (llm.ToolPermissions ?? new Dictionary<string, string>()),
|
|
ActiveTab = ActiveTab,
|
|
DevMode = llm.DevMode,
|
|
DevModeStepApproval = llm.DevModeStepApproval
|
|
};
|
|
}
|
|
|
|
private void EmitEvent(AgentEventType type, string toolName, string summary, string? filePath = null, int stepCurrent = 0, int stepTotal = 0, List<string>? steps = null, long elapsedMs = 0L, int inputTokens = 0, int outputTokens = 0, string? toolInput = null, int iteration = 0)
|
|
{
|
|
string agentLogLevel = _settings.Settings.Llm.AgentLogLevel;
|
|
bool flag = agentLogLevel == "simple";
|
|
bool flag2 = flag;
|
|
if (flag2)
|
|
{
|
|
bool flag3 = (uint)type <= 1u;
|
|
flag2 = flag3;
|
|
}
|
|
if (flag2)
|
|
{
|
|
return;
|
|
}
|
|
if (agentLogLevel == "simple" && summary.Length > 200)
|
|
{
|
|
summary = summary.Substring(0, 200) + "…";
|
|
}
|
|
if (agentLogLevel != "debug")
|
|
{
|
|
toolInput = null;
|
|
}
|
|
AgentEvent evt = new AgentEvent
|
|
{
|
|
Type = type,
|
|
ToolName = toolName,
|
|
Summary = summary,
|
|
FilePath = filePath,
|
|
Success = (type != AgentEventType.Error),
|
|
StepCurrent = stepCurrent,
|
|
StepTotal = stepTotal,
|
|
Steps = steps,
|
|
ElapsedMs = elapsedMs,
|
|
InputTokens = inputTokens,
|
|
OutputTokens = outputTokens,
|
|
ToolInput = toolInput,
|
|
Iteration = iteration
|
|
};
|
|
if (Dispatcher != null)
|
|
{
|
|
Dispatcher(delegate
|
|
{
|
|
Events.Add(evt);
|
|
this.EventOccurred?.Invoke(evt);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Events.Add(evt);
|
|
this.EventOccurred?.Invoke(evt);
|
|
}
|
|
}
|
|
|
|
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
|
|
{
|
|
string text = ((!(Application.Current is App app)) ? null : app.SettingsService?.Settings.Llm.AgentDecisionLevel) ?? "normal";
|
|
string text2 = call.ToolName ?? "";
|
|
JsonElement? toolInput = call.ToolInput;
|
|
if (text2 == "git_tool")
|
|
{
|
|
JsonElement value;
|
|
string text3 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("action", out value)) ? value.GetString() : "");
|
|
if (text3 == "commit")
|
|
{
|
|
JsonElement value2;
|
|
string text4 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("args", out value2)) ? value2.GetString() : "");
|
|
return "Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: " + text4;
|
|
}
|
|
}
|
|
if (text == "minimal")
|
|
{
|
|
if (text2 == "process")
|
|
{
|
|
JsonElement value3;
|
|
string text5 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("command", out value3)) ? value3.GetString() : "");
|
|
return "외부 명령을 실행하시겠습니까?\n\n명령: " + text5;
|
|
}
|
|
return null;
|
|
}
|
|
if (text == "normal" || text == "detailed")
|
|
{
|
|
if (text2 == "process")
|
|
{
|
|
JsonElement value4;
|
|
string text6 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("command", out value4)) ? value4.GetString() : "");
|
|
return "외부 명령을 실행하시겠습니까?\n\n명령: " + text6;
|
|
}
|
|
if (text2 == "file_write")
|
|
{
|
|
JsonElement value5;
|
|
string text7 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value5)) ? value5.GetString() : "");
|
|
if (!string.IsNullOrEmpty(text7))
|
|
{
|
|
string path = (Path.IsPathRooted(text7) ? text7 : Path.Combine(context.WorkFolder, text7 ?? ""));
|
|
if (!File.Exists(path))
|
|
{
|
|
return "새 파일을 생성하시겠습니까?\n\n경로: " + text7;
|
|
}
|
|
}
|
|
}
|
|
bool flag;
|
|
switch (text2)
|
|
{
|
|
case "excel_create":
|
|
case "docx_create":
|
|
case "html_create":
|
|
case "csv_create":
|
|
case "script_create":
|
|
flag = true;
|
|
break;
|
|
default:
|
|
flag = false;
|
|
break;
|
|
}
|
|
if (flag)
|
|
{
|
|
JsonElement value6;
|
|
string text8 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value6)) ? value6.GetString() : "");
|
|
return "문서를 생성하시겠습니까?\n\n도구: " + text2 + "\n경로: " + text8;
|
|
}
|
|
if ((text2 == "build_run" || text2 == "test_loop") ? true : false)
|
|
{
|
|
JsonElement value7;
|
|
string text9 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("action", out value7)) ? value7.GetString() : "");
|
|
return "빌드/테스트를 실행하시겠습니까?\n\n도구: " + text2 + "\n액션: " + text9;
|
|
}
|
|
}
|
|
if (text == "detailed" && ((text2 == "file_write" || text2 == "file_edit") ? true : false))
|
|
{
|
|
JsonElement value8;
|
|
string text10 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value8)) ? value8.GetString() : "");
|
|
return "파일을 수정하시겠습니까?\n\n경로: " + text10;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static string FormatToolCallSummary(LlmService.ContentBlock call)
|
|
{
|
|
if (!call.ToolInput.HasValue)
|
|
{
|
|
return call.ToolName;
|
|
}
|
|
try
|
|
{
|
|
JsonElement value = call.ToolInput.Value;
|
|
if (value.TryGetProperty("path", out var value2))
|
|
{
|
|
return call.ToolName + ": " + value2.GetString();
|
|
}
|
|
if (value.TryGetProperty("command", out var value3))
|
|
{
|
|
return call.ToolName + ": " + value3.GetString();
|
|
}
|
|
if (value.TryGetProperty("pattern", out var value4))
|
|
{
|
|
return call.ToolName + ": " + value4.GetString();
|
|
}
|
|
return call.ToolName;
|
|
}
|
|
catch
|
|
{
|
|
return call.ToolName;
|
|
}
|
|
}
|
|
|
|
private static string TruncateOutput(string output, int maxLength)
|
|
{
|
|
if (output.Length <= maxLength)
|
|
{
|
|
return output;
|
|
}
|
|
return output.Substring(0, maxLength) + "\n... (출력 잘림)";
|
|
}
|
|
|
|
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential) ClassifyToolCalls(List<LlmService.ContentBlock> calls)
|
|
{
|
|
List<LlmService.ContentBlock> list = new List<LlmService.ContentBlock>();
|
|
List<LlmService.ContentBlock> list2 = new List<LlmService.ContentBlock>();
|
|
foreach (LlmService.ContentBlock call in calls)
|
|
{
|
|
if (ReadOnlyTools.Contains(call.ToolName ?? ""))
|
|
{
|
|
list.Add(call);
|
|
}
|
|
else
|
|
{
|
|
list2.Add(call);
|
|
}
|
|
}
|
|
if (list.Count <= 1)
|
|
{
|
|
list2.InsertRange(0, list);
|
|
list.Clear();
|
|
}
|
|
return (Parallel: list, Sequential: list2);
|
|
}
|
|
|
|
private async Task ExecuteToolsInParallelAsync(List<LlmService.ContentBlock> calls, List<ChatMessage> messages, AgentContext context, List<string> planSteps, ParallelState state, int baseMax, int maxRetry, LlmSettings llm, int iteration, CancellationToken ct, List<string> statsUsedTools)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...", null, 0, 0, null, 0L);
|
|
List<Task<(LlmService.ContentBlock call, ToolResult, long)>> tasks = calls.Select(async delegate(LlmService.ContentBlock contentBlock)
|
|
{
|
|
IAgentTool tool = _tools.Get(contentBlock.ToolName);
|
|
if (tool == null)
|
|
{
|
|
return (call: contentBlock, ToolResult.Fail("알 수 없는 도구: " + contentBlock.ToolName), 0L);
|
|
}
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
JsonElement input = contentBlock.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
|
ToolResult result2 = await tool.ExecuteAsync(input, context, ct);
|
|
sw.Stop();
|
|
return (call: contentBlock, result2, sw.ElapsedMilliseconds);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
return (call: contentBlock, ToolResult.Fail("도구 실행 오류: " + ex.Message), sw.ElapsedMilliseconds);
|
|
}
|
|
}).ToList();
|
|
(LlmService.ContentBlock call, ToolResult, long)[] array = await Task.WhenAll(tasks);
|
|
for (int num = 0; num < array.Length; num++)
|
|
{
|
|
(LlmService.ContentBlock, ToolResult, long) tuple = array[num];
|
|
var (call, result, _) = tuple;
|
|
EmitEvent(elapsedMs: tuple.Item3, type: result.Success ? AgentEventType.ToolResult : AgentEventType.Error, toolName: call.ToolName, summary: TruncateOutput(result.Output, 200), filePath: result.FilePath, stepCurrent: 0, stepTotal: 0, steps: null, inputTokens: 0, outputTokens: 0, toolInput: null, iteration: iteration);
|
|
if (result.Success)
|
|
{
|
|
state.StatsSuccessCount++;
|
|
}
|
|
else
|
|
{
|
|
state.StatsFailCount++;
|
|
}
|
|
if (!statsUsedTools.Contains(call.ToolName))
|
|
{
|
|
statsUsedTools.Add(call.ToolName);
|
|
}
|
|
state.TotalToolCalls++;
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000)));
|
|
if (!result.Success)
|
|
{
|
|
state.ConsecutiveErrors++;
|
|
if (state.ConsecutiveErrors > maxRetry)
|
|
{
|
|
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[FAILED after retries] " + TruncateOutput(result.Output, 500)));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
state.ConsecutiveErrors = 0;
|
|
}
|
|
if (llm.EnableAuditLog)
|
|
{
|
|
AuditLogService.LogToolCall(_conversationId, ActiveTab ?? "", call.ToolName, call.ToolInput?.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success);
|
|
}
|
|
}
|
|
}
|
|
}
|