AgentLoopService.Memory.cs (신규, 105줄): - InjectHierarchicalMemoryAsync(): 세션 시작 시 4-layer 계층 메모리 수집 (Managed→User→Project→Local AX.md + rules/*.md) AxMdIncludeResolver.ResolveAsync()로 @include 지시어 최대 5단계 재귀 해석 40,000자 초과 시 크기 경고, 마커 기반 in-place 교체(중복 방지) - InjectPathScopedRulesAsync(): 파일 도구 실행 후 .ax/rules/*.md paths: 프론트매터 기반 경로 범위 규칙 주입. workFolder별 PathScopedRuleInjector 캐시 적용 AgentLoopService.cs (편집): - Phase 17-A(Reflexion) 이후 await InjectHierarchicalMemoryAsync() 호출 추가 AgentLoopService.Execution.cs (편집): - InjectPathBasedSkills() 이후 InjectPathScopedRulesAsync() fire-and-forget 추가 AppSettings.LlmSettings.cs (편집): - EnableMemorySystem 설정 추가 (기본 true, json: "enableMemorySystem") docs/AGENT_ROADMAP.md: - Group E 완료 표시 + 구현 내역 기록 (17-E1/E2 완료, 17-E3 차기) 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
917 lines
49 KiB
C#
917 lines
49 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프.
|
|
/// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다.
|
|
/// </summary>
|
|
public partial class AgentLoopService
|
|
{
|
|
private static App? CurrentApp => System.Windows.Application.Current as App;
|
|
|
|
// ─── Phase 33-A: 매직 넘버 상수화 ───────────────────────────────────
|
|
private static class Defaults
|
|
{
|
|
// 반복 제어
|
|
public const int MaxIterations = 25;
|
|
public const int MaxRetryOnError = 3;
|
|
public const int MaxTestFixIterations = 5;
|
|
public const int MaxPlanRegenerationRetries = 3;
|
|
public const int MaxToolExecutionRetries = 2;
|
|
public const int MaxPostDocPlanRetries = 2;
|
|
public const int FreeTierDelaySeconds = 4;
|
|
|
|
// 컨텍스트 관리
|
|
public const int AutoCompactThresholdPercent = 80;
|
|
public const double ContextCompressionTargetRatio = 0.6;
|
|
public const int ToolCallThresholdForExpansion = 15;
|
|
|
|
// 텍스트 절단 길이
|
|
public const int QueryNameMaxLength = 40;
|
|
public const int QueryTitleMaxLength = 60;
|
|
public const int ThinkingTextMaxLength = 150;
|
|
public const int VerificationSummaryMaxLength = 300;
|
|
public const int LogSummaryMaxLength = 200;
|
|
public const int ToolResultTruncateLength = 4000;
|
|
}
|
|
|
|
private readonly LlmService _llm;
|
|
private readonly ToolRegistry _tools;
|
|
private readonly SettingsService _settings;
|
|
private readonly SessionManager? _sessionManager; // Phase 33-D: DI 기반
|
|
|
|
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
|
public ObservableCollection<AgentEvent> Events { get; } = new();
|
|
|
|
/// <summary>현재 루프 실행 중 여부.</summary>
|
|
public bool IsRunning { get; private set; }
|
|
|
|
/// <summary>이벤트 발생 시 UI 스레드에서 호출할 디스패처.</summary>
|
|
public Action<Action>? Dispatcher { get; set; }
|
|
|
|
/// <summary>Ask 모드 권한 확인 콜백. (toolName, filePath) → bool</summary>
|
|
public Func<string, string, Task<bool>>? AskPermissionCallback { get; set; }
|
|
|
|
/// <summary>에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 응답.</summary>
|
|
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; set; }
|
|
|
|
/// <summary>현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용).</summary>
|
|
public string ActiveTab { get; set; } = "Chat";
|
|
|
|
/// <summary>현재 대화 ID (감사 로그 기록용).</summary>
|
|
private string _conversationId = "";
|
|
|
|
/// <summary>문서 생성 폴백 재시도 여부 (루프당 1회만).</summary>
|
|
private bool _docFallbackAttempted;
|
|
|
|
/// <summary>현재 세션 ID (AgentEventLog 기록용).</summary>
|
|
private string _sessionId = "";
|
|
|
|
/// <summary>현재 세션 이벤트 로그 (비동기 JSONL 기록).</summary>
|
|
private AgentEventLog? _eventLog;
|
|
|
|
/// <summary>위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).</summary>
|
|
private DelegateAgentTool? _delegateAgentTool;
|
|
|
|
/// <summary>일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.</summary>
|
|
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
|
|
|
|
/// <summary>현재 일시정지 상태 여부.</summary>
|
|
public bool IsPaused { get; private set; }
|
|
|
|
/// <summary>
|
|
/// 사용자 의사결정 콜백. 계획 제시 후 사용자 승인을 대기합니다.
|
|
/// (planSummary, options) → 선택된 옵션 텍스트. null이면 승인(계속 진행).
|
|
/// </summary>
|
|
public Func<string, List<string>, Task<string?>>? UserDecisionCallback { get; set; }
|
|
|
|
/// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary>
|
|
public event Action<AgentEvent>? EventOccurred;
|
|
|
|
public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings,
|
|
SessionManager? sessionManager = null)
|
|
{
|
|
_llm = llm;
|
|
_tools = tools;
|
|
_settings = settings;
|
|
_sessionManager = sessionManager;
|
|
|
|
// DelegateAgentTool에 서브에이전트 실행기 주입
|
|
_delegateAgentTool = tools.Get("delegate") as DelegateAgentTool;
|
|
_delegateAgentTool?.SetSubAgentRunner(RunSubAgentAsync);
|
|
|
|
// Phase 17-D: SkillManagerTool에 fork 실행기 주입
|
|
(tools.Get("skill_manager") as SkillManagerTool)?.SetForkRunner(RunSkillInForkAsync);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 에이전트 루프를 일시정지합니다.
|
|
/// 다음 반복 시작 시점에서 대기 상태가 됩니다.
|
|
/// </summary>
|
|
public async Task PauseAsync()
|
|
{
|
|
if (IsPaused || !IsRunning) return;
|
|
// 세마포어를 획득하여 루프가 다음 반복에서 대기하게 함
|
|
await _pauseSemaphore.WaitAsync().ConfigureAwait(false);
|
|
IsPaused = true;
|
|
EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 일시정지된 에이전트 루프를 재개합니다.
|
|
/// </summary>
|
|
public void Resume()
|
|
{
|
|
if (!IsPaused) return;
|
|
IsPaused = false;
|
|
try
|
|
{
|
|
_pauseSemaphore.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
// 이미 릴리즈된 상태 — 무시
|
|
}
|
|
EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 에이전트 루프를 실행합니다.
|
|
/// 사용자 메시지를 LLM에 전달하고, LLM이 도구를 호출하면 실행 후 결과를 다시 LLM에 피드백합니다.
|
|
/// LLM이 더 이상 도구를 호출하지 않으면 (텍스트만 반환) 루프를 종료합니다.
|
|
/// </summary>
|
|
/// <param name="messages">대화 메시지 목록 (시스템 프롬프트 포함)</param>
|
|
/// <param name="ct">취소 토큰</param>
|
|
/// <returns>최종 텍스트 응답</returns>
|
|
public async Task<string> RunAsync(List<ChatMessage> messages, CancellationToken ct = default)
|
|
{
|
|
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
|
|
|
IsRunning = true;
|
|
_docFallbackAttempted = false;
|
|
_sessionId = Guid.NewGuid().ToString("N")[..12];
|
|
_eventLog = null;
|
|
// Phase 33-F: ActiveTab 스냅샷 — 루프 중 외부 변경에 의한 레이스 컨디션 방지
|
|
var activeTabSnapshot = ActiveTab ?? "Chat";
|
|
var llm = _settings.Settings.Llm;
|
|
if (llm.EventLog?.Enabled ?? true)
|
|
{
|
|
_eventLog = new AgentEventLog(_sessionId);
|
|
_ = _eventLog.AppendAsync(AgentEventLogType.SessionStart,
|
|
JsonSerializer.Serialize(new { tab = activeTabSnapshot, model = llm.Model ?? "" }));
|
|
}
|
|
|
|
var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : Defaults.MaxIterations;
|
|
var maxIterations = baseMax;
|
|
var maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : Defaults.MaxRetryOnError;
|
|
var iteration = 0;
|
|
|
|
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
|
|
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
|
|
|
// Phase 17-B: UserMessage 이벤트 기록 + TaskState Working Memory 초기화
|
|
if (!string.IsNullOrWhiteSpace(userQuery))
|
|
_ = _eventLog?.AppendAsync(AgentEventLogType.UserMessage,
|
|
JsonSerializer.Serialize(new { length = userQuery.Length }));
|
|
await InitTaskStateAsync(userQuery, _sessionId);
|
|
|
|
// Phase 17-C: SessionStart 훅 실행 (fire-and-forget)
|
|
_ = RunExtendedEventAsync(HookEventKind.SessionStart, messages, CancellationToken.None,
|
|
userMessage: userQuery);
|
|
|
|
// Phase 17-C: UserPromptSubmit 훅 실행 — 차단 시 즉시 종료
|
|
if (!string.IsNullOrWhiteSpace(userQuery))
|
|
{
|
|
var promptBlocked = await RunExtendedEventAsync(
|
|
HookEventKind.UserPromptSubmit, messages, ct, userMessage: userQuery);
|
|
if (promptBlocked)
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "훅 정책에 의해 요청이 차단되었습니다");
|
|
return "⚠ 요청이 훅 정책에 의해 차단되었습니다.";
|
|
}
|
|
}
|
|
|
|
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
|
|
var totalToolCalls = 0; // 복잡도 추정용
|
|
|
|
// 통계 수집
|
|
var statsStart = DateTime.Now;
|
|
var statsSuccessCount = 0;
|
|
var statsFailCount = 0;
|
|
var statsInputTokens = 0;
|
|
var statsOutputTokens = 0;
|
|
var statsUsedTools = new List<string>();
|
|
|
|
// Task Decomposition: 계획 단계 추적
|
|
var planSteps = new List<string>();
|
|
var currentStep = 0;
|
|
var planExtracted = false;
|
|
var planExecutionRetry = 0; // 계획 승인 후 도구 미호출 재시도 카운터
|
|
var documentPlanCalled = false; // document_plan 도구 호출 여부
|
|
var postDocumentPlanRetry = 0; // document_plan 후 terminal 도구 미호출 재시도 카운터
|
|
string? documentPlanPath = null; // document_plan이 제안한 파일명
|
|
string? documentPlanTitle = null; // document_plan이 제안한 문서 제목
|
|
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
|
|
|
|
// 플랜 모드 설정
|
|
var planMode = llm.PlanMode ?? "off"; // off | always | auto
|
|
|
|
var context = BuildContext(activeTabSnapshot);
|
|
|
|
// Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입
|
|
await InjectReflexionContextAsync(messages, userQuery);
|
|
|
|
// Phase 17-E: 4-layer 계층 메모리 + @include 해석 → 시스템 메시지 주입
|
|
await InjectHierarchicalMemoryAsync(messages, llm.WorkFolder, ct);
|
|
|
|
try
|
|
{
|
|
// ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ──
|
|
if (planMode == "always")
|
|
{
|
|
iteration++;
|
|
EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중...");
|
|
|
|
// 계획 생성 전용 시스템 지시를 임시 추가
|
|
var planInstruction = new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. " +
|
|
"각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. " +
|
|
"계획만 제시하고 실행은 하지 마세요."
|
|
};
|
|
messages.Add(planInstruction);
|
|
|
|
// 도구 없이 텍스트만 요청
|
|
string planText;
|
|
try
|
|
{
|
|
planText = await _llm.SendAsync(messages, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}");
|
|
return $"⚠ LLM 오류: {ex.Message}";
|
|
}
|
|
|
|
// 계획 지시 메시지 제거 (실제 실행 시 혼란 방지)
|
|
messages.Remove(planInstruction);
|
|
|
|
// 계획 추출
|
|
planSteps = TaskDecomposer.ExtractSteps(planText);
|
|
planExtracted = true;
|
|
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계",
|
|
steps: planSteps);
|
|
|
|
// 사용자 승인 대기
|
|
if (UserDecisionCallback != null)
|
|
{
|
|
var decision = await UserDecisionCallback(
|
|
planText,
|
|
new List<string> { "승인", "수정 요청", "취소" });
|
|
|
|
if (decision == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
|
return "작업이 취소되었습니다.";
|
|
}
|
|
else 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 < Defaults.MaxPlanRegenerationRetries; retry++)
|
|
{
|
|
try { planText = await _llm.SendAsync(messages, ct); }
|
|
catch (Exception ex) { LogService.Warn($"[AgentLoop] 계획 재생성 실패: {ex.Message}"); break; }
|
|
|
|
planSteps = TaskDecomposer.ExtractSteps(planText);
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"수정된 계획: {planSteps.Count}단계",
|
|
steps: planSteps);
|
|
}
|
|
|
|
decision = await UserDecisionCallback(
|
|
planText,
|
|
new List<string> { "승인", "수정 요청", "취소" });
|
|
|
|
if (decision == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
|
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 });
|
|
|
|
// 1차 계획의 단계들을 document_plan의 sections_hint로 전달하도록 지시
|
|
// → BuildSections() 하드코딩 대신 LLM이 잡은 섹션 구조가 문서에 반영됨
|
|
var planSectionsHint = planSteps.Count > 0
|
|
? string.Join(", ", planSteps)
|
|
: "";
|
|
var sectionInstruction = !string.IsNullOrEmpty(planSectionsHint)
|
|
? $"document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"{planSectionsHint}\""
|
|
: "";
|
|
|
|
messages.Add(new ChatMessage { Role = "user",
|
|
Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. " +
|
|
"텍스트로 설명하지 말고 반드시 도구를 호출하세요." +
|
|
(string.IsNullOrEmpty(sectionInstruction) ? "" : "\n" + sectionInstruction) });
|
|
}
|
|
else
|
|
{
|
|
// 계획 추출 실패 — assistant 응답으로 추가하고 일반 모드로 진행
|
|
if (!string.IsNullOrEmpty(planText))
|
|
messages.Add(new ChatMessage { Role = "assistant", Content = planText });
|
|
}
|
|
}
|
|
|
|
while (iteration < maxIterations && !ct.IsCancellationRequested)
|
|
{
|
|
iteration++;
|
|
|
|
// ── 일시정지 체크포인트: Pause() 호출 시 여기서 대기 ──
|
|
await _pauseSemaphore.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// 즉시 릴리즈 — 다음 반복에서도 다시 획득할 수 있도록
|
|
_pauseSemaphore.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
// PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시
|
|
}
|
|
|
|
// Phase 33-C: 통합 컨텍스트 관리 — ContextCondenser + AutoCompactMonitor 병합
|
|
// 1단계: 기본 압축 (MaxContextTokens 초과 시)
|
|
// 2단계: 사용량 임계치 초과 시 적극적 압축 (목표 60%)
|
|
if (llm.MaxContextTokens > 0)
|
|
{
|
|
// Phase 17-B: 압축 전 TaskState Working Memory 주입 — 압축 후에도 작업 상태 유지
|
|
InjectTaskStateContext(messages);
|
|
|
|
var condensed = await ContextCondenser.CondenseIfNeededAsync(
|
|
messages, _llm, llm.MaxContextTokens, ct);
|
|
if (condensed)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다");
|
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
|
JsonSerializer.Serialize(new { messageCount = messages.Count }));
|
|
UpdateTaskStateSummaryAsync("컨텍스트 압축 완료");
|
|
// Phase 17-C: PostCompact 훅 (fire-and-forget)
|
|
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
|
|
}
|
|
|
|
// 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준)
|
|
if (!condensed && _llm.LastTokenUsage != null)
|
|
{
|
|
var threshold = llm.AutoCompactThreshold > 0
|
|
? llm.AutoCompactThreshold
|
|
: Defaults.AutoCompactThresholdPercent;
|
|
var monitor = new AutoCompactMonitor(threshold);
|
|
if (monitor.ShouldCompact(_llm.LastTokenUsage.PromptTokens, llm.MaxContextTokens))
|
|
{
|
|
var usagePct = AutoCompactMonitor.CalculateUsagePercent(
|
|
_llm.LastTokenUsage.PromptTokens, llm.MaxContextTokens);
|
|
EmitEvent(AgentEventType.Thinking, "",
|
|
$"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행");
|
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered,
|
|
JsonSerializer.Serialize(new { aggressive = true, usagePct }));
|
|
// Phase 17-C: PreCompact 훅 — 적극적 압축 직전 (압축 발생 확실)
|
|
await RunExtendedEventAsync(HookEventKind.PreCompact, messages, ct);
|
|
var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio);
|
|
var compacted = await ContextCondenser.CondenseIfNeededAsync(
|
|
messages, _llm, targetTokens, ct);
|
|
if (compacted)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", "적극적 컴팩션 완료");
|
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
|
JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count }));
|
|
UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)");
|
|
// Phase 17-C: PostCompact 훅 (fire-and-forget)
|
|
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
|
|
|
|
// 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
|
|
if (llm.FreeTierMode && iteration > 1)
|
|
{
|
|
var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : Defaults.FreeTierDelaySeconds;
|
|
EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중...");
|
|
await Task.Delay(delaySec * 1000, ct);
|
|
}
|
|
|
|
// LLM에 도구 정의와 함께 요청
|
|
List<LlmService.ContentBlock> blocks;
|
|
try
|
|
{
|
|
// Phase 29-B: ToolEnvironmentContext를 사용하여 IConditionalTool 필터링 적용
|
|
var toolEnv = new ToolEnvironmentContext
|
|
{
|
|
Settings = _settings.Settings,
|
|
ActiveTab = activeTabSnapshot,
|
|
WorkFolder = llm.WorkFolder,
|
|
HasGitRepo = !string.IsNullOrEmpty(llm.WorkFolder)
|
|
&& System.IO.Directory.Exists(System.IO.Path.Combine(llm.WorkFolder, ".git")),
|
|
AiEnabled = _settings.Settings.AiEnabled,
|
|
InternalModeEnabled = _settings.Settings.InternalModeEnabled,
|
|
};
|
|
var activeTools = _tools.GetActiveTools(llm.DisabledTools, toolEnv);
|
|
blocks = await _llm.SendWithToolsAsync(messages, activeTools, ct);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
// Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체
|
|
var textResp = await _llm.SendAsync(messages, ct);
|
|
return textResp;
|
|
}
|
|
catch (ToolCallNotSupportedException ex)
|
|
{
|
|
// 서버가 도구 호출을 400으로 거부 → 도구 없이 일반 응답으로 폴백
|
|
LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: {ex.Message}");
|
|
EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…");
|
|
|
|
// document_plan이 완료됐지만 html_create 미실행 → 조기 종료 전에 앱이 직접 생성
|
|
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
|
|
{
|
|
_docFallbackAttempted = true;
|
|
EmitEvent(AgentEventType.Thinking, "", "앱에서 직접 문서를 생성합니다...");
|
|
try
|
|
{
|
|
var bodyRequest = new List<ChatMessage>
|
|
{
|
|
new() { Role = "user",
|
|
Content = $"아래 HTML 골격의 각 h2 섹션에 주석의 핵심 항목을 참고하여 " +
|
|
$"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " +
|
|
$"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" +
|
|
$"주제: {documentPlanTitle ?? userQuery}\n\n" +
|
|
$"골격:\n{documentPlanScaffold}" }
|
|
};
|
|
var bodyText = await _llm.SendAsync(bodyRequest, ct);
|
|
if (!string.IsNullOrEmpty(bodyText))
|
|
{
|
|
var htmlTool = _tools.Get("html_create");
|
|
if (htmlTool != null)
|
|
{
|
|
var fallbackPath = documentPlanPath;
|
|
if (string.IsNullOrEmpty(fallbackPath))
|
|
{
|
|
var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery;
|
|
foreach (var c in System.IO.Path.GetInvalidFileNameChars())
|
|
safe = safe.Replace(c, '_');
|
|
fallbackPath = $"{safe.Trim()}.html";
|
|
}
|
|
var argsJson = System.Text.Json.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" }
|
|
});
|
|
var htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct);
|
|
if (htmlResult.Success)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create",
|
|
$"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}",
|
|
filePath: htmlResult.FilePath);
|
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
|
return htmlResult.Output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception docEx)
|
|
{
|
|
LogService.Warn($"[AgentLoop] document_plan 직접 생성 실패: {docEx.Message}");
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
var textResp = await _llm.SendAsync(messages, ct);
|
|
return textResp;
|
|
}
|
|
catch (Exception fallbackEx)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"LLM 오류: {fallbackEx.Message}");
|
|
return $"⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): {fallbackEx.Message}";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}");
|
|
return $"⚠ LLM 오류: {ex.Message}";
|
|
}
|
|
|
|
// 응답에서 텍스트와 도구 호출 분리
|
|
var textParts = new List<string>();
|
|
var toolCalls = new List<LlmService.ContentBlock>();
|
|
|
|
foreach (var block in blocks)
|
|
{
|
|
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
|
|
textParts.Add(block.Text);
|
|
else if (block.Type == "tool_use")
|
|
toolCalls.Add(block);
|
|
}
|
|
|
|
// 텍스트 부분
|
|
var textResponse = string.Join("\n", textParts);
|
|
|
|
// Phase 17-B: AssistantMessage 이벤트 기록
|
|
if (!string.IsNullOrWhiteSpace(textResponse))
|
|
_ = _eventLog?.AppendAsync(AgentEventLogType.AssistantMessage,
|
|
JsonSerializer.Serialize(new { length = textResponse.Length, hasToolCalls = toolCalls.Count > 0 }));
|
|
|
|
// Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출
|
|
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
|
|
{
|
|
planSteps = TaskDecomposer.ExtractSteps(textResponse);
|
|
planExtracted = true;
|
|
|
|
if (planSteps.Count > 0)
|
|
{
|
|
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계",
|
|
steps: planSteps);
|
|
|
|
// 플랜 모드 "auto"에서만 승인 대기
|
|
// - auto: 계획 감지 시 승인 대기 (단, 도구 호출이 함께 있으면 이미 실행 중이므로 스킵)
|
|
// - off/always: 승인창 띄우지 않음 (off=자동 진행, always=앞에서 이미 처리됨)
|
|
var requireApproval = planMode == "auto" && toolCalls.Count == 0;
|
|
|
|
if (requireApproval && UserDecisionCallback != null)
|
|
{
|
|
var decision = await UserDecisionCallback(
|
|
textResponse,
|
|
new List<string> { "승인", "수정 요청", "취소" });
|
|
|
|
if (decision == "취소")
|
|
{
|
|
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
|
return "작업이 취소되었습니다.";
|
|
}
|
|
else if (decision != null && decision != "승인")
|
|
{
|
|
// 수정 요청 — 사용자 피드백을 메시지에 추가
|
|
messages.Add(new ChatMessage { Role = "user", Content = decision });
|
|
EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중...");
|
|
planExtracted = false; // 재추출 허용
|
|
continue; // 루프 재시작 — LLM이 수정된 계획으로 재응답
|
|
}
|
|
// 승인(null) — 그대로 진행
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
// Thinking UI: 텍스트 응답 중 도구 호출이 있으면 "사고 과정"으로 표시
|
|
if (!string.IsNullOrEmpty(textResponse) && toolCalls.Count > 0)
|
|
{
|
|
var thinkingSummary = textResponse.Length > Defaults.ThinkingTextMaxLength
|
|
? textResponse[..Defaults.ThinkingTextMaxLength] + "…"
|
|
: textResponse;
|
|
EmitEvent(AgentEventType.Thinking, "", thinkingSummary);
|
|
}
|
|
|
|
// 도구 호출이 없으면 루프 종료 — 단, 문서 생성 요청인데 파일이 미생성이면 자동 저장
|
|
if (toolCalls.Count == 0)
|
|
{
|
|
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
|
|
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
|
|
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...");
|
|
continue; // 루프 재시작
|
|
}
|
|
|
|
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 재시도 (최대 2회)
|
|
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...");
|
|
continue; // 루프 재시작
|
|
}
|
|
|
|
// 재시도도 모두 소진 → 앱이 직접 본문 생성 후 html_create 강제 실행
|
|
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
|
|
{
|
|
_docFallbackAttempted = true;
|
|
EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다...");
|
|
try
|
|
{
|
|
// 도구 없이 LLM에게 HTML body 내용만 요청
|
|
var bodyRequest = new List<ChatMessage>
|
|
{
|
|
new() { Role = "user",
|
|
Content = $"아래 HTML 골격의 각 h2 섹션에 주석(<!-- -->)의 핵심 항목을 참고하여 " +
|
|
$"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " +
|
|
$"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" +
|
|
$"주제: {documentPlanTitle ?? userQuery}\n\n" +
|
|
$"골격:\n{documentPlanScaffold}" }
|
|
};
|
|
var bodyText = await _llm.SendAsync(bodyRequest, ct);
|
|
if (!string.IsNullOrEmpty(bodyText))
|
|
{
|
|
var htmlTool = _tools.Get("html_create");
|
|
if (htmlTool != null)
|
|
{
|
|
// 파일명 정규화
|
|
var fallbackPath = documentPlanPath;
|
|
if (string.IsNullOrEmpty(fallbackPath))
|
|
{
|
|
var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery;
|
|
foreach (var c in System.IO.Path.GetInvalidFileNameChars())
|
|
safe = safe.Replace(c, '_');
|
|
fallbackPath = $"{safe.Trim()}.html";
|
|
}
|
|
var argsJson = System.Text.Json.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" }
|
|
});
|
|
var htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct);
|
|
if (htmlResult.Success)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create",
|
|
$"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}",
|
|
filePath: htmlResult.FilePath);
|
|
textResponse = htmlResult.Output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EmitEvent(AgentEventType.Thinking, "", $"직접 생성 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// LLM이 도구를 한 번도 호출하지 않고 텍스트만 반환 + 문서 생성 요청이면 → 앱이 직접 HTML 파일로 저장
|
|
// 주의: 이미 도구가 실행된 경우(totalToolCalls > 0)에는 폴백하지 않음 (중복 파일 방지)
|
|
if (!_docFallbackAttempted && totalToolCalls == 0
|
|
&& !string.IsNullOrEmpty(textResponse)
|
|
&& IsDocumentCreationRequest(userQuery))
|
|
{
|
|
_docFallbackAttempted = true;
|
|
var savedPath = AutoSaveAsHtml(textResponse, userQuery, context);
|
|
if (savedPath != null)
|
|
{
|
|
EmitEvent(AgentEventType.ToolResult, "html_create",
|
|
$"✅ 보고서 파일 자동 생성: {System.IO.Path.GetFileName(savedPath)}",
|
|
filePath: savedPath);
|
|
textResponse += $"\n\n📄 파일이 저장되었습니다: {savedPath}";
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
|
return textResponse;
|
|
}
|
|
|
|
// 도구 호출이 있을 때: assistant 메시지에 text + tool_use 블록을 모두 기록
|
|
// (Claude API는 assistant 메시지에 tool_use가 포함되어야 tool_result를 매칭함)
|
|
var contentBlocks = new List<object>();
|
|
if (!string.IsNullOrEmpty(textResponse))
|
|
contentBlocks.Add(new { type = "text", text = textResponse });
|
|
foreach (var tc in toolCalls)
|
|
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
|
|
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
|
|
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
|
|
|
|
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
|
|
var parallelEnabled = llm.EnableParallelTools && toolCalls.Count > 1;
|
|
if (parallelEnabled)
|
|
{
|
|
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
|
|
if (parallelBatch.Count > 1)
|
|
{
|
|
var 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;
|
|
}
|
|
|
|
// Phase 33-B: 도구 실행 루프 — ProcessSingleToolCallAsync로 위임
|
|
var execState = new ToolExecutionState
|
|
{
|
|
CurrentStep = currentStep,
|
|
TotalToolCalls = totalToolCalls,
|
|
MaxIterations = maxIterations,
|
|
BaseMax = baseMax,
|
|
MaxRetry = maxRetry,
|
|
ConsecutiveErrors = consecutiveErrors,
|
|
StatsSuccessCount = statsSuccessCount,
|
|
StatsFailCount = statsFailCount,
|
|
StatsInputTokens = statsInputTokens,
|
|
StatsOutputTokens = statsOutputTokens,
|
|
StatsUsedTools = statsUsedTools,
|
|
PlanSteps = planSteps,
|
|
DocumentPlanCalled = documentPlanCalled,
|
|
DocumentPlanPath = documentPlanPath,
|
|
DocumentPlanTitle = documentPlanTitle,
|
|
DocumentPlanScaffold = documentPlanScaffold,
|
|
};
|
|
var loopBreak = false;
|
|
foreach (var call in toolCalls)
|
|
{
|
|
if (ct.IsCancellationRequested) break;
|
|
var (action, returnValue) = await ProcessSingleToolCallAsync(
|
|
call, messages, context, execState, toolCalls, activeTabSnapshot, iteration, ct);
|
|
if (action == ToolCallAction.Return)
|
|
return returnValue ?? "";
|
|
if (action == ToolCallAction.Break) { loopBreak = true; break; }
|
|
}
|
|
// 상태 동기화
|
|
currentStep = execState.CurrentStep;
|
|
totalToolCalls = execState.TotalToolCalls;
|
|
maxIterations = execState.MaxIterations;
|
|
consecutiveErrors = execState.ConsecutiveErrors;
|
|
statsSuccessCount = execState.StatsSuccessCount;
|
|
statsFailCount = execState.StatsFailCount;
|
|
statsInputTokens = execState.StatsInputTokens;
|
|
statsOutputTokens = execState.StatsOutputTokens;
|
|
documentPlanCalled = execState.DocumentPlanCalled;
|
|
documentPlanPath = execState.DocumentPlanPath;
|
|
documentPlanTitle = execState.DocumentPlanTitle;
|
|
documentPlanScaffold = execState.DocumentPlanScaffold;
|
|
if (loopBreak) break;
|
|
}
|
|
|
|
if (iteration >= maxIterations)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)");
|
|
return "⚠ 에이전트가 최대 반복 횟수에 도달했습니다.";
|
|
}
|
|
|
|
return "(취소됨)";
|
|
}
|
|
finally
|
|
{
|
|
// Phase 17-C: SessionEnd + AgentStop 확장 훅 실행 (fire-and-forget)
|
|
_ = RunExtendedEventAsync(HookEventKind.SessionEnd, null, CancellationToken.None, userMessage: userQuery);
|
|
_ = RunExtendedEventAsync(HookEventKind.AgentStop, null, CancellationToken.None, userMessage: userQuery);
|
|
|
|
// 세션 종료 이벤트 기록
|
|
if (_eventLog != null)
|
|
_ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd,
|
|
JsonSerializer.Serialize(new { totalToolCalls, iteration }));
|
|
|
|
IsRunning = false;
|
|
|
|
// 일시정지 상태 리셋
|
|
if (IsPaused)
|
|
{
|
|
IsPaused = false;
|
|
try { _pauseSemaphore.Release(); }
|
|
catch (SemaphoreFullException) { }
|
|
}
|
|
|
|
// Phase 33-D: SessionManager — DI 기반 세션 자동 저장
|
|
if (_sessionManager != null)
|
|
{
|
|
try
|
|
{
|
|
var capturedTab = activeTabSnapshot;
|
|
var session = new AgentSession
|
|
{
|
|
Id = _sessionId ?? Guid.NewGuid().ToString("N")[..12],
|
|
Name = userQuery.Length > Defaults.QueryNameMaxLength
|
|
? userQuery[..Defaults.QueryNameMaxLength] + "…" : userQuery,
|
|
Tab = capturedTab,
|
|
WorkFolder = llm.WorkFolder ?? "",
|
|
Model = llm.Model ?? "",
|
|
Messages = messages,
|
|
Tags = new List<string> { capturedTab },
|
|
CreatedAt = statsStart,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
await _sessionManager.SaveSessionAsync(session);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EmitEvent(AgentEventType.Error, "", $"세션 저장 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// 통계 기록 (도구 호출이 1회 이상인 세션만)
|
|
if (totalToolCalls > 0)
|
|
{
|
|
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
|
|
|
|
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
|
|
{
|
|
Timestamp = statsStart,
|
|
Tab = activeTabSnapshot,
|
|
Model = _settings.Settings.Llm.Model ?? "",
|
|
ToolCalls = totalToolCalls,
|
|
SuccessCount = statsSuccessCount,
|
|
FailCount = statsFailCount,
|
|
InputTokens = statsInputTokens,
|
|
OutputTokens = statsOutputTokens,
|
|
DurationMs = durationMs,
|
|
UsedTools = statsUsedTools,
|
|
});
|
|
|
|
// 전체 호출·토큰 합계 표시 (개발자 모드 설정)
|
|
if (llm.ShowTotalCallStats)
|
|
{
|
|
var totalTokens = statsInputTokens + statsOutputTokens;
|
|
var durationSec = durationMs / 1000.0;
|
|
var toolList = string.Join(", ", statsUsedTools);
|
|
var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " +
|
|
$"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " +
|
|
$"소요 {durationSec:F1}초 | 사용 도구: {toolList}";
|
|
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
|
|
}
|
|
|
|
// Phase 17-A: Reflexion — 세션 완료 후 자기평가 비동기 저장
|
|
var isSessionSuccess = statsSuccessCount > 0 && statsFailCount == 0;
|
|
var lastResult = messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "";
|
|
FireAndForgetReflexionEval(
|
|
userQuery, lastResult, _sessionId ?? "",
|
|
isSessionSuccess, totalToolCalls, iteration);
|
|
}
|
|
}
|
|
}
|
|
|
|
private AgentContext BuildContext(string? tabOverride = null)
|
|
{
|
|
var llm = _settings.Settings.Llm;
|
|
return new AgentContext
|
|
{
|
|
Settings = _settings.Settings,
|
|
WorkFolder = llm.WorkFolder,
|
|
Permission = llm.FilePermission,
|
|
BlockedPaths = llm.BlockedPaths,
|
|
BlockedExtensions = llm.BlockedExtensions,
|
|
AskPermission = AskPermissionCallback,
|
|
UserDecision = UserDecisionCallback,
|
|
UserAskCallback = UserAskCallback,
|
|
ToolPermissions = llm.ToolPermissions ?? new(),
|
|
ActiveTab = tabOverride ?? ActiveTab ?? "Chat",
|
|
DevMode = llm.DevMode,
|
|
DevModeStepApproval = llm.DevModeStepApproval,
|
|
};
|
|
}
|
|
}
|