컨텍스트 전송 뷰와 압축 트리거를 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:
2026-04-12 21:36:50 +09:00
parent 9175dfe657
commit 0f83dc802c
6 changed files with 844 additions and 42 deletions

View File

@@ -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)할 때, 프롬프트에 도구 설명을 임베딩하고
/// &lt;tool_call&gt; 형식으로 출력하게 유도하여 텍스트 기반 도구 호출을 시도합니다.
/// </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;
}
}