컨텍스트 전송 뷰와 압축 트리거를 claw-code 기준으로 정리
claw-code의 query.ts, autoCompact.ts, sessionMemoryCompact.ts 흐름을 참고해 AX Agent의 컨텍스트 관리와 압축 동작을 더 가깝게 맞췄다. - AgentQueryContextBuilder를 추가해 저장된 전체 대화와 실제 LLM 전송용 query view를 분리 - compact boundary 이후만 전송하고 tool_result/tool_use 짝이 끊기지 않도록 start index를 보정 - 오래된 tool_result는 query view에서만 별도 budget으로 축약하도록 조정 - ContextCondenser의 자동 압축 시작점을 effective context window, summary reserve, buffer 기준으로 재계산 - 미사용 입력 높이 캐시 필드를 제거해 빌드 경고를 해소 - README.md, docs/DEVELOPMENT.md에 2026-04-12 21:34 (KST) 기준 작업 이력 반영 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
This commit is contained in:
@@ -36,6 +36,16 @@ public partial class AgentLoopService
|
||||
};
|
||||
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>실행 중 사용자 메시지 주입 큐 (Claude Code 스타일 mid-execution steering).</summary>
|
||||
private readonly ConcurrentQueue<string> _pendingUserMessages = new();
|
||||
|
||||
/// <summary>실행 중인 에이전트 루프에 사용자 메시지를 주입합니다. 다음 LLM 호출 전에 반영됩니다.</summary>
|
||||
public void InjectUserMessage(string message)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
_pendingUserMessages.Enqueue(message);
|
||||
}
|
||||
|
||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||
public ObservableCollection<AgentEvent> Events { get; } = new();
|
||||
|
||||
@@ -59,6 +69,7 @@ public partial class AgentLoopService
|
||||
|
||||
/// <summary>문서 생성 폴백 재시도 여부 (루프당 1회만).</summary>
|
||||
private bool _docFallbackAttempted;
|
||||
private bool _documentPlanApproved;
|
||||
private string _currentRunId = "";
|
||||
private bool _runPendingPostCompactionTurn;
|
||||
private int _runPostCompactionTurnCounter;
|
||||
@@ -167,6 +178,8 @@ public partial class AgentLoopService
|
||||
IsRunning = true;
|
||||
_currentRunId = Guid.NewGuid().ToString("N");
|
||||
_docFallbackAttempted = false;
|
||||
_documentPlanApproved = false;
|
||||
while (_pendingUserMessages.TryDequeue(out _)) { } // 이전 실행의 잔여 메시지 제거
|
||||
var llm = _settings.Settings.Llm;
|
||||
var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : 25;
|
||||
var maxIterations = baseMax; // 동적 조정 가능
|
||||
@@ -222,6 +235,7 @@ public partial class AgentLoopService
|
||||
string? documentPlanTitle = null; // document_plan이 제안한 문서 제목
|
||||
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
|
||||
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
|
||||
var statsModifiedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적
|
||||
var taskType = ClassifyTaskType(userQuery, ActiveTab);
|
||||
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
||||
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
||||
@@ -261,6 +275,9 @@ public partial class AgentLoopService
|
||||
if (!executionPolicy.ReduceEarlyMemoryPressure)
|
||||
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
|
||||
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
|
||||
// 스킬이 allowed-tools를 명시하면 탐색 필터링(DirectCreation의 folder_map 차단 등)을 비활성화
|
||||
if (runtimeOverrides != null && runtimeOverrides.AllowedToolNames.Count > 0)
|
||||
explorationState.SkillAllowedToolsActive = true;
|
||||
var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides);
|
||||
var runtimeOverrideApplied = false;
|
||||
string? lastUserPromptHookFingerprint = null;
|
||||
@@ -395,7 +412,35 @@ public partial class AgentLoopService
|
||||
lastToolResultToolName = null;
|
||||
}
|
||||
|
||||
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
|
||||
// ── 실행 중 사용자 메시지 주입 (Claude Code 스타일 steering) ──
|
||||
while (_pendingUserMessages.TryDequeue(out var injectedMsg))
|
||||
{
|
||||
messages.Add(new ChatMessage { Role = "user", Content = injectedMsg });
|
||||
EmitEvent(AgentEventType.UserMessage, "", injectedMsg);
|
||||
}
|
||||
|
||||
var queryView = AgentQueryContextBuilder.Build(messages);
|
||||
var queryMessages = queryView.Messages;
|
||||
if (queryView.BoundaryApplied || queryView.ToolPairExpanded || queryView.TruncatedToolResultCount > 0)
|
||||
{
|
||||
var queryViewSummary =
|
||||
$"query-view {queryView.SourceMessageCount}->{queryView.ViewMessageCount}, " +
|
||||
$"start={queryView.WindowStartIndex}, " +
|
||||
$"pairs={queryView.PreservedToolPairCount}, " +
|
||||
$"tool_result_budget={queryView.TruncatedToolResultCount}, " +
|
||||
$"tokens {queryView.TokensBeforeBudget}->{queryView.TokensAfterBudget}";
|
||||
WorkflowLogService.LogTransition(
|
||||
_conversationId,
|
||||
_currentRunId,
|
||||
iteration,
|
||||
"query_view",
|
||||
queryViewSummary);
|
||||
}
|
||||
|
||||
var isDebugLog = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase);
|
||||
EmitEvent(AgentEventType.Thinking, "", isDebugLog
|
||||
? $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"
|
||||
: "LLM에 요청 중...");
|
||||
|
||||
// Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
|
||||
var activeService = (_settings.Settings.Llm.Service ?? "").Trim();
|
||||
@@ -456,12 +501,12 @@ public partial class AgentLoopService
|
||||
// IBM/Qwen 등 chatty 모델 대응: 첫 번째 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder 주입.
|
||||
// recovery 메시지가 이미 추가된 경우(NoToolCallLoopRetry > 0)에는 중복 주입하지 않음.
|
||||
// 임시 메시지이므로 실제 messages 목록은 수정하지 않고, 별도 sendMessages로 전달.
|
||||
List<ChatMessage> sendMessages = messages;
|
||||
List<ChatMessage> sendMessages = queryMessages;
|
||||
if (forceFirst
|
||||
&& executionPolicy.InjectPreCallToolReminder
|
||||
&& runState.NoToolCallLoopRetry == 0)
|
||||
{
|
||||
sendMessages = [.. messages, new ChatMessage
|
||||
sendMessages = [.. queryMessages, new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
|
||||
@@ -535,16 +580,26 @@ public partial class AgentLoopService
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체
|
||||
var textResp = await _llm.SendAsync(messages, ct);
|
||||
var textResp = await _llm.SendAsync(queryMessages, ct);
|
||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||
return textResp;
|
||||
}
|
||||
catch (ToolCallNotSupportedException ex)
|
||||
{
|
||||
// 서버가 도구 호출을 400으로 거부 → 도구 없이 일반 응답으로 폴백
|
||||
LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: {ex.Message}");
|
||||
// 서버가 도구 호출을 400으로 거부 → 텍스트 기반 도구 호출 시도 후 폴백
|
||||
LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 텍스트 기반 폴백 시도: {ex.Message}");
|
||||
EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…");
|
||||
|
||||
// document_plan이 완료됐지만 html_create 미실행 → 조기 종료 전에 앱이 직접 생성
|
||||
// ── 1단계: 텍스트 기반 도구 호출 폴백 (프롬프트에 도구 설명 임베딩) ──
|
||||
var textFallbackResult = await TryTextBasedToolCallFallbackAsync(
|
||||
messages, cachedActiveTools, context, ct);
|
||||
if (!string.IsNullOrEmpty(textFallbackResult))
|
||||
{
|
||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||
return textFallbackResult;
|
||||
}
|
||||
|
||||
// ── 2단계: document_plan 직접 생성 폴백 ──
|
||||
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
|
||||
{
|
||||
_docFallbackAttempted = true;
|
||||
@@ -603,9 +658,11 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3단계: 순수 텍스트 폴백 ──
|
||||
try
|
||||
{
|
||||
var textResp = await _llm.SendAsync(messages, ct);
|
||||
var textResp = await _llm.SendAsync(queryMessages, ct);
|
||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||
return textResp;
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
@@ -664,9 +721,8 @@ public partial class AgentLoopService
|
||||
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계",
|
||||
steps: planSteps);
|
||||
|
||||
// 플랜 모드 "auto"에서만 승인 대기
|
||||
// - auto: 계획 감지 시 승인 대기 (단, 도구 호출이 함께 있으면 이미 실행 중이므로 스킵)
|
||||
// - off/always: 승인창 띄우지 않음 (off=자동 진행, always=앞에서 이미 처리됨)
|
||||
// 일반 계획 승인은 비활성화 — document_plan 도구 실행 시에만 승인 요청
|
||||
// 코드 작업 등에서 LLM이 번호 매긴 텍스트를 반환해도 불필요한 승인 방지
|
||||
const bool requireApproval = false;
|
||||
|
||||
if (requireApproval && UserDecisionCallback != null)
|
||||
@@ -973,7 +1029,9 @@ public partial class AgentLoopService
|
||||
|
||||
if (!string.IsNullOrEmpty(textResponse))
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||
EmitEvent(AgentEventType.Complete, "",
|
||||
BuildCompletionSummary(iteration, totalToolCalls, statsSuccessCount, statsFailCount, lastArtifactFilePath, statsUsedTools, statsModifiedFiles),
|
||||
filePath: lastArtifactFilePath);
|
||||
return textResponse;
|
||||
}
|
||||
|
||||
@@ -1516,7 +1574,11 @@ public partial class AgentLoopService
|
||||
lastFailedToolSignature = null;
|
||||
repeatedFailedToolSignatureCount = 0;
|
||||
if (!string.IsNullOrWhiteSpace(result.FilePath))
|
||||
{
|
||||
lastArtifactFilePath = result.FilePath;
|
||||
if (IsFileModifyingTool(effectiveCall.ToolName))
|
||||
statsModifiedFiles.Add(result.FilePath);
|
||||
}
|
||||
|
||||
// 도구 결과를 LLM에 피드백
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
@@ -1575,6 +1637,46 @@ public partial class AgentLoopService
|
||||
ref documentPlanTitle,
|
||||
ref documentPlanScaffold);
|
||||
|
||||
// document_plan 도구 실행 후 계획 승인 대기 — 비활성화 (사용자 요청)
|
||||
// 문서 작업 시 자동으로 계획창이 나오지 않도록 함
|
||||
if (false && string.Equals(effectiveCall.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase)
|
||||
&& result.Success
|
||||
&& UserDecisionCallback != null
|
||||
&& !_documentPlanApproved)
|
||||
{
|
||||
var planOutput = result.Output ?? string.Empty;
|
||||
var planStepsFromTool = ExtractDocumentPlanSections(planOutput);
|
||||
|
||||
EmitEvent(AgentEventType.Decision, "", $"문서 계획 확인 대기 · {planStepsFromTool.Count}개 섹션",
|
||||
steps: planStepsFromTool);
|
||||
|
||||
var planDecision = await UserDecisionCallback(
|
||||
planOutput,
|
||||
new List<string> { "승인", "수정 요청", "취소" });
|
||||
|
||||
if (string.Equals(planDecision, "취소", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
|
||||
return "작업이 중단되었습니다.";
|
||||
}
|
||||
|
||||
// 승인 완료 — 이후 루프에서 다시 물어보지 않음
|
||||
_documentPlanApproved = true;
|
||||
|
||||
if (planDecision != null
|
||||
&& !string.Equals(planDecision, "승인", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(planDecision, "확인", StringComparison.OrdinalIgnoreCase)
|
||||
&& !TryParseApprovedPlanDecision(planDecision, out _, out _))
|
||||
{
|
||||
// 수정 요청 — 사용자 피드백을 메시지에 추가
|
||||
_documentPlanApproved = false; // 수정 시 재승인 필요
|
||||
messages.Add(new ChatMessage { Role = "user", Content = planDecision });
|
||||
EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중...");
|
||||
continue; // 루프 재시작
|
||||
}
|
||||
// 승인 — 그대로 진행
|
||||
}
|
||||
|
||||
var (terminalCompleted, consumedExtraIteration) = await TryHandleTerminalDocumentCompletionTransitionAsync(
|
||||
effectiveCall,
|
||||
result,
|
||||
@@ -2240,6 +2342,14 @@ public partial class AgentLoopService
|
||||
or "pptx_create" or "document_assemble" or "csv_create";
|
||||
}
|
||||
|
||||
/// <summary>파일을 생성하거나 수정하는 도구인지 확인합니다.</summary>
|
||||
private static bool IsFileModifyingTool(string toolName)
|
||||
{
|
||||
return toolName is "file_write" or "file_edit" or "file_manage" or "script_create"
|
||||
or "html_create" or "docx_create" or "excel_create" or "csv_create"
|
||||
or "pptx_create" or "markdown_create" or "chart_create" or "document_assemble";
|
||||
}
|
||||
|
||||
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
|
||||
private static bool IsCodeVerificationTarget(string toolName)
|
||||
{
|
||||
@@ -3647,6 +3757,180 @@ public partial class AgentLoopService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버가 OpenAI tools 파라미터를 거부(400)할 때, 프롬프트에 도구 설명을 임베딩하고
|
||||
/// <tool_call> 형식으로 출력하게 유도하여 텍스트 기반 도구 호출을 시도합니다.
|
||||
/// </summary>
|
||||
private async Task<string?> TryTextBasedToolCallFallbackAsync(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> activeTools,
|
||||
AgentContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (activeTools.Count == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
// 도구 설명을 텍스트로 빌드
|
||||
var toolDesc = new StringBuilder();
|
||||
toolDesc.AppendLine("# Available Tools");
|
||||
toolDesc.AppendLine("To call a tool, output EXACTLY this format (no other text before it):");
|
||||
toolDesc.AppendLine("<tool_call>");
|
||||
toolDesc.AppendLine("{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}");
|
||||
toolDesc.AppendLine("</tool_call>");
|
||||
toolDesc.AppendLine();
|
||||
foreach (var tool in activeTools)
|
||||
{
|
||||
toolDesc.AppendLine($"## {tool.Name}");
|
||||
toolDesc.AppendLine($"{tool.Description}");
|
||||
if (tool.Parameters?.Properties != null)
|
||||
{
|
||||
toolDesc.AppendLine("Parameters:");
|
||||
foreach (var (pName, pSchema) in tool.Parameters.Properties)
|
||||
{
|
||||
var required = tool.Parameters.Required?.Contains(pName) == true ? " (required)" : "";
|
||||
toolDesc.AppendLine($" - {pName} ({pSchema.Type}): {pSchema.Description}{required}");
|
||||
}
|
||||
}
|
||||
toolDesc.AppendLine();
|
||||
}
|
||||
|
||||
// 메시지 복사 후 도구 설명을 user 메시지로 추가
|
||||
var enhancedMessages = new List<ChatMessage>(messages);
|
||||
enhancedMessages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"[SYSTEM] The API tool-calling endpoint is unavailable. " +
|
||||
$"You MUST call tools by outputting <tool_call> tags as shown below.\n\n" +
|
||||
$"{toolDesc}\n" +
|
||||
$"Now call the appropriate tool(s) using <tool_call> format to complete the task."
|
||||
});
|
||||
|
||||
EmitEvent(AgentEventType.Thinking, "", "텍스트 기반 도구 호출로 재시도 중…");
|
||||
var textResp = await _llm.SendAsync(enhancedMessages, ct);
|
||||
if (string.IsNullOrEmpty(textResp)) return null;
|
||||
|
||||
// <tool_call> 태그에서 도구 호출 파싱
|
||||
var extractedCalls = LlmService.TryExtractToolCallsFromText(textResp);
|
||||
if (extractedCalls.Count == 0)
|
||||
{
|
||||
LogService.Debug("[AgentLoop] 텍스트 기반 폴백: <tool_call> 태그를 찾지 못함");
|
||||
return null;
|
||||
}
|
||||
|
||||
LogService.Info($"[AgentLoop] 텍스트 기반 폴백: {extractedCalls.Count}개 도구 호출 파싱 성공");
|
||||
|
||||
// 파싱된 도구 호출 실행
|
||||
var results = new StringBuilder();
|
||||
string? lastFilePath = null;
|
||||
foreach (var call in extractedCalls)
|
||||
{
|
||||
var tool = activeTools.FirstOrDefault(t =>
|
||||
string.Equals(t.Name, call.ToolName, StringComparison.OrdinalIgnoreCase));
|
||||
if (tool == null)
|
||||
{
|
||||
EmitEvent(AgentEventType.Error, call.ToolName ?? "", $"알 수 없는 도구: {call.ToolName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitEvent(AgentEventType.ToolCall, tool.Name, $"텍스트 기반 도구 호출: {tool.Name}");
|
||||
|
||||
// 권한 확인
|
||||
var permResult = await EnforceToolPermissionAsync(tool.Name, call.ToolInput ?? default, context, messages);
|
||||
if (permResult != null)
|
||||
{
|
||||
EmitEvent(AgentEventType.ToolResult, tool.Name, $"⚠ 권한 거부: {tool.Name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var toolResult = await tool.ExecuteAsync(call.ToolInput ?? default, context, ct);
|
||||
var summary = TruncateOutput(toolResult.Output, 300);
|
||||
EmitEvent(
|
||||
AgentEventType.ToolResult,
|
||||
tool.Name,
|
||||
toolResult.Success ? $"✅ {tool.Name}: {summary}" : $"❌ {tool.Name}: {summary}",
|
||||
filePath: toolResult.FilePath);
|
||||
|
||||
if (toolResult.Success)
|
||||
{
|
||||
results.AppendLine(toolResult.Output);
|
||||
if (!string.IsNullOrEmpty(toolResult.FilePath))
|
||||
lastFilePath = toolResult.FilePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
results.AppendLine($"⚠ {tool.Name} 실패: {toolResult.Output}");
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Length == 0) return null;
|
||||
|
||||
// 도구 실행 결과를 LLM에 보내서 최종 응답 생성
|
||||
var finalMessages = new List<ChatMessage>(messages);
|
||||
finalMessages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"[도구 실행 결과]\n{results}\n\n위 결과를 바탕으로 사용자에게 간결하게 보고하세요."
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var finalResp = await _llm.SendAsync(finalMessages, ct);
|
||||
return !string.IsNullOrEmpty(finalResp) ? finalResp : results.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return results.ToString();
|
||||
}
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
{
|
||||
LogService.Warn($"[AgentLoop] 텍스트 기반 도구 호출 폴백 실패: {fallbackEx.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>정상 완료 시 작업 요약 문자열 생성.</summary>
|
||||
private static string BuildCompletionSummary(
|
||||
int iterations,
|
||||
int totalToolCalls,
|
||||
int successCount,
|
||||
int failCount,
|
||||
string? lastArtifactFilePath,
|
||||
List<string> usedTools,
|
||||
HashSet<string>? modifiedFiles = null)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
parts.Add($"반복 {iterations}회");
|
||||
if (totalToolCalls > 0)
|
||||
parts.Add($"도구 {totalToolCalls}회 (성공 {successCount}, 실패 {failCount})");
|
||||
if (!string.IsNullOrEmpty(lastArtifactFilePath))
|
||||
parts.Add($"산출물: {System.IO.Path.GetFileName(lastArtifactFilePath)}");
|
||||
|
||||
// 수정/생성된 파일 목록
|
||||
if (modifiedFiles != null && modifiedFiles.Count > 0)
|
||||
{
|
||||
var fileNames = modifiedFiles
|
||||
.Select(f => System.IO.Path.GetFileName(f))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (fileNames.Count <= 4)
|
||||
parts.Add($"변경 파일: {string.Join(", ", fileNames)}");
|
||||
else
|
||||
parts.Add($"변경 파일: {string.Join(", ", fileNames.Take(3))} 외 {fileNames.Count - 3}개");
|
||||
}
|
||||
|
||||
if (usedTools.Count > 0)
|
||||
{
|
||||
var distinct = usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList();
|
||||
var toolList = string.Join(", ", distinct);
|
||||
if (usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Count() > 6)
|
||||
toolList += $" 외 {usedTools.Distinct(StringComparer.OrdinalIgnoreCase).Count() - 6}개";
|
||||
parts.Add($"사용 도구: {toolList}");
|
||||
}
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildIterationLimitFallbackResponse(
|
||||
int maxIterations,
|
||||
TaskTypePolicy taskPolicy,
|
||||
@@ -4188,7 +4472,7 @@ public partial class AgentLoopService
|
||||
private AgentContext BuildContext()
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
var baseWorkFolder = llm.WorkFolder;
|
||||
var baseWorkFolder = ResolveTabWorkFolder(llm, ActiveTab);
|
||||
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
|
||||
return new AgentContext
|
||||
{
|
||||
@@ -4207,6 +4491,58 @@ public partial class AgentLoopService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭(Cowork/Code)별 작업 폴더를 결정합니다.
|
||||
/// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백.
|
||||
/// 최종 경로가 비어있으면 기본 경로를 생성합니다.
|
||||
/// </summary>
|
||||
private static string ResolveTabWorkFolder(LlmSettings llm, string? activeTab)
|
||||
{
|
||||
var isCode = string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 탭별 전용 경로 확인
|
||||
var tabFolder = isCode ? llm.CodeWorkFolder : llm.CoworkWorkFolder;
|
||||
|
||||
// 탭 전용 경로가 설정되어 있으면 사용
|
||||
if (!string.IsNullOrWhiteSpace(tabFolder))
|
||||
{
|
||||
EnsureDirectoryExists(tabFolder);
|
||||
return tabFolder;
|
||||
}
|
||||
|
||||
// 레거시 WorkFolder 폴백
|
||||
if (!string.IsNullOrWhiteSpace(llm.WorkFolder))
|
||||
{
|
||||
EnsureDirectoryExists(llm.WorkFolder);
|
||||
return llm.WorkFolder;
|
||||
}
|
||||
|
||||
// 모두 미설정 → 기본 경로 자동 생성
|
||||
var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
var defaultRoot = Path.Combine(docs, "AX Agent");
|
||||
var defaultTab = Path.Combine(defaultRoot, isCode ? "Code" : "Cowork");
|
||||
EnsureDirectoryExists(defaultTab);
|
||||
|
||||
// 설정에 기본값 기록 (다음 실행 시 유지)
|
||||
if (isCode)
|
||||
llm.CodeWorkFolder = defaultTab;
|
||||
else
|
||||
llm.CoworkWorkFolder = defaultTab;
|
||||
|
||||
return defaultTab;
|
||||
}
|
||||
|
||||
/// <summary>디렉토리가 없으면 생성합니다.</summary>
|
||||
private static void EnsureDirectoryExists(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
catch { /* 권한 문제 등은 무시 — 이후 도구 실행에서 별도 에러 처리 */ }
|
||||
}
|
||||
|
||||
private static string ResolveRuntimeWorkFolder(string? configuredRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredRoot))
|
||||
@@ -4593,6 +4929,15 @@ public partial class AgentLoopService
|
||||
// AgentLogLevel에 따라 이벤트 필터링
|
||||
var logLevel = _settings.Settings.Llm.AgentLogLevel;
|
||||
|
||||
// hidden: Complete, Error, Decision, Permission, total_stats, ToolCall, ToolResult, StepDone 전달
|
||||
// (ToolCall/ToolResult/StepDone는 라이브 진행 카드용으로 필요)
|
||||
if (logLevel == "hidden" && type is not (AgentEventType.Complete or AgentEventType.Error
|
||||
or AgentEventType.Decision or AgentEventType.PermissionRequest
|
||||
or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied
|
||||
or AgentEventType.ToolCall or AgentEventType.ToolResult
|
||||
or AgentEventType.StepDone or AgentEventType.StepStart))
|
||||
return;
|
||||
|
||||
// simple: ToolCall, ToolResult, Error, Complete, StepStart, StepDone, Decision만
|
||||
if (logLevel == "simple" && type is AgentEventType.Thinking or AgentEventType.Planning)
|
||||
return;
|
||||
@@ -4602,7 +4947,8 @@ public partial class AgentLoopService
|
||||
summary = summary[..200] + "…";
|
||||
|
||||
// debug 아닌 경우 ToolInput 제거
|
||||
if (logLevel != "debug")
|
||||
// hidden/simple: ToolInput 제거, detailed/debug: 유지
|
||||
if (logLevel is "hidden" or "simple")
|
||||
toolInput = null;
|
||||
|
||||
var evt = new AgentEvent
|
||||
@@ -4655,6 +5001,14 @@ public partial class AgentLoopService
|
||||
var toolName = call.ToolName ?? "";
|
||||
var input = call.ToolInput;
|
||||
|
||||
// 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략
|
||||
if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
|
||||
{
|
||||
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
|
||||
if (PermissionModeCatalog.IsBypassPermissions(effectivePerm))
|
||||
return null;
|
||||
}
|
||||
|
||||
// Git 커밋 — 수준에 관계없이 무조건 확인
|
||||
if (toolName == "git_tool")
|
||||
{
|
||||
@@ -4948,4 +5302,55 @@ public partial class AgentLoopService
|
||||
steps: steps);
|
||||
}
|
||||
|
||||
/// <summary>document_plan 도구 결과에서 섹션 목록을 추출합니다.</summary>
|
||||
private static List<string> ExtractDocumentPlanSections(string planOutput)
|
||||
{
|
||||
var sections = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(planOutput))
|
||||
{
|
||||
sections.Add("문서 계획 검토");
|
||||
return sections;
|
||||
}
|
||||
|
||||
// 1) JSON의 "heading" 필드 추출
|
||||
var headingMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planOutput, @"""heading""\s*:\s*""([^""]+)""");
|
||||
foreach (System.Text.RegularExpressions.Match m in headingMatches)
|
||||
sections.Add(m.Groups[1].Value);
|
||||
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 2) HTML <h2> 태그
|
||||
sections.Clear();
|
||||
var h2Matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planOutput, @"<h2>([^<]+)</h2>");
|
||||
foreach (System.Text.RegularExpressions.Match m in h2Matches)
|
||||
sections.Add(m.Groups[1].Value);
|
||||
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 3) Markdown ## 헤딩
|
||||
sections.Clear();
|
||||
var mdMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planOutput, @"(?:^|\n)##\s+(.+?)(?:\n|$)");
|
||||
foreach (System.Text.RegularExpressions.Match m in mdMatches)
|
||||
{
|
||||
var heading = m.Groups[1].Value.Trim();
|
||||
if (!heading.StartsWith("[") && !heading.Contains("즉시 실행"))
|
||||
sections.Add(heading);
|
||||
}
|
||||
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 4) 번호 매긴 단계
|
||||
sections.Clear();
|
||||
var numbered = TaskDecomposer.ExtractSteps(planOutput);
|
||||
if (numbered.Count >= 2) return numbered;
|
||||
|
||||
// 5) 폴백
|
||||
sections.Clear();
|
||||
sections.Add("문서 계획 검토");
|
||||
return sections;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user