??? ???? ???? ??? ?? ???? ???? ??? ? ?????? ?? ?? ??

- WPF/MVVM, ASP.NET API, React/Vue/Next, Node, Python API, Spring, Android, Go, Rust CLI, generic solution ??? ProjectScaffold? ???? ???? ????? ???
- Code ?? empty workspace ??? ??? ????? ??? ?? ??? file_write? ?? ???? ??? ????? file_manage/file_write? ?? ?? ???? ???? ???
- AgentLoop ?? ? ProjectLayoutGate? ??? ??? ?? ??? ?? ??? ??? ????? ?? ??? ? ????? ???
- code-scaffold.skill.md? when_to_use? file_manage/file_edit ?????? ??? proactive auto-skill ???? ???
- IntentGate, scaffold profile, code quality ?? ???? ???

?? ??
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\ ?? 0 / ?? 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\ ?? 183
This commit is contained in:
2026-04-15 23:46:05 +09:00
parent 4980113b99
commit 6810fb1954
15 changed files with 1140 additions and 286 deletions

View File

@@ -2326,3 +2326,15 @@ MIT License
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_build_failure_recovery\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_build_failure_recovery_tests\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery_tests\\` 통과 134
업데이트: 2026-04-15 23:44 (KST)
- Code 탭의 구조형 프로젝트 생성 흐름을 프로젝트 스캐폴드 모드로 승격했습니다. `src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs`를 추가해 WPF/MVVM, ASP.NET API, React/Vue/Next, Node, Python API, Spring, Android, Go, Rust CLI, generic solution 요청을 감지하고 최소 폴더 구조/대표 파일 경로를 프로파일 단위로 제안합니다.
- `src/AxCopilot/Services/Agent/IntentGateService.cs`, `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`, `src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs`, `src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs`, `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`를 함께 조정해 빈 작업 폴더에서 `index.html/app.js/style.css` 같은 평면 예시 대신 `file_manage(mkdir)`와 중첩 `file_write`로 최소 프로젝트 트리를 먼저 만들도록 유도합니다.
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs`에는 프로젝트 레이아웃 게이트를 추가했습니다. 초기 워크스페이스가 비어 있었고 구조형 스캐폴드 요청이 감지된 경우, 루트에 구현 파일이 평면으로 쌓인 채 종료하려 하면 `Views/ViewModels/Models/Services/Themes`, `src/components/pages/services`, `app/api/models/services`, `src/main/java/resources/test` 같은 프레임워크별 기본 분리를 다시 요구합니다.
- `src/AxCopilot/skills/code-scaffold.skill.md``when_to_use``file_manage/file_edit` 메타데이터를 추가해 proactive auto-skill 대상으로 승격했습니다. 이제 새 프로젝트/프레임워크 앱 생성에서는 기존 코드 탐색보다 폴더 트리 설계와 스캐폴딩 규칙이 먼저 주입됩니다.
- 테스트:
- `src/AxCopilot.Tests/Services/IntentGateServiceTests.cs`
- `src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs`
- `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183

View File

@@ -1657,3 +1657,41 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_build_failure_recovery\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_build_failure_recovery_tests\\ -p:IntermediateOutputPath=obj\\verify_build_failure_recovery_tests\\` 통과 134
업데이트: 2026-04-15 23:44 (KST)
- 구조형 프로젝트 스캐폴드 정책을 추가했다. `src/AxCopilot/Services/Agent/ProjectScaffoldProfileCatalog.cs`
- WPF/MVVM, ASP.NET API, React/Vue/Next, Node backend, Python API, Spring Boot, Android/Kotlin, Go service, Rust CLI, generic solution 프로파일을 정의한다.
- 프로파일마다 트리거 토큰, 기대 폴더, 대표 시작 파일, 루트 허용 파일, 최소 디렉터리 적중 기준을 관리한다.
- `AssessLayout(...)`로 초기 빈 워크스페이스에서 생성된 결과가 평면 루트 파일 위주인지 검사한다.
- 인텐트/탐색 흐름을 프로젝트 스캐폴드 중심으로 재구성했다.
- `src/AxCopilot/Services/Agent/IntentGateService.cs`
- Code 탭에서 구조형 프로젝트 요청을 `ExplorationScope.ProjectScaffold`로 분류한다.
- WPF, XAML, csproj, MVVM, ViewModel, ResourceDictionary뿐 아니라 React, Python API, Spring, Android, Go, Rust 같은 다른 언어/개발 유형도 같은 기준으로 감지한다.
- `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`
- `ProjectScaffold` scope를 추가하고 `file_manage/file_write/file_edit`를 우선 노출한다.
- 빈 워크스페이스의 구조형 요청은 broad 탐색 대신 최소 트리 생성 후 구현 파일 배치를 기본 순서로 안내한다.
- `src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs`
- 탐색 상태 초기화 시 `ProjectScaffoldProfileCatalog.Detect(...)` 결과를 붙여 run 단위 스캐폴드 메타를 유지한다.
- `src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs`
- empty workspace 복구/초기 가이드를 `file_write 즉시 호출`에서 `single file direct create / multi-file scaffold tree-first` 규칙으로 확장했다.
- `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`
- Code 시스템 프롬프트에 `file_manage`를 실제 available tools로 반영하고, 프레임워크/멀티파일 스캐폴드는 트리 생성부터 시작하라고 명시했다.
- 코드 품질 게이트에 프로젝트 레이아웃 검사를 추가했다.
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`
- `RunState.ProjectLayoutGateRetry`를 추가했다.
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs`
- 구조형 스캐폴드 요청이었고 초기 워크스페이스가 비어 있었던 경우, 루트에 구현 파일이 평면으로 남아 있으면 종료 전 `ProjectLayoutGate`를 발동한다.
- `file_manage(mkdir/move)``file_edit/file_write`로 폴더 재배치를 먼저 수행하도록 요구한다.
- 스킬 메타데이터도 함께 보강했다.
- `src/AxCopilot/skills/code-scaffold.skill.md`
- `when_to_use`를 추가해 auto-skill 후보로 포함되게 했다.
- `file_manage`, `file_edit`를 허용 도구에 추가하고, 빈 작업 폴더에서는 최소 폴더 트리부터 설계하도록 명시했다.
- 테스트:
- `src/AxCopilot.Tests/Services/IntentGateServiceTests.cs`
- WPF/React 구조형 요청이 `ProjectScaffold`로 분류되는지 확인한다.
- `src/AxCopilot.Tests/Services/ProjectScaffoldProfileCatalogTests.cs`
- WPF/FastAPI 프로파일 감지와 flat-root vs structured-layout 평가를 검증한다.
- `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`
- `BuildProjectLayoutGatePrompt(...)`가 폴더 재배치 지시를 포함하는지 검증한다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\` 통과 183

View File

@@ -258,6 +258,31 @@ public class AgentLoopCodeQualityTests
prompt.Should().NotContain("git_tool(diff) -> targeted tool retry");
}
[Fact]
public void BuildProjectLayoutGatePrompt_CallsForFolderReorganization()
{
var profile = ProjectScaffoldProfileCatalog.Detect(
"Create a C# WPF MVVM desktop app with ResourceDictionary themes",
"Code")!;
var assessment = new ProjectScaffoldLayoutAssessment(
IsSatisfied: false,
MatchedDirectoryCount: 0,
ExistingDirectories: [],
MissingDirectories: ["Views", "ViewModels", "Themes"],
SuspiciousRootFiles: ["MainWindow.xaml", "MainWindowViewModel.cs"]);
var prompt = InvokePrivateStatic<string>(
"BuildProjectLayoutGatePrompt",
profile,
assessment);
prompt.Should().Contain("[System:ProjectLayoutGate]");
prompt.Should().Contain("Views");
prompt.Should().Contain("ViewModels");
prompt.Should().Contain("MainWindow.xaml");
prompt.Should().Contain("file_manage(mkdir/move)");
}
[Fact]
public void BuildFailureNextToolPriorityPrompt_IncludesOrderedPriority()
{

View File

@@ -135,6 +135,22 @@ public class IntentGateServiceTests
result.SuggestedScope.Should().Be(ExplorationScope.Localized);
}
[Fact]
public async Task ClassifyAsync_WpfProjectQuery_SuggestsProjectScaffold()
{
var result = await _sut.ClassifyAsync("Create a new C# WPF MVVM project with ResourceDictionary themes", "Code");
result.SuggestedScope.Should().Be(ExplorationScope.ProjectScaffold);
}
[Fact]
public async Task ClassifyAsync_ReactProjectQuery_SuggestsProjectScaffold()
{
var result = await _sut.ClassifyAsync("Generate a React TypeScript frontend starter with pages and services", "Code");
result.SuggestedScope.Should().Be(ExplorationScope.ProjectScaffold);
}
// ═══════════════════════════════════════════
// 복합 요청 감지 (P5)
// ═══════════════════════════════════════════

View File

@@ -0,0 +1,85 @@
using AxCopilot.Services.Agent;
using FluentAssertions;
using System.IO;
using Xunit;
namespace AxCopilot.Tests.Services;
public class ProjectScaffoldProfileCatalogTests
{
[Fact]
public void Detect_ReturnsWpfProfile_ForWpfQuery()
{
var profile = ProjectScaffoldProfileCatalog.Detect(
"Create a C# WPF MVVM desktop app with ResourceDictionary themes",
"Code");
profile.Should().NotBeNull();
profile!.Key.Should().Be("wpf-mvvm");
}
[Fact]
public void Detect_ReturnsPythonServiceProfile_ForFastApiQuery()
{
var profile = ProjectScaffoldProfileCatalog.Detect(
"Generate a Python FastAPI backend service scaffold",
"Code");
profile.Should().NotBeNull();
profile!.Key.Should().Be("python-service");
}
[Fact]
public void AssessLayout_Fails_WhenStructuredProjectFilesAreFlatInRoot()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-scaffold-flat-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "MainWindow.xaml"), "<Window />");
File.WriteAllText(Path.Combine(tempDir, "MainWindowViewModel.cs"), "class MainWindowViewModel {}");
var profile = ProjectScaffoldProfileCatalog.Detect(
"Create a C# WPF MVVM desktop app with ResourceDictionary themes",
"Code")!;
var assessment = ProjectScaffoldProfileCatalog.AssessLayout(profile, tempDir);
assessment.IsSatisfied.Should().BeFalse();
assessment.SuspiciousRootFiles.Should().Contain("MainWindow.xaml");
assessment.SuspiciousRootFiles.Should().Contain("MainWindowViewModel.cs");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void AssessLayout_Passes_WhenExpectedDirectoriesExist()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-scaffold-structured-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
Directory.CreateDirectory(Path.Combine(tempDir, "Views"));
Directory.CreateDirectory(Path.Combine(tempDir, "ViewModels"));
Directory.CreateDirectory(Path.Combine(tempDir, "Themes"));
File.WriteAllText(Path.Combine(tempDir, "App.xaml"), "<Application />");
var profile = ProjectScaffoldProfileCatalog.Detect(
"Create a C# WPF MVVM desktop app with ResourceDictionary themes",
"Code")!;
var assessment = ProjectScaffoldProfileCatalog.AssessLayout(profile, tempDir);
assessment.IsSatisfied.Should().BeTrue();
assessment.MatchedDirectoryCount.Should().BeGreaterThanOrEqualTo(2);
assessment.SuspiciousRootFiles.Should().BeEmpty();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
}

View File

@@ -66,7 +66,7 @@ public partial class AgentLoopService
var blockedMessage = isExternalEscalation
? $"Empty workspace guard blocked external path search: {target}"
: $"Empty workspace guard blocked detour tool '{toolName}'. Use file_write directly.";
: $"Empty workspace guard blocked detour tool '{toolName}'. Use file_manage/file_write directly.";
EmitEvent(AgentEventType.Error, toolName, blockedMessage);
EmitEvent(
@@ -81,18 +81,34 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildEmptyWorkspaceCreationRecoveryPrompt(activeToolNames)
Content = BuildEmptyWorkspaceCreationRecoveryPrompt(activeToolNames, explorationState: null, context.InitialUserQuery)
});
return true;
}
private static string BuildEmptyWorkspaceCreationRecoveryPrompt(IReadOnlyCollection<string> activeToolNames)
private static string BuildEmptyWorkspaceCreationRecoveryPrompt(
IReadOnlyCollection<string> activeToolNames,
ExplorationTrackingState? explorationState,
string? userQuery)
{
var activeToolPreview = AgentLoopNoToolResponseRecoveryService.BuildActiveToolPreview(activeToolNames);
var scaffoldProfile = explorationState?.ScaffoldProfile ?? ProjectScaffoldProfileCatalog.Detect(userQuery, "Code");
if (scaffoldProfile != null)
{
var layoutPreview = ProjectScaffoldProfileCatalog.BuildDirectoryPreview(scaffoldProfile);
var starterPreview = ProjectScaffoldProfileCatalog.BuildStarterPathPreview(scaffoldProfile);
return "[System:EmptyWorkspaceCreation] The current work folder is empty and the request is a structured project scaffold. " +
"Do not search C:\\, other drive roots, or repeat folder_map, grep, glob, file_read, env_tool, skill_manager, or mcp tools. " +
$"First establish a minimal folder layout such as {layoutPreview}, then create representative files directly in those paths. " +
$"Use file_manage(mkdir) and file_write with relative paths like {starterPreview}. " +
"Do not flatten implementation files in the workspace root, and do not only describe the plan. Emit the actual tool calls now. " +
$"Available tools: {activeToolPreview}";
}
return "[System:EmptyWorkspaceCreation] The current work folder is empty. " +
"Do not search C:\\, other drive roots, or repeat folder_map, grep, glob, file_read, env_tool, skill_manager, or mcp tools. " +
"Call file_write immediately using a relative path in the current work folder and create the needed file directly. " +
"For example: index.html, app.js, or style.css. " +
"If the user asked for a multi-file app or framework scaffold, create a minimal project tree first instead of flattening files in the root. " +
"Do not only describe the plan. Emit the actual tool call now. " +
$"Available tools: {activeToolPreview}";
}
@@ -108,7 +124,7 @@ public partial class AgentLoopService
if (!runState.WorkspaceAppearsEmpty)
return;
if (explorationState.Scope != ExplorationScope.DirectCreation)
if (explorationState.Scope is not (ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold))
return;
runState.EmptyWorkspaceGuardTriggered = true;
@@ -116,10 +132,7 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "system",
Content =
"[System:EmptyWorkspaceStart] The current work folder is empty and the user asked to create a new artifact. " +
"Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " +
"Call file_write immediately with a relative path inside the current work folder and create the requested file directly."
Content = BuildInitialEmptyWorkspaceGuidance(explorationState, contextHint: null)
});
EmitEvent(
@@ -128,6 +141,28 @@ public partial class AgentLoopService
"빈 작업 폴더 감지 · 탐색 없이 새 파일 생성을 시작합니다");
}
private static string BuildInitialEmptyWorkspaceGuidance(
ExplorationTrackingState explorationState,
string? contextHint)
{
var scaffoldProfile = explorationState.ScaffoldProfile
?? ProjectScaffoldProfileCatalog.Detect(contextHint, "Code");
if (explorationState.Scope == ExplorationScope.ProjectScaffold && scaffoldProfile != null)
{
var layoutPreview = ProjectScaffoldProfileCatalog.BuildDirectoryPreview(scaffoldProfile);
var starterPreview = ProjectScaffoldProfileCatalog.BuildStarterPathPreview(scaffoldProfile);
return "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked for a structured project scaffold. " +
"Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " +
$"Create a minimal folder layout such as {layoutPreview} first, then place code into relative paths like {starterPreview}. " +
"Use file_manage(mkdir) and nested file_write/file_edit calls, and do not dump implementation files into the workspace root.";
}
return "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked to create a new artifact. " +
"Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " +
"Call file_write immediately with a relative path inside the current work folder and create the requested file directly. " +
"If the request is a framework or multi-file scaffold, create a minimal project tree first instead of flattening files in the root.";
}
private static bool IsExternalWorkspaceEscalationTarget(string? target, string? workFolder)
{
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(workFolder))

View File

@@ -11,7 +11,7 @@ public partial class AgentLoopService
TopicBased,
RepoWide,
OpenEnded,
/// <summary>문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용.</summary>
ProjectScaffold,
DirectCreation,
}
@@ -24,8 +24,8 @@ public partial class AgentLoopService
public bool BroadScanDetected { get; set; }
public bool SelectiveHit { get; set; }
public bool CorrectiveHintInjected { get; set; }
/// <summary>스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀.</summary>
public bool SkillAllowedToolsActive { get; set; }
public ProjectScaffoldProfile? ScaffoldProfile { get; set; }
}
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
@@ -38,21 +38,37 @@ public partial class AgentLoopService
if (tools.Count == 0)
return tools;
// 스킬 런타임 정책으로 allowed-tools가 명시된 경우 탐색 필터링을 건너뜀
// — 스킬이 의도적으로 허용한 도구(folder_map 등)를 정책이 차단하면 안 됨
if (state.SkillAllowedToolsActive)
return tools;
// 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치
if (state.Scope == ExplorationScope.ProjectScaffold)
{
var scaffoldFirst = new List<IAgentTool>(tools.Count);
scaffoldFirst.AddRange(tools.Where(IsProjectScaffoldTool));
scaffoldFirst.AddRange(tools.Where(t =>
!IsProjectScaffoldTool(t)
&& !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
scaffoldFirst.AddRange(tools.Where(t =>
string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)
|| string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)));
return scaffoldFirst
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
if (state.Scope == ExplorationScope.DirectCreation)
{
var creationFirst = new List<IAgentTool>(tools.Count);
creationFirst.AddRange(tools.Where(IsDocumentCreationTool));
creationFirst.AddRange(tools.Where(t => !IsDocumentCreationTool(t)
creationFirst.AddRange(tools.Where(t =>
!IsDocumentCreationTool(t)
&& !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
// 탐색 도구는 마지막에 (필요 시에만 사용)
creationFirst.AddRange(tools.Where(t =>
string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
@@ -66,12 +82,11 @@ public partial class AgentLoopService
var ordered = new List<IAgentTool>(tools.Count);
ordered.AddRange(tools.Where(IsSelectiveDiscoveryTool));
ordered.AddRange(tools.Where(t => !IsSelectiveDiscoveryTool(t) &&
(allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))));
ordered.AddRange(tools.Where(t =>
!IsSelectiveDiscoveryTool(t)
&& (allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))));
if (allowFolderMap)
{
ordered.AddRange(tools.Where(t => string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)));
}
return ordered
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
@@ -86,14 +101,19 @@ public partial class AgentLoopService
or "document_assemble" or "file_write";
}
private static bool IsProjectScaffoldTool(IAgentTool tool)
{
return tool.Name is "file_manage" or "file_write" or "file_edit" or "file_read"
or "build_run" or "test_loop" or "dev_env_detect" or "process";
}
private static bool ShouldAllowFolderMapForCurrentIteration(
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
// 문서 생성 모드에서는 folder_map 차단 — 탐색 없이 바로 생성
if (state.Scope == ExplorationScope.DirectCreation)
if (state.Scope is ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold)
return false;
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
@@ -116,23 +136,8 @@ public partial class AgentLoopService
return ContainsAny(
userQuery,
"folder",
"directory",
"tree",
"structure",
"list files",
"workspace layout",
"work folder",
"project structure",
"폴더",
"디렉터리",
"폴더 구조",
"디렉터리 구조",
"파일 목록",
"작업 폴더",
"폴더 안",
"구조를 보여",
"구조 확인");
"folder", "directory", "tree", "structure", "list files", "workspace layout", "work folder", "project structure",
"폴더", "디렉터리", "폴더 구조", "디렉터리 구조", "파일 목록", "작업 폴더", "구조를 보여", "구조 확인");
}
private static bool IsExistingMaterialReferenceRequest(string userQuery)
@@ -142,21 +147,8 @@ public partial class AgentLoopService
return ContainsAny(
userQuery,
"existing file",
"existing files",
"existing document",
"existing documents",
"reference files",
"reference docs",
"existing materials",
"기존 파일",
"기존 문서",
"기존 자료",
"참고 파일",
"참고 문서",
"폴더 내 자료",
"안의 자료",
"작업 폴더 파일");
"existing file", "existing files", "existing document", "existing documents", "reference files", "reference docs", "existing materials",
"기존 파일", "기존 문서", "기존 자료", "참고 파일", "참고 문서", "작업 폴더 파일");
}
private static bool IsSelectiveDiscoveryTool(IAgentTool tool)
@@ -171,27 +163,9 @@ public partial class AgentLoopService
return ContainsAny(
userQuery,
"definition",
"reference",
"references",
"implementation",
"implementations",
"caller",
"callers",
"callee",
"call hierarchy",
"symbol",
"symbols",
"interface",
"override",
"정의",
"참조",
"구현",
"호출부",
"호출 관계",
"심볼",
"인터페이스",
"오버라이드");
"definition", "reference", "references", "implementation", "implementations",
"caller", "callers", "callee", "call hierarchy", "symbol", "symbols", "interface", "override",
"정의", "참조", "구현", "호출부", "호출 관계", "심볼", "인터페이스", "오버라이드");
}
private static string BuildPreferredInitialToolSequence(
@@ -200,7 +174,14 @@ public partial class AgentLoopService
string? activeTab,
string userQuery)
{
// 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용
if (state.Scope == ExplorationScope.ProjectScaffold)
{
var layoutPreview = state.ScaffoldProfile == null
? "src, tests, config"
: ProjectScaffoldProfileCatalog.BuildDirectoryPreview(state.ScaffoldProfile);
return $"file_manage(mkdir)/file_write with a minimal project tree ({layoutPreview}) -> file_read/file_edit -> build_run/test_loop as needed";
}
if (state.Scope == ExplorationScope.DirectCreation)
{
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
@@ -233,31 +214,28 @@ public partial class AgentLoopService
if (string.IsNullOrWhiteSpace(userQuery))
return ExplorationScope.OpenEnded;
var q = userQuery.Trim();
var lower = q.ToLowerInvariant();
var lower = userQuery.Trim().ToLowerInvariant();
// Cowork 탭에서 문서 생성 의도가 있으면 탐색을 건너뛰고 바로 생성 도구 사용
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasDocumentCreationIntent(lower))
return ExplorationScope.DirectCreation;
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lower, activeTab))
return ExplorationScope.ProjectScaffold;
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCodeArtifactCreationIntent(lower))
return ExplorationScope.DirectCreation;
if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") ||
lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") ||
lower.Contains("아키텍처") || lower.Contains("전체 점검"))
if (ContainsAny(lower, "전체", "전반", "코드베이스 전체", "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 맥락"))
return ExplorationScope.RepoWide;
if (q.Contains('.') || q.Contains('/') || q.Contains('\\') ||
lower.Contains("file ") || lower.Contains("class ") || lower.Contains("method ") ||
lower.Contains("function ") || lower.Contains("line ") || lower.Contains("bug") ||
lower.Contains("오류") || lower.Contains("버그") || lower.Contains("예외"))
if (lower.Contains('.') || lower.Contains('/') || lower.Contains('\\') ||
ContainsAny(lower, "file ", "class ", "method ", "function ", "line ", "bug", "오류", "버그", "예외"))
return ExplorationScope.Localized;
if (lower.Contains("정리") || lower.Contains("요약") || lower.Contains("보고서") ||
lower.Contains("주제") || lower.Contains("관련") || lower.Contains("분석"))
if (ContainsAny(lower, "정리", "요약", "보고", "주제", "관련", "분석"))
return ExplorationScope.TopicBased;
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
@@ -265,38 +243,30 @@ public partial class AgentLoopService
: ExplorationScope.OpenEnded;
}
/// <summary>
/// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다.
/// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true.
/// </summary>
private static bool HasDocumentCreationIntent(string lowerQuery)
{
// 생성 동사 키워드
var hasCreationVerb = ContainsAny(lowerQuery,
"작성", "써줘", "써 줘", "만들어", "생성해", "작성 해",
"만들어줘", "만들어 줘", "생성해줘", "생성해 줘",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"리포트 써", "보고서 써", "문서 써",
"작성 부탁", "만들어 부탁");
var hasCreationVerb = ContainsAny(
lowerQuery,
"작성", "써줘", "만들", "생성", "만들어줘", "생성해줘",
"write", "create", "draft", "generate", "compose");
if (!hasCreationVerb)
return false;
// 생성 대상이 문서/보고서/자료 등인지 확인
return ContainsAny(lowerQuery,
"보고서", "문서", "제안서", "리포트", "분석서", "기획서",
"report", "document", "proposal", "analysis",
"요약서", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word",
"표", "차트", "스프레드시트", "프레젠테이션",
"정리해", "정리 해");
return ContainsAny(
lowerQuery,
"보고서", "문서", "제안서", "리포트", "분석서", "기획서", "요약", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word", "sheet", "chart",
"report", "document", "proposal", "analysis", "presentation", "template", "spreadsheet", "excel", "memo");
}
private static bool HasCodeArtifactCreationIntent(string lowerQuery)
{
if (ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lowerQuery, "Code"))
return true;
var hasCreationVerb = ContainsAny(
lowerQuery,
"만들", "생성", "작성", "create", "generate", "build", "write", "scaffold", "draft");
"만들", "생성", "작성", "구현", "create", "generate", "build", "write", "scaffold", "draft");
if (!hasCreationVerb)
return false;
@@ -310,35 +280,52 @@ public partial class AgentLoopService
"template", "템플릿", "index.html", "app.js", "style.css");
}
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationTrackingState state)
{
var scope = state.Scope;
var guidance = scope switch
{
ExplorationScope.ProjectScaffold =>
"Exploration scope = project-scaffold. The user wants a framework or multi-file project scaffold. " +
"Create a minimal project tree first with file_manage(mkdir) or nested file_write paths, then place source files into the proper folders. " +
"Do not flatten implementation files in the workspace root, and do not waste turns on folder_map/glob/grep unless the user explicitly asked to inspect the workspace.",
ExplorationScope.DirectCreation =>
"Exploration scope = direct-creation. The user wants to CREATE a new document/report/file. " +
"Do NOT search for existing files with glob/grep/folder_map — skip exploration entirely. " +
"Call document_plan first to outline the document structure, then immediately call the appropriate creation tool " +
"(docx_create, html_create, excel_create, markdown_create, etc.) to produce the actual file. " +
"The output MUST be a real file on disk, not a text response.",
"Exploration scope = direct-creation. The user wants to create a new document or file. " +
"Do not search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " +
"Produce a real file on disk instead of only describing the result.",
ExplorationScope.Localized =>
"Exploration scope = localized. Start with lsp_code_intel when the request is about definitions/references/implementations/call hierarchy; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asks for folder structure or file listing.",
"Exploration scope = localized. Start with lsp_code_intel for definitions, references, and implementations; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asked for folder structure.",
ExplorationScope.TopicBased =>
"Exploration scope = topic-based. Identify candidate files by topic keywords first with glob/grep, then read only a small targeted set.",
ExplorationScope.RepoWide =>
"Exploration scope = repo-wide. Broad structure inspection is allowed when needed.",
_ =>
"Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans."
"Exploration scope = open-ended. Expand gradually and prefer selective discovery before broad scans."
};
if (scope == ExplorationScope.DirectCreation)
if (scope == ExplorationScope.ProjectScaffold)
{
var layoutPreview = state.ScaffoldProfile == null
? "src, tests, config"
: ProjectScaffoldProfileCatalog.BuildDirectoryPreview(state.ScaffoldProfile);
var starterPreview = state.ScaffoldProfile == null
? "'src/main.ext', 'tests/main.spec.ext'"
: ProjectScaffoldProfileCatalog.BuildStarterPathPreview(state.ScaffoldProfile);
guidance =
"Exploration scope = project-scaffold. The user wants to create a structured project. " +
$"Establish a minimal folder layout such as {layoutPreview} before filling in implementation files. " +
$"Use relative scaffold paths like {starterPreview}. " +
"Prefer file_manage(mkdir) and nested file_write/file_edit calls. Do not dump every generated file into the workspace root.";
}
else if (scope == ExplorationScope.DirectCreation)
{
guidance =
"Exploration scope = direct-creation. The user wants to CREATE a new file or document. " +
"Do NOT search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " +
"Exploration scope = direct-creation. The user wants to create a new file or document. " +
"Do not search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " +
"If you are in the Code tab, call file_write immediately with a relative path inside the current work folder, then use file_edit/build_run/test_loop only if needed. " +
"If you are in Cowork and the user asked for a presentation, deck, slide pack, or PPT, call pptx_create directly unless the user explicitly asked for a plan or outline. " +
"Use document_plan first only when you are drafting a multi-section written document or when structure planning materially improves the result. " +
"The output MUST be a real file on disk, not a text response.";
"The output must be a real file on disk, not a text response.";
}
messages.Add(new ChatMessage
@@ -353,8 +340,8 @@ public partial class AgentLoopService
try
{
using var doc = JsonDocument.Parse(argsJson);
if (doc.RootElement.TryGetProperty("paths", out var pathsEl) && pathsEl.ValueKind == JsonValueKind.Array)
return pathsEl.GetArrayLength();
if (doc.RootElement.TryGetProperty("paths", out var pathsElement) && pathsElement.ValueKind == JsonValueKind.Array)
return pathsElement.GetArrayLength();
}
catch
{
@@ -368,13 +355,12 @@ public partial class AgentLoopService
string toolName,
string argsJson)
{
// 문서 생성 모드: 탐색 도구가 1회라도 호출되면 즉시 교정
if (state.Scope == ExplorationScope.DirectCreation)
if (state.Scope is ExplorationScope.DirectCreation or ExplorationScope.ProjectScaffold)
{
return string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase)
|| state.TotalFilesRead >= 1;
|| (state.Scope == ExplorationScope.ProjectScaffold && state.TotalFilesRead >= 3);
}
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
@@ -391,11 +377,11 @@ public partial class AgentLoopService
try
{
using var doc = JsonDocument.Parse(argsJson);
var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesEl) &&
includeFilesEl.ValueKind is JsonValueKind.True or JsonValueKind.False &&
includeFilesEl.GetBoolean();
var depth = doc.RootElement.TryGetProperty("depth", out var depthEl) && depthEl.ValueKind == JsonValueKind.Number
? depthEl.GetInt32()
var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesElement)
&& includeFilesElement.ValueKind is JsonValueKind.True or JsonValueKind.False
&& includeFilesElement.GetBoolean();
var depth = doc.RootElement.TryGetProperty("depth", out var depthElement) && depthElement.ValueKind == JsonValueKind.Number
? depthElement.GetInt32()
: 2;
if (includeFiles || depth >= 3)
return true;
@@ -429,8 +415,8 @@ public partial class AgentLoopService
return;
}
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) ||
string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase))
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase))
{
state.TotalFilesRead++;
}

View File

@@ -60,10 +60,12 @@ public partial class AgentLoopService
{
var intentGate = new IntentGateService(_llm);
var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false);
var scaffoldProfile = ProjectScaffoldProfileCatalog.Detect(userQuery, ActiveTab);
var explorationState = new ExplorationTrackingState
{
Scope = intentResult.SuggestedScope,
Scope = scaffoldProfile == null ? intentResult.SuggestedScope : ExplorationScope.ProjectScaffold,
SelectiveHit = true,
ScaffoldProfile = scaffoldProfile,
};
var pathAccessState = new PathAccessTrackingState();
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
@@ -79,7 +81,7 @@ public partial class AgentLoopService
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
InjectExplorationScopeGuidance(messages, explorationState);
if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint))
{

View File

@@ -298,6 +298,7 @@ public partial class AgentLoopService
var context = BuildContext();
context.InitialUserQuery = userQuery;
runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder);
var workspaceWasInitiallyEmpty = runState.WorkspaceAppearsEmpty;
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
@@ -309,6 +310,7 @@ public partial class AgentLoopService
"",
explorationState.Scope switch
{
ExplorationScope.ProjectScaffold => "프로젝트 스캐폴드 모드 · 최소 폴더 구조부터 만드는 중",
ExplorationScope.DirectCreation => string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)
? "즉시 생성 모드 · 바로 파일을 만드는 중"
: "문서 생성 모드 · 바로 문서를 만드는 중",
@@ -1042,6 +1044,9 @@ public partial class AgentLoopService
taskPolicy,
requireHighImpactCodeVerification,
totalToolCalls,
context,
explorationState,
workspaceWasInitiallyEmpty,
runState,
executionPolicy))
continue;

View File

@@ -1238,6 +1238,7 @@ public partial class AgentLoopService
public int ExecutionSuccessGateRetry;
public int HighImpactBuildTestGateRetry;
public int CodeVerificationGateRetry;
public int ProjectLayoutGateRetry;
public int FinalReportGateRetry;
public int TransientLlmErrorRetries;
public int DocumentArtifactGateRetry;

View File

@@ -1,4 +1,4 @@
using AxCopilot.Models;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
@@ -28,8 +28,8 @@ public partial class AgentLoopService
taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
? "고영향 코드 변경으로 분류돼 참조 검증과 build/test 검증을 더 엄격하게 이어갑니다."
: "코드 변경 후 build/test/diff 검증을 이어갑니다.");
? "怨좎쁺??肄붾뱶 蹂€寃쎌쑝濡?遺꾨쪟??李몄“ 寃€利앷낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??"
: "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??");
}
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
{
@@ -43,12 +43,24 @@ public partial class AgentLoopService
TaskTypePolicy taskPolicy,
bool requireHighImpactCodeVerification,
int totalToolCalls,
AgentContext context,
ExplorationTrackingState explorationState,
bool workspaceWasInitiallyEmpty,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0)
return false;
if (TryApplyProjectLayoutGateTransition(
messages,
textResponse,
context,
explorationState,
workspaceWasInitiallyEmpty,
runState))
return true;
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages,
requireHighImpactCodeVerification);
@@ -69,12 +81,12 @@ public partial class AgentLoopService
{
Role = "user",
Content = requireHighImpactCodeVerification
? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요."
: "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요."
? "[System:CodeQualityGate] 怨듭슜/?듭떖 肄붾뱶 蹂€寃??댄썑 寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 file_read, grep/glob, git diff, build/test源뚯? ?뺤씤???ㅼ뿉留?留덈Т由ы븯?몄슂."
: "[System:CodeQualityGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test/file_read/diff 洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 寃€利?洹쇨굅瑜?蹂닿컯???ㅼ뿉留?留덈Т由ы븯?몄슂."
});
EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification
? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..."
: "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다...");
? "?듭떖 肄붾뱶 蹂€寃쎌쓽 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.."
: "肄붾뱶 寃곌낵 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??..");
return true;
}
@@ -89,9 +101,9 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_runtest_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요."
Content = "[System:HighImpactBuildTestGate] ?듭떖 肄붾뱶 蹂€寃쎌엯?덈떎. 醫낅즺?섏? 留먭퀬 build_run怨?test_loop瑜?紐⑤몢 ?ㅽ뻾???깃났 洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
});
EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다...");
EmitEvent(AgentEventType.Thinking, "", "?듭떖 蹂€寃쎌씠??build+test ?깃났 洹쇨굅瑜?紐⑤몢 ?뺣낫???뚭퉴吏€ 吏꾪뻾?⑸땲??..");
return true;
}
@@ -115,13 +127,92 @@ public partial class AgentLoopService
Role = "user",
Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification)
});
EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다...");
EmitEvent(AgentEventType.Thinking, "", "理쒖쥌 蹂닿퀬??蹂€寃승룰?利씲룸━?ㅽ겕 ?붿빟??遺€議깊빐 ??踰????뺣━?⑸땲??..");
return true;
}
return false;
}
private bool TryApplyProjectLayoutGateTransition(
List<ChatMessage> messages,
string? textResponse,
AgentContext context,
ExplorationTrackingState explorationState,
bool workspaceWasInitiallyEmpty,
RunState runState)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
var scaffoldProfile = explorationState.ScaffoldProfile;
if (!workspaceWasInitiallyEmpty || scaffoldProfile == null)
return false;
if (runState.ProjectLayoutGateRetry >= 1)
return false;
if (!HasProjectScaffoldModificationEvidence(messages))
return false;
var assessment = ProjectScaffoldProfileCatalog.AssessLayout(scaffoldProfile, context.WorkFolder);
if (assessment.IsSatisfied)
return false;
runState.ProjectLayoutGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildProjectLayoutGatePrompt(scaffoldProfile, assessment)
});
EmitEvent(
AgentEventType.Thinking,
"",
$"?꾨줈?앺듃 援ъ“媛€ ?됰㈃?곸쑝濡??앹꽦???대뜑 ?덉씠?꾩썐??癒쇱? ?뺣━?⑸땲??({runState.ProjectLayoutGateRetry}/1)");
return true;
}
private static bool HasProjectScaffoldModificationEvidence(List<ChatMessage> messages)
{
foreach (var message in messages)
{
if (!TryGetToolResultToolName(message, out var toolName))
continue;
if (toolName is "file_write" or "file_edit" or "file_manage" or "script_create")
return true;
}
return false;
}
private static string BuildProjectLayoutGatePrompt(
ProjectScaffoldProfile profile,
ProjectScaffoldLayoutAssessment assessment)
{
var missingDirectories = assessment.MissingDirectories.Count == 0
? "none"
: string.Join(", ", assessment.MissingDirectories.Take(6));
var suspiciousRootFiles = assessment.SuspiciousRootFiles.Count == 0
? "none"
: string.Join(", ", assessment.SuspiciousRootFiles.Take(8));
var allowedRootFiles = profile.AllowedRootFiles.Count == 0
? "only manifest and entry files"
: string.Join(", ", profile.AllowedRootFiles);
return "[System:ProjectLayoutGate] This looks like a structured scaffold request for "
+ $"{profile.Label}, but the current workspace layout is still too flat. "
+ $"Create or complete folders such as {ProjectScaffoldProfileCatalog.BuildDirectoryPreview(profile)}, "
+ $"especially the missing ones: {missingDirectories}. "
+ $"Move implementation files that are still sitting in the workspace root into the proper folders: {suspiciousRootFiles}. "
+ $"Keep only appropriate root files such as {allowedRootFiles}. "
+ "Use file_manage(mkdir/move) plus file_edit/file_write to reorganize the scaffold before finishing, "
+ "then rerun build/test if relevant and only after that summarize the result.";
}
private bool TryApplyCodeDiffEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
@@ -147,9 +238,9 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. 지금 즉시 git_tool 도구를 호출하세요."
Content = "[System:CodeDiffGate] 肄붾뱶 蹂€寃??댄썑 diff 洹쇨굅媛€ 遺€議깊빀?덈떎. git_tool ?꾧뎄濡?蹂€寃??뚯씪怨??듭떖 diff瑜?癒쇱? ?뺤씤?섍퀬 ?붿빟?섏꽭?? 吏€湲?利됱떆 git_tool ?꾧뎄瑜??몄텧?섏꽭??"
});
EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다...");
EmitEvent(AgentEventType.Thinking, "", "肄붾뱶 diff 洹쇨굅媛€ 遺€議깊빐 git diff 寃€利앹쓣 異붽??⑸땲??..");
return true;
}
@@ -186,7 +277,7 @@ public partial class AgentLoopService
Role = "user",
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다...");
EmitEvent(AgentEventType.Thinking, "", "理쒓렐 ?섏젙 ?댄썑 ?ㅽ뻾 洹쇨굅媛€ 遺€議깊빐 build/test ?ш?利앹쓣 ?섑뻾?⑸땲??..");
return true;
}
@@ -223,7 +314,7 @@ public partial class AgentLoopService
Role = "user",
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다...");
EmitEvent(AgentEventType.Thinking, "", "?ㅽ뙣???ㅽ뻾 洹쇨굅留??덉뼱 build/test ?깃났 寃곌낵瑜??ㅼ떆 寃€利앺빀?덈떎...");
return true;
}
@@ -263,7 +354,7 @@ public partial class AgentLoopService
Role = "user",
Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath)
});
EmitEvent(AgentEventType.Thinking, "", $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})");
EmitEvent(AgentEventType.Thinking, "", $"醫낅즺 ???ㅽ뻾 利앷굅媛€ 遺€議깊빐 蹂닿컯 ?④퀎瑜?吏꾪뻾?⑸땲??({runState.TerminalEvidenceGateRetry}/{retryMax})");
return true;
}
}

View File

@@ -1,126 +1,117 @@
using AxCopilot.Services;
using static AxCopilot.Services.Agent.AgentLoopService;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기.
/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합)
/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑
/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동
/// </summary>
internal sealed class IntentGateService
{
private readonly ILlmService? _llm;
/// <summary>DetectComplexTask에서 매번 재생성 방지용 정적 배열.</summary>
private static readonly string[] Conjunctions =
{
"그리고", "하고", "다음에", "이후에", "그런 다음",
" and then ", " after that ", " also ", " additionally "
};
[
"그리고",
"하고",
"다음",
"이후",
"그런 다음",
" and then ",
" after that ",
" also ",
" additionally "
];
private static readonly string[] ActionVerbs =
{
"해줘", "해 줘", "만들어", "수정해", "분석해", "작성해",
"검토해", "확인해", "추가해", "삭제해", "변경해"
};
[
"해줘",
"해 줘",
"만들",
"수정",
"분석",
"작성",
"검토",
"확인",
"추가",
"삭제",
"변경",
"fix",
"review",
"analyze",
"write",
"create",
"implement",
"add",
"remove",
"change"
];
/// <summary>입력 길이 제한 — 50KB 이상은 잘라서 처리.</summary>
private const int MaxInputLength = 50_000;
public IntentGateService(ILlmService? llm = null) => _llm = llm;
/// <summary>
/// 사용자 쿼리를 분석하여 IntentResult를 생성합니다.
/// </summary>
public Task<IntentResult> ClassifyAsync(
string userQuery, string? activeTab, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// 안전 가드: null/과도한 길이
var safeQuery = userQuery ?? "";
var safeQuery = userQuery ?? string.Empty;
if (safeQuery.Length > MaxInputLength)
safeQuery = safeQuery[..MaxInputLength];
// 한 번만 lowercase 변환 후 하위 메서드에 전달
var lowerQuery = safeQuery.ToLowerInvariant();
// ── Stage 1: 키워드 분류 ──
var taskType = ClassifyTaskTypeKeyword(safeQuery, activeTab);
var (intentCategory, intentConfidence) = IntentDetector.Detect(safeQuery);
// 종합 confidence: taskType 확정도 + IntentDetector 확신도 가중 평균
var taskTypeConfidence = ComputeTaskTypeConfidence(taskType, lowerQuery);
var combinedConfidence = Math.Min(1.0,
taskTypeConfidence * 0.6 + intentConfidence * 0.4);
// ── Stage 2: 프로파일 매핑 ──
var combinedConfidence = Math.Min(1.0, taskTypeConfidence * 0.6 + intentConfidence * 0.4);
var overlay = MapToOverlay(taskType, intentCategory, activeTab);
var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory);
// ── 복합 요청 감지 (P5 연동) ──
var (isComplex, hint) = DetectComplexTask(lowerQuery);
var result = new IntentResult(
return Task.FromResult(new IntentResult(
TaskType: taskType,
IntentCategory: intentCategory,
Confidence: Math.Round(combinedConfidence, 2, MidpointRounding.AwayFromZero),
PolicyOverlay: overlay,
SuggestedScope: scope,
IsComplexTask: isComplex,
DecompositionHint: hint
);
return Task.FromResult(result);
DecompositionHint: hint));
}
// ════════════════════════════════════════════════════════════
// Stage 1: 키워드 기반 작업 유형 분류
// ════════════════════════════════════════════════════════════
/// <summary>
/// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직.
/// </summary>
internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab)
{
var q = userQuery ?? "";
var query = userQuery ?? string.Empty;
if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검"))
if (ContainsAny(query, "review", "code review", "리뷰", "검토"))
return "review";
if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패"))
if (ContainsAny(query, "bug", "fix", "error", "failure", "broken", "버그", "오류", "수정", "고쳐", "실패"))
return "bugfix";
if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선"))
if (ContainsAny(query, "refactor", "cleanup", "rename", "reorganize", "리팩토링", "리팩터링", "정리", "개편", "구조 개선"))
return "refactor";
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서"))
&& ContainsAny(query, "report", "document", "proposal", "analysis", "보고서", "문서", "제안서", "기획서"))
return "docs";
if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능"))
if (ContainsAny(query, "feature", "implement", "add", "support", "추가", "구현", "기능"))
return "feature";
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general";
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? "feature"
: "general";
}
/// <summary>
/// taskType 키워드 매칭 강도로 confidence를 산출합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static double ComputeTaskTypeConfidence(string taskType, string lowerQuery)
{
// "general"은 폴백이므로 confidence 낮음
if (string.Equals(taskType, "general", StringComparison.Ordinal)) return 0.3;
if (string.Equals(taskType, "general", StringComparison.Ordinal))
return 0.3;
// 직접 매칭 키워드 수 세기
var hitCount = taskType switch
{
"review" => CountHits(lowerQuery, "review", "리뷰", "검토", "code review", "점검"),
"bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "오류", "버그", "수정", "고쳐", "실패"),
"refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩터링", "정리", "개편"),
"docs" => CountHits(lowerQuery, "report", "document", "보고서", "문서", "제안서"),
"review" => CountHits(lowerQuery, "review", "code review", "리뷰", "검토"),
"bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "버그", "오류", "수정", "고쳐"),
"refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩토링", "리팩터링", "정리", "개편"),
"docs" => CountHits(lowerQuery, "report", "document", "proposal", "보고서", "문서", "제안서"),
"feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"),
_ => 0,
};
@@ -137,27 +128,20 @@ internal sealed class IntentGateService
private static int CountHits(string lower, params string[] keywords)
{
var count = 0;
foreach (var kw in keywords)
foreach (var keyword in keywords)
{
if (lower.Contains(kw, StringComparison.OrdinalIgnoreCase))
if (lower.Contains(keyword, StringComparison.OrdinalIgnoreCase))
count++;
}
return count;
}
// ════════════════════════════════════════════════════════════
// Stage 2: 프로파일 매핑
// ════════════════════════════════════════════════════════════
/// <summary>
/// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다.
/// </summary>
private static ExecutionPolicyOverlay? MapToOverlay(
string taskType, string intentCategory, string? activeTab)
{
return (taskType, intentCategory) switch
{
// 코드 수정 관련
("bugfix", "coding" or "general") => new(
ToolTemperatureCap: 0.2,
ForceInitialToolCall: true,
@@ -178,7 +162,6 @@ internal sealed class IntentGateService
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
// 문서 생성
("docs", "document" or "creative" or "general") => new(
EnableDocumentVerificationGate: true,
ReduceEarlyMemoryPressure: true),
@@ -186,7 +169,6 @@ internal sealed class IntentGateService
("docs", _) => new(
EnableDocumentVerificationGate: true),
// 리뷰/분석
("review", "analysis" or "coding" or "general") => new(
ToolTemperatureCap: 0.3,
EnableCodeQualityGates: true,
@@ -196,131 +178,110 @@ internal sealed class IntentGateService
ToolTemperatureCap: 0.3,
ForceInitialToolCall: true),
// general + 순수 대화 (Chat 탭)
("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
=> null, // Chat 탭은 도구 없음, overlay 불필요
=> null,
// general + 문서 의도
("general", "document") => new(
EnableDocumentVerificationGate: true),
// general + 분석 의도
("general", "analysis") => new(
ToolTemperatureCap: 0.35,
MaxParallelReadBatch: 8),
// 기타: base policy 그대로
_ => null,
};
}
// ════════════════════════════════════════════════════════════
// 탐색 범위 결정
// ════════════════════════════════════════════════════════════
/// <summary>
/// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static ExplorationScope ClassifyScopeFromIntent(
string lowerQuery, string? activeTab, string taskType, string intentCategory)
{
if (string.IsNullOrWhiteSpace(lowerQuery))
return ExplorationScope.OpenEnded;
// docs 타입이면서 생성 동사가 있으면 DirectCreation
if (taskType == "docs" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
if (HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
}
// document 인텐트 + 생성 동사 → DirectCreation
if (intentCategory == "document"
&& !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
// RepoWide
if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체",
"repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 점검"))
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& ProjectScaffoldProfileCatalog.IsStructuredProjectRequest(lowerQuery, activeTab))
return ExplorationScope.ProjectScaffold;
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCreationVerb(lowerQuery)
&& ContainsAny(
lowerQuery,
"html", "css", "javascript", "js", "typescript", "ts",
"web page", "webpage", "website", "landing page",
"file", "script", "component", "template",
"index.html", "app.js", "style.css",
"파일", "스크립트", "컴포넌트", "템플릿"))
return ExplorationScope.DirectCreation;
if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체", "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 맥락"))
return ExplorationScope.RepoWide;
// Localized
if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') ||
ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ",
"bug", "오류", "버그", "예외"))
ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ", "bug", "오류", "버그", "예외"))
return ExplorationScope.Localized;
// TopicBased
if (ContainsAny(lowerQuery, "정리", "요약", "보고서", "주제", "관련", "분석"))
if (ContainsAny(lowerQuery, "정리", "요약", "보고", "주제", "관련", "분석"))
return ExplorationScope.TopicBased;
// 탭 기반 기본값
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? ExplorationScope.Localized
: ExplorationScope.OpenEnded;
}
private static bool HasCreationVerb(string lower)
=> ContainsAny(lower,
"작성해", "써줘", "써 줘", "만들어", "생성해",
"만들어줘", "만들어", "생성해줘", "생성해",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"작성 부탁", "만들어 부탁");
=> ContainsAny(
lower,
"작성", "작성해", "써줘", "만들", "생성", "만들어줘", "만들어", "생성해줘", "생성해",
"write", "create", "draft", "generate", "compose", "scaffold", "bootstrap");
// ════════════════════════════════════════════════════════════
// 복합 요청 감지 (P5 연동)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 복합 요청을 감지합니다. <paramref name="lowerQuery"/>는 이미 lowercase 변환된 문자열입니다.
/// </summary>
private static (bool IsComplex, string? Hint) DetectComplexTask(string lowerQuery)
{
if (lowerQuery.Length < 20)
return (false, null);
// 접속사/열거 패턴 감지 (클래스 수준 static readonly 배열 사용)
var conjunctionCount = 0;
foreach (var conj in Conjunctions)
foreach (var conjunction in Conjunctions)
{
if (lowerQuery.Contains(conj, StringComparison.Ordinal))
if (lowerQuery.Contains(conjunction, StringComparison.Ordinal))
conjunctionCount++;
}
// 동사 열거 패턴 (클래스 수준 static readonly 배열 사용)
var verbCount = 0;
foreach (var verb in ActionVerbs)
{
var idx = 0;
while ((idx = lowerQuery.IndexOf(verb, idx, StringComparison.Ordinal)) >= 0)
var index = 0;
while ((index = lowerQuery.IndexOf(verb, index, StringComparison.Ordinal)) >= 0)
{
verbCount++;
idx += verb.Length;
index += verb.Length;
}
}
if (conjunctionCount >= 2 || verbCount >= 3)
{
return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요.");
}
return (true, "이 요청은 여러 단계의 작업을 포함합니다. 병렬화 가능한 하위 작업을 분리해 처리하는 것이 좋습니다.");
return (false, null);
}
// ════════════════════════════════════════════════════════════
// 공통 유틸
// ════════════════════════════════════════════════════════════
private static bool ContainsAny(string text, params string[] keywords)
{
foreach (var kw in keywords)
foreach (var keyword in keywords)
{
if (text.Contains(kw, StringComparison.OrdinalIgnoreCase))
if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,583 @@
using System.IO;
namespace AxCopilot.Services.Agent;
internal sealed record ProjectScaffoldProfile(
string Key,
string Label,
IReadOnlyList<string> TriggerTokens,
IReadOnlyList<string> ExpectedDirectories,
IReadOnlyList<string> StarterPaths,
IReadOnlyList<string> AllowedRootFiles,
int MinimumDirectoryHits = 2);
internal sealed record ProjectScaffoldLayoutAssessment(
bool IsSatisfied,
int MatchedDirectoryCount,
IReadOnlyList<string> ExistingDirectories,
IReadOnlyList<string> MissingDirectories,
IReadOnlyList<string> SuspiciousRootFiles);
internal static class ProjectScaffoldProfileCatalog
{
private static readonly string[] s_creationVerbs =
[
"create",
"generate",
"build",
"write",
"scaffold",
"bootstrap",
"set up",
"setup",
"make",
"implement",
"start",
"만들",
"생성",
"작성",
"구현",
"구축",
"세팅",
"스캐폴드"
];
private static readonly string[] s_projectNouns =
[
"project",
"app",
"application",
"service",
"api",
"solution",
"starter",
"template",
"boilerplate",
"workspace",
"repo",
"repository",
"library",
"package",
"module",
"프로젝트",
"앱",
"애플리케이션",
"서비스",
"솔루션",
"템플릿",
"보일러플레이트",
"워크스페이스",
"저장소",
"라이브러리",
"패키지",
"모듈"
];
private static readonly string[] s_languageTokens =
[
"c#",
"dotnet",
".net",
"csproj",
"wpf",
"xaml",
"mvvm",
"viewmodel",
"resourcedictionary",
"resource dictionary",
"javascript",
"typescript",
"react",
"next",
"vue",
"nuxt",
"node",
"express",
"python",
"fastapi",
"flask",
"django",
"java",
"spring",
"spring boot",
"kotlin",
"android",
"go",
"golang",
"rust",
"cli",
"web",
"desktop",
"mobile",
"frontend",
"backend",
"api",
"웹",
"데스크톱",
"모바일",
"프론트엔드",
"백엔드",
"파이썬",
"자바",
"코틀린",
"안드로이드",
"리액트",
"뷰",
"고",
"러스트",
"스프링",
"장고",
"플라스크",
"패스트api"
];
private static readonly string[] s_solutionShapeTokens =
[
"frontend",
"backend",
"desktop",
"mobile",
"cli",
"tool",
"worker",
"daemon",
"game",
"프론트엔드",
"백엔드",
"데스크톱",
"모바일",
"도구",
"워커",
"게임"
];
private static readonly HashSet<string> s_commonAllowedRootFiles = new(StringComparer.OrdinalIgnoreCase)
{
"README.md",
".gitignore",
".editorconfig",
".gitattributes",
".env",
".env.example",
"package.json",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"tsconfig.json",
"vite.config.ts",
"vite.config.js",
"next.config.js",
"next.config.mjs",
"nuxt.config.ts",
"requirements.txt",
"pyproject.toml",
"poetry.lock",
"Pipfile",
"Pipfile.lock",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"Cargo.toml",
"go.mod",
"go.sum",
"composer.json",
"Gemfile",
"index.html"
};
private static readonly string[] s_relevantRootExtensions =
[
".cs",
".xaml",
".razor",
".ts",
".tsx",
".js",
".jsx",
".css",
".scss",
".py",
".java",
".kt",
".go",
".rs",
".php",
".rb",
".json",
".xml",
".yaml",
".yml"
];
private static readonly IReadOnlyList<ProjectScaffoldProfile> s_profiles =
[
new(
Key: "wpf-mvvm",
Label: "WPF / MVVM desktop app",
TriggerTokens:
[
"wpf",
"xaml",
"mvvm",
"viewmodel",
"resourcedictionary",
"resource dictionary",
"csproj",
"desktop app",
"window",
"usercontrol",
"user control"
],
ExpectedDirectories: ["Views", "ViewModels", "Models", "Services", "Themes", "Assets"],
StarterPaths:
[
"App.xaml",
"App.xaml.cs",
"Views/MainWindow.xaml",
"Views/MainWindow.xaml.cs",
"ViewModels/MainWindowViewModel.cs",
"Themes/Colors.xaml"
],
AllowedRootFiles: ["App.xaml", "App.xaml.cs"],
MinimumDirectoryHits: 2),
new(
Key: "aspnet-api",
Label: "ASP.NET / Web API service",
TriggerTokens:
[
"asp.net",
"aspnet",
"web api",
"webapi",
"minimal api",
"mvc",
"blazor",
"razor",
"controller",
"swagger"
],
ExpectedDirectories: ["Controllers", "Services", "Models", "Contracts", "Data", "Properties"],
StarterPaths:
[
"Program.cs",
"Controllers/HealthController.cs",
"Services/HealthService.cs",
"Models/HealthStatus.cs",
"appsettings.json"
],
AllowedRootFiles: ["Program.cs", "appsettings.json", "appsettings.Development.json"],
MinimumDirectoryHits: 2),
new(
Key: "react-web",
Label: "React / Next / Vue frontend",
TriggerTokens:
[
"react",
"next",
"next.js",
"nextjs",
"vue",
"nuxt",
"svelte",
"vite",
"frontend",
"front-end",
"spa",
"component"
],
ExpectedDirectories: ["src/components", "src/pages", "src/services", "src/styles", "public"],
StarterPaths:
[
"src/App.tsx",
"src/main.tsx",
"src/components/AppShell.tsx",
"src/pages/HomePage.tsx",
"src/services/api.ts"
],
AllowedRootFiles: ["index.html"],
MinimumDirectoryHits: 2),
new(
Key: "node-service",
Label: "Node / Express / Nest backend",
TriggerTokens:
[
"node",
"express",
"nestjs",
"nest",
"koa",
"backend",
"back-end",
"rest api",
"server app",
"middleware"
],
ExpectedDirectories: ["src/controllers", "src/routes", "src/services", "src/models", "tests"],
StarterPaths:
[
"src/index.ts",
"src/controllers/HealthController.ts",
"src/routes/index.ts",
"src/services/HealthService.ts",
"tests/health.spec.ts"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "python-service",
Label: "Python API service",
TriggerTokens:
[
"python",
"fastapi",
"flask",
"django",
"uvicorn",
"backend api",
"python api"
],
ExpectedDirectories: ["app/api", "app/models", "app/services", "app/core", "tests"],
StarterPaths:
[
"app/main.py",
"app/api/routes.py",
"app/services/health_service.py",
"app/models/health.py",
"tests/test_health.py"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "spring-service",
Label: "Java / Spring Boot service",
TriggerTokens:
[
"spring",
"spring boot",
"java api",
"maven",
"gradle",
"jpa",
"controller",
"repository"
],
ExpectedDirectories: ["src/main/java", "src/main/resources", "src/test/java"],
StarterPaths:
[
"src/main/java/com/example/Application.java",
"src/main/java/com/example/controller/HealthController.java",
"src/main/java/com/example/service/HealthService.java",
"src/main/resources/application.yml",
"src/test/java/com/example/ApplicationTests.java"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "android-kotlin",
Label: "Android / Kotlin app",
TriggerTokens:
[
"android",
"kotlin",
"jetpack compose",
"compose",
"xml layout",
"activity",
"fragment"
],
ExpectedDirectories: ["app/src/main/java", "app/src/main/res/layout", "app/src/main/res/values", "app/src/test"],
StarterPaths:
[
"app/src/main/java/com/example/MainActivity.kt",
"app/src/main/res/layout/activity_main.xml",
"app/src/main/res/values/colors.xml",
"app/src/test/java/com/example/MainActivityTest.kt"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "go-service",
Label: "Go service",
TriggerTokens:
[
"golang",
"go api",
"go service",
"gin",
"echo",
"fiber",
"handler"
],
ExpectedDirectories: ["cmd", "internal", "pkg", "api", "tests"],
StarterPaths:
[
"cmd/server/main.go",
"internal/handler/health.go",
"internal/service/health.go",
"pkg/config/config.go",
"tests/health_test.go"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "rust-cli",
Label: "Rust CLI / tool",
TriggerTokens:
[
"rust",
"cargo",
"cli",
"command line",
"terminal tool",
"binary",
"subcommand"
],
ExpectedDirectories: ["src/bin", "src/commands", "src/domain", "tests"],
StarterPaths:
[
"src/main.rs",
"src/commands/root.rs",
"src/domain/config.rs",
"tests/cli_tests.rs"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2),
new(
Key: "generic-solution",
Label: "Generic structured solution",
TriggerTokens:
[
"project",
"solution",
"starter",
"template",
"boilerplate",
"library",
"module",
"workspace"
],
ExpectedDirectories: ["src", "tests", "docs", "config"],
StarterPaths:
[
"src/main.ext",
"src/services/service.ext",
"tests/main.spec.ext",
"docs/README.md"
],
AllowedRootFiles: [],
MinimumDirectoryHits: 2)
];
internal static ProjectScaffoldProfile? Detect(string? userQuery, string? activeTab)
{
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
return null;
if (string.IsNullOrWhiteSpace(userQuery))
return null;
var query = userQuery.ToLowerInvariant();
if (!ContainsAny(query, s_creationVerbs))
return null;
ProjectScaffoldProfile? bestProfile = null;
var bestScore = 0;
foreach (var profile in s_profiles)
{
var score = profile.TriggerTokens.Count(token => query.Contains(token, StringComparison.OrdinalIgnoreCase));
if (score > bestScore)
{
bestScore = score;
bestProfile = profile;
}
}
if (bestProfile != null && bestScore > 0)
return bestProfile;
if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_languageTokens))
return GetProfile("generic-solution");
if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_solutionShapeTokens))
return GetProfile("generic-solution");
return null;
}
internal static bool IsStructuredProjectRequest(string? userQuery, string? activeTab)
=> Detect(userQuery, activeTab) != null;
internal static string BuildDirectoryPreview(ProjectScaffoldProfile profile, int maxCount = 5)
=> string.Join(", ", profile.ExpectedDirectories.Take(Math.Max(1, maxCount)));
internal static string BuildStarterPathPreview(ProjectScaffoldProfile profile, int maxCount = 5)
=> string.Join(", ", profile.StarterPaths.Take(Math.Max(1, maxCount)).Select(path => $"'{path}'"));
internal static ProjectScaffoldLayoutAssessment AssessLayout(ProjectScaffoldProfile profile, string? workFolder)
{
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
{
return new ProjectScaffoldLayoutAssessment(
IsSatisfied: false,
MatchedDirectoryCount: 0,
ExistingDirectories: [],
MissingDirectories: profile.ExpectedDirectories.ToList(),
SuspiciousRootFiles: []);
}
var existingDirectories = profile.ExpectedDirectories
.Where(dir => Directory.Exists(Path.Combine(workFolder, NormalizeDirectory(dir))))
.ToList();
var missingDirectories = profile.ExpectedDirectories
.Where(dir => !existingDirectories.Contains(dir, StringComparer.OrdinalIgnoreCase))
.ToList();
var allowedRootFiles = new HashSet<string>(s_commonAllowedRootFiles, StringComparer.OrdinalIgnoreCase);
foreach (var file in profile.AllowedRootFiles)
allowedRootFiles.Add(file);
var suspiciousRootFiles = Directory.EnumerateFiles(workFolder)
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name!)
.Where(name =>
{
if (allowedRootFiles.Contains(name))
return false;
var extension = Path.GetExtension(name);
return s_relevantRootExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
})
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
var isSatisfied = existingDirectories.Count >= profile.MinimumDirectoryHits
&& suspiciousRootFiles.Count == 0;
return new ProjectScaffoldLayoutAssessment(
IsSatisfied: isSatisfied,
MatchedDirectoryCount: existingDirectories.Count,
ExistingDirectories: existingDirectories,
MissingDirectories: missingDirectories,
SuspiciousRootFiles: suspiciousRootFiles);
}
private static ProjectScaffoldProfile GetProfile(string key)
=> s_profiles.First(profile => string.Equals(profile.Key, key, StringComparison.OrdinalIgnoreCase));
private static string NormalizeDirectory(string value)
=> value.Replace('/', Path.DirectorySeparatorChar);
private static bool ContainsAny(string text, IEnumerable<string> tokens)
{
foreach (var token in tokens)
{
if (text.Contains(token, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -31,7 +31,9 @@ public partial class ChatWindow
sb.AppendLine("A text-only response is fine once the requested artifact already exists, the requested analysis is complete, or enough evidence has been gathered.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete.");
sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder.");
sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and move directly to artifact creation.");
sb.AppendLine("For single-file outputs, call file_write directly with a relative path inside the current work folder.");
sb.AppendLine("For framework or multi-file scaffolds such as WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, or Rust, first establish a minimal project tree and place files inside framework-appropriate folders instead of flattening everything in the workspace root.");
sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
@@ -212,7 +214,9 @@ public partial class ChatWindow
sb.AppendLine("A text-only response is fine once the requested code work is complete or enough evidence has been gathered.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete.");
sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder.");
sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and move directly to artifact creation.");
sb.AppendLine("For a single-file artifact, call file_write directly with a relative path inside the current work folder.");
sb.AppendLine("For framework or multi-file scaffolds such as WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, or Rust, create a minimal project tree first and place files inside framework-appropriate folders instead of dumping all source files in the workspace root.");
sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
@@ -251,7 +255,7 @@ public partial class ChatWindow
sb.AppendLine("---");
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), lsp_code_intel, folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), file_manage, glob, grep (supports context_lines, case_sensitive), lsp_code_intel, folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("Do NOT call open_external, launch browsers, or start preview/server commands unless the user explicitly asks to open, preview, serve, launch, or run the result.");
@@ -268,6 +272,7 @@ public partial class ChatWindow
sb.AppendLine("4. VERIFY: Run build_run/test_loop when the change affects buildable or testable behavior, or when the user explicitly asks for verification.");
sb.AppendLine(" - Use git_tool(diff) when it helps confirm the final change set or explain what changed.");
sb.AppendLine(" - After editing code, do not stop until you have enough evidence from file_read, diff, build_run, or test_loop.");
sb.AppendLine(" - If the task is a new framework/project scaffold, verify the layout too: keep views/components/routes/services/models/resources under appropriate folders instead of leaving implementation files flat in the root.");
sb.AppendLine("5. REPORT: Summarize what changed, which files/callers were affected, and what verification evidence was collected.");
sb.AppendLine(" - For bugfix/feature/refactor tasks, keep the final report structured and concrete rather than minimal.");

View File

@@ -2,28 +2,34 @@
name: code-scaffold
label: 코드 스캐폴딩
description: 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 자동 생성합니다.
when_to_use: 빈 작업 폴더에서 새 프로젝트를 만들거나, WPF/MVVM, ASP.NET, React/Vue/Next, FastAPI/Django/Flask, Spring, Android, Go, Rust 같은 구조형 스캐폴드를 폴더 트리부터 잡아야 할 때
icon: \uE943
allowed-tools:
- file_manage
- folder_map
- file_read
- grep
- file_write
- file_edit
- search_codebase
tabs: code
---
작업 폴더의 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 생성하세요.
작업 폴더의 프로젝트 구조를 분석하고 새 기능이나 새 프로젝트의 코드 뼈대를 생성하세요.
다음 도구를 사용하세요:
1. folder_map — 프로젝트 구조 파악
2. file_read — 기존 코드 패턴 분석
3. grep — 코딩 컨벤션 확인
4. file_write — 새 파일 생성
1. file_manage — 폴더 생성, 이동, 정리
2. folder_map — 기존 프로젝트 구조 파악
3. file_read — 기존 코드 패턴 분석
4. grep — 코딩 컨벤션 확인
5. file_write / file_edit — 새 파일 생성 및 보강
작업 순서:
1. 프로젝트 타입 감지 (언어, 프레임워크, 빌드 시스템)
2. 기존 코드 패턴 분석 (네이밍, 폴더 구조, 임포트 스타일)
3. 사용자 요청에 맞는 코드 뼈대 생성
2. 빈 작업 폴더라면 최소 프로젝트 트리를 먼저 설계하고 file_manage로 폴더를 만든 뒤 파일을 배치
3. 기존 프로젝트가 있다면 네이밍, 폴더 구조, 임포트 스타일을 먼저 맞춤
4. 사용자 요청에 맞는 코드 뼈대 생성
5. 구조형 프로젝트는 뷰/UI, 모델, 서비스, 리소스, 테스트를 가능한 한 폴더로 분리
생성 항목:
- 클래스/모듈 파일 (프로젝트 컨벤션에 맞춰)
@@ -33,5 +39,8 @@ tabs: code
규칙:
- 기존 프로젝트의 코딩 스타일을 따르세요
- WPF, ASP.NET, React, Python API, Spring, Android, Go, Rust 같은 구조형 프로젝트는 구현 파일을 루트에 평평하게 두지 마세요
- 루트에는 manifest, entrypoint, README 같은 최소 파일만 두고 나머지는 적절한 폴더로 분리하세요
- 새 프로젝트를 시작할 때는 단일 파일 생성보다 폴더 트리와 대표 파일 경로를 먼저 확정하세요
- TODO 주석으로 구현이 필요한 부분을 표시하세요
- 한국어 주석을 추가하세요