using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
///
/// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프.
/// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다.
///
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 기반
/// 에이전트 이벤트 스트림 (UI 바인딩용).
public ObservableCollection Events { get; } = new();
/// 현재 루프 실행 중 여부.
public bool IsRunning { get; private set; }
/// 이벤트 발생 시 UI 스레드에서 호출할 디스패처.
public Action? Dispatcher { get; set; }
/// Ask 모드 권한 확인 콜백. (toolName, filePath) → bool
public Func>? AskPermissionCallback { get; set; }
/// 에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 응답.
public Func, string, Task>? UserAskCallback { get; set; }
/// 현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용).
public string ActiveTab { get; set; } = "Chat";
/// 현재 대화 ID (감사 로그 기록용).
private string _conversationId = "";
/// 문서 생성 폴백 재시도 여부 (루프당 1회만).
private bool _docFallbackAttempted;
/// 현재 세션 ID (AgentEventLog 기록용).
private string _sessionId = "";
/// 현재 세션 이벤트 로그 (비동기 JSONL 기록).
private AgentEventLog? _eventLog;
/// 위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).
private DelegateAgentTool? _delegateAgentTool;
/// 일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
/// 현재 일시정지 상태 여부.
public bool IsPaused { get; private set; }
///
/// 사용자 의사결정 콜백. 계획 제시 후 사용자 승인을 대기합니다.
/// (planSummary, options) → 선택된 옵션 텍스트. null이면 승인(계속 진행).
///
public Func, Task>? UserDecisionCallback { get; set; }
/// 에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).
public event Action? 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);
}
///
/// 에이전트 루프를 일시정지합니다.
/// 다음 반복 시작 시점에서 대기 상태가 됩니다.
///
public async Task PauseAsync()
{
if (IsPaused || !IsRunning) return;
// 세마포어를 획득하여 루프가 다음 반복에서 대기하게 함
await _pauseSemaphore.WaitAsync().ConfigureAwait(false);
IsPaused = true;
EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다");
}
///
/// 일시정지된 에이전트 루프를 재개합니다.
///
public void Resume()
{
if (!IsPaused) return;
IsPaused = false;
try
{
_pauseSemaphore.Release();
}
catch (SemaphoreFullException)
{
// 이미 릴리즈된 상태 — 무시
}
EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다");
}
///
/// 에이전트 루프를 실행합니다.
/// 사용자 메시지를 LLM에 전달하고, LLM이 도구를 호출하면 실행 후 결과를 다시 LLM에 피드백합니다.
/// LLM이 더 이상 도구를 호출하지 않으면 (텍스트만 반환) 루프를 종료합니다.
///
/// 대화 메시지 목록 (시스템 프롬프트 포함)
/// 취소 토큰
/// 최종 텍스트 응답
public async Task RunAsync(List 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 ?? "";
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();
// Task Decomposition: 계획 단계 추적
var planSteps = new List();
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);
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 { "승인", "수정 요청", "취소" });
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 { "승인", "수정 요청", "취소" });
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)
{
var condensed = await ContextCondenser.CondenseIfNeededAsync(
messages, _llm, llm.MaxContextTokens, ct);
if (condensed)
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다");
// 임계치 기반 적극적 압축 (이전 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}% — 적극적 컴팩션 실행");
var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio);
var compacted = await ContextCondenser.CondenseIfNeededAsync(
messages, _llm, targetTokens, ct);
if (compacted)
EmitEvent(AgentEventType.Thinking, "", "적극적 컴팩션 완료");
}
}
}
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 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
{
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();
var toolCalls = new List();
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);
// 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 { "승인", "수정 요청", "취소" });
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
{
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