???? ?? ?? ?? ??? ?? fallback ???? ?? ??

- CodeLanguageCatalog? UTF-8 ???? ????? ?? fallback ???? ??? ??? manifest/build/test/lint ?? ?? ??? ????
- WorkspaceContextGenerator? ?? ??? ????? ?? Language Workflow ??? ?????? ??? no-LSP ?????? ?? ??? ?? ??? ?? ???
- AgentLoopLlmRequestPreparationService? ??? ?? tool-call ??? pre-call reminder ?? ??? AgentLoopService?? ???
- CodeLanguageCatalogTests, WorkspaceContextGeneratorTests, AgentLoopLlmRequestPreparationServiceTests? ??? ?? fallback/????/LLM ?? ?? ??? ???
- ??: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_final_batch\\ -p:IntermediateOutputPath=obj\\verify_final_batch\\ (?? 0 / ?? 0)
- ??: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|ChatStorageServiceTests|HtmlSkillGoldenReportTests|PptxSkillGoldenDeckTests|DocxSkillGoldenDocumentTests|ExcelSkillGoldenWorkbookTests" -p:OutputPath=bin\\verify_final_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_final_batch_tests\\ (?? 54)
This commit is contained in:
2026-04-15 10:51:44 +09:00
parent 91c4dc74c3
commit 48e8c57cf3
11 changed files with 456 additions and 235 deletions

View File

@@ -0,0 +1,52 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopLlmRequestPreparationResult(
List<ChatMessage> SendMessages,
bool ForceInitialToolCall,
bool InjectedToolReminder);
/// <summary>
/// query view가 만들어진 뒤 실제 LLM 요청 배열을 조립합니다.
/// 초기 tool call 강제 여부와 사전 reminder 주입을 한곳에서 결정해
/// AgentLoopService 본체가 orchestration에 더 집중하도록 분리합니다.
/// </summary>
internal static class AgentLoopLlmRequestPreparationService
{
public static AgentLoopLlmRequestPreparationResult Prepare(
IReadOnlyList<ChatMessage> queryMessages,
int totalToolCalls,
bool forceInitialToolCallEnabled,
bool injectPreCallToolReminder,
int noToolCallLoopRetry)
{
var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled;
if (!forceInitialToolCall
|| !injectPreCallToolReminder
|| noToolCallLoopRetry > 0)
{
return new AgentLoopLlmRequestPreparationResult(
queryMessages.ToList(),
forceInitialToolCall,
false);
}
var sendMessages = queryMessages.ToList();
sendMessages.Add(BuildToolReminderMessage());
return new AgentLoopLlmRequestPreparationResult(
sendMessages,
forceInitialToolCall,
true);
}
internal static ChatMessage BuildToolReminderMessage()
{
return new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
};
}
}

View File

@@ -87,22 +87,6 @@ public partial class AgentLoopService
requestInterrupt && IsRunning);
}
private void DrainPendingCommands(List<ChatMessage> messages)
{
var queuedSnapshot = _pendingCommands.Snapshot();
if (queuedSnapshot.Count == 0)
return;
var drained = _pendingCommands.DequeuePriorityBatch();
if (drained.Count == 0)
return;
var projection = AgentQueuedCommandProjector.Project(drained, queuedSnapshot.Count - drained.Count);
messages.AddRange(projection.Messages);
foreach (var evt in projection.Events)
EmitEvent(evt.Type, evt.ToolName, evt.Summary);
}
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
public ObservableCollection<AgentEvent> Events { get; } = new();
@@ -557,25 +541,14 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
return "⚠ 현재 스킬 정책에서 허용된 도구가 없어 작업을 진행할 수 없습니다. allowed-tools 설정을 확인하세요.";
}
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
// IBM/Qwen 등 chatty 모델 대응: 첫 번째 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder 주입.
// recovery 메시지가 이미 추가된 경우(NoToolCallLoopRetry > 0)에는 중복 주입하지 않음.
// 임시 메시지이므로 실제 messages 목록은 수정하지 않고, 별도 sendMessages로 전달.
List<ChatMessage> sendMessages = queryMessages;
if (forceFirst
&& executionPolicy.InjectPreCallToolReminder
&& runState.NoToolCallLoopRetry == 0)
{
sendMessages = [.. queryMessages, new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
}];
}
var llmRequest = AgentLoopLlmRequestPreparationService.Prepare(
queryMessages,
totalToolCalls,
executionPolicy.ForceInitialToolCall,
executionPolicy.InjectPreCallToolReminder,
runState.NoToolCallLoopRetry);
var forceFirst = llmRequest.ForceInitialToolCall;
var sendMessages = llmRequest.SendMessages;
// 워크플로우 상세 로그: LLM 요청
llmCallSw.Restart();

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
@@ -174,6 +175,21 @@ internal static class WorkspaceContextGenerator
catch { return null; }
}
internal static IReadOnlyList<string> DetectLanguageWorkflowHints(
string? workFolder,
string? preferredLanguage = null,
int maxLanguages = 3)
{
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
return [];
var extDist = GetExtensionDistribution(workFolder, CancellationToken.None);
return CodeLanguageCatalog.BuildWorkspaceWorkflowSummaries(
extDist.Select(x => x.Key),
preferredLanguage,
maxLanguages);
}
// ════════════════════════════════════════════════════════════
// 분석 로직
// ════════════════════════════════════════════════════════════
@@ -450,26 +466,7 @@ internal static class WorkspaceContextGenerator
.ToList();
private static List<string> BuildLanguageWorkflow(List<KeyValuePair<string, int>> extDist)
{
var workflow = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (extension, _) in extDist)
{
var capability = Services.CodeLanguageCatalog.FindByExtension(extension);
if (capability == null || !seen.Add(capability.Key))
continue;
var summary = Services.CodeLanguageCatalog.BuildWorkflowSummary(capability.Key);
if (!string.IsNullOrWhiteSpace(summary))
workflow.Add(summary);
if (workflow.Count >= 3)
break;
}
return workflow;
}
=> CodeLanguageCatalog.BuildWorkspaceWorkflowSummaries(extDist.Select(x => x.Key)).ToList();
private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync(
string folder, CancellationToken ct)

View File

@@ -20,12 +20,7 @@ public sealed record CodeLanguageCapability(
/// <summary>
/// 코드 탭과 에이전트가 공통으로 참조하는 언어 지원 카탈로그.
/// - 파일 분류
/// - 인덱싱 대상 확장자
/// - 시스템 프롬프트 언어 가이드
/// - LSP 연동 가능 언어
/// - 설정 UI 설명 문자열
/// 를 한 곳에서 관리합니다.
/// 파일 분류, 시스템 프롬프트 가이드, build/test/lint 힌트, LSP 가능 여부를 한 곳에서 관리합니다.
/// </summary>
public static class CodeLanguageCatalog
{
@@ -100,187 +95,187 @@ public static class CodeLanguageCatalog
new(new List<CodeLanguageCapability>
{
new(
"csharp",
"C# (.NET)",
[".cs", ".csx", ".csproj", ".sln"],
[
"Use dotnet CLI, solution/project files, and NuGet package conventions.",
"Follow Microsoft naming conventions and prefer targeted edits over broad rewrites.",
"Verify impact on callers, DI registration, nullable flow, and build configuration."
],
LspLanguageId: "csharp",
ShowInQuickSelect: true,
QuickSelectKey: "csharp",
QuickSelectLabel: "C# (.NET)",
QuickSelectIcon: "\uD83D\uDD39"),
"csharp",
"C# (.NET)",
[".cs", ".csx", ".csproj", ".sln"],
[
"Use dotnet CLI, solution/project files, and NuGet package conventions.",
"Follow Microsoft naming conventions and prefer targeted edits over broad rewrites.",
"Verify impact on callers, DI registration, nullable flow, and build configuration."
],
LspLanguageId: "csharp",
ShowInQuickSelect: true,
QuickSelectKey: "csharp",
QuickSelectLabel: "C# (.NET)",
QuickSelectIcon: "\uD83D\uDD39"),
new(
"python",
"Python",
[".py", ".pyi", ".ipynb"],
[
"Use pip/venv or conda only if already available in the environment.",
"Follow PEP 8, type hints, and module/package boundaries.",
"Prefer small focused functions and verify import/runtime errors after edits."
],
LspLanguageId: "python",
ShowInQuickSelect: true,
QuickSelectKey: "python",
QuickSelectLabel: "Python",
QuickSelectIcon: "\uD83D\uDC0D"),
"python",
"Python",
[".py", ".pyi", ".ipynb"],
[
"Use pip/venv or conda only if already available in the environment.",
"Follow PEP 8, type hints, and module/package boundaries.",
"Prefer small focused functions and verify import/runtime errors after edits."
],
LspLanguageId: "python",
ShowInQuickSelect: true,
QuickSelectKey: "python",
QuickSelectLabel: "Python",
QuickSelectIcon: "\uD83D\uDC0D"),
new(
"java",
"Java",
[".java", ".gradle", ".pom"],
[
"Use Maven or Gradle conventions already present in the repository.",
"Follow package structure, visibility rules, and style consistent with the existing codebase.",
"Check interfaces, implementations, and test fixtures together when modifying shared behavior."
],
LspLanguageId: "java",
ShowInQuickSelect: true,
QuickSelectKey: "java",
QuickSelectLabel: "Java",
QuickSelectIcon: "\u2615"),
"java",
"Java",
[".java", ".gradle", ".pom"],
[
"Use Maven or Gradle conventions already present in the repository.",
"Follow package structure, visibility rules, and style consistent with the existing codebase.",
"Check interfaces, implementations, and test fixtures together when modifying shared behavior."
],
LspLanguageId: "java",
ShowInQuickSelect: true,
QuickSelectKey: "java",
QuickSelectLabel: "Java",
QuickSelectIcon: "\u2615"),
new(
"cpp",
"C / C++",
[".c", ".cc", ".cxx", ".cpp", ".h", ".hh", ".hpp", ".inl"],
[
"Respect the repository's existing build system, usually CMake, MSBuild, or compiler-specific scripts.",
"Be careful with headers, include order, ownership, ABI-sensitive changes, and platform guards.",
"Validate both declaration and implementation impact when editing shared types."
],
LspLanguageId: "cpp",
ShowInQuickSelect: true,
QuickSelectKey: "cpp",
QuickSelectLabel: "C/C++",
QuickSelectIcon: "\u2699"),
"cpp",
"C / C++",
[".c", ".cc", ".cxx", ".cpp", ".h", ".hh", ".hpp", ".inl"],
[
"Respect the repository's existing build system, usually CMake, MSBuild, or compiler-specific scripts.",
"Be careful with headers, include order, ownership, ABI-sensitive changes, and platform guards.",
"Validate both declaration and implementation impact when editing shared types."
],
LspLanguageId: "cpp",
ShowInQuickSelect: true,
QuickSelectKey: "cpp",
QuickSelectLabel: "C/C++",
QuickSelectIcon: "\u2699"),
new(
"typescript",
"TypeScript",
[".ts", ".tsx", ".mts", ".cts"],
[
"Use the existing package manager and tsconfig structure.",
"Prefer explicit types on public boundaries and check build/lint config before changing module format.",
"Preserve framework conventions already used by the project."
],
LspLanguageId: "typescript"),
"typescript",
"TypeScript",
[".ts", ".tsx", ".mts", ".cts"],
[
"Use the existing package manager and tsconfig structure.",
"Prefer explicit types on public boundaries and check build/lint config before changing module format.",
"Preserve framework conventions already used by the project."
],
LspLanguageId: "typescript"),
new(
"javascript",
"JavaScript / Vue",
[".js", ".jsx", ".mjs", ".cjs", ".vue"],
[
"Use the existing Node package manager and lint/format rules.",
"For Vue, preserve the current component style and API pattern used by the project.",
"Check module boundaries, imports, and runtime side effects after edits."
],
LspLanguageId: "javascript",
ShowInQuickSelect: true,
QuickSelectKey: "javascript",
QuickSelectLabel: "JavaScript / Vue",
QuickSelectIcon: "\uD83C\uDF10"),
"javascript",
"JavaScript / Vue",
[".js", ".jsx", ".mjs", ".cjs", ".vue"],
[
"Use the existing Node package manager and lint/format rules.",
"For Vue, preserve the current component style and API pattern used by the project.",
"Check module boundaries, imports, and runtime side effects after edits."
],
LspLanguageId: "javascript",
ShowInQuickSelect: true,
QuickSelectKey: "javascript",
QuickSelectLabel: "JavaScript / Vue",
QuickSelectIcon: "\uD83C\uDF10"),
new(
"go",
"Go",
[".go", ".mod", ".sum"],
[
"Preserve package boundaries, error-first flow, and gofmt-style formatting.",
"Check interfaces, exported identifiers, and concurrency-sensitive changes together."
],
LspLanguageId: "go"),
"go",
"Go",
[".go", ".mod", ".sum"],
[
"Preserve package boundaries, error-first flow, and gofmt-style formatting.",
"Check interfaces, exported identifiers, and concurrency-sensitive changes together."
],
LspLanguageId: "go"),
new(
"rust",
"Rust",
[".rs", ".toml"],
[
"Respect Cargo workspace structure, ownership/borrowing rules, and crate boundaries.",
"Prefer explicit enums/results and verify compiler diagnostics after edits."
],
LspLanguageId: "rust"),
"rust",
"Rust",
[".rs", ".toml"],
[
"Respect Cargo workspace structure, ownership/borrowing rules, and crate boundaries.",
"Prefer explicit enums/results and verify compiler diagnostics after edits."
],
LspLanguageId: "rust"),
new(
"php",
"PHP",
[".php", ".phtml"],
[
"Follow the framework and autoloading structure already present in the project.",
"Be careful with runtime includes, container wiring, and mixed template/application files."
],
LspLanguageId: "php"),
"php",
"PHP",
[".php", ".phtml"],
[
"Follow the framework and autoloading structure already present in the project.",
"Be careful with runtime includes, container wiring, and mixed template/application files."
],
LspLanguageId: "php"),
new(
"ruby",
"Ruby",
[".rb", ".rake", ".gemspec"],
[
"Preserve gem structure, Rails or plain Ruby conventions already used in the repository.",
"Check dynamic dispatch, concerns/modules, and tests together after edits."
],
LspLanguageId: "ruby"),
"ruby",
"Ruby",
[".rb", ".rake", ".gemspec"],
[
"Preserve gem structure, Rails or plain Ruby conventions already used in the repository.",
"Check dynamic dispatch, concerns/modules, and tests together after edits."
],
LspLanguageId: "ruby"),
new(
"kotlin",
"Kotlin",
[".kt", ".kts"],
[
"Preserve Gradle structure, package layout, and nullability intent.",
"Be careful with JVM interop boundaries and Android-specific module structure when present."
],
LspLanguageId: "kotlin"),
"kotlin",
"Kotlin",
[".kt", ".kts"],
[
"Preserve Gradle structure, package layout, and nullability intent.",
"Be careful with JVM interop boundaries and Android-specific module structure when present."
],
LspLanguageId: "kotlin"),
new(
"swift",
"Swift",
[".swift"],
[
"Preserve target structure, Apple framework imports, and protocol-oriented design already in use.",
"Check app lifecycle and platform-specific behavior after edits."
],
LspLanguageId: "swift"),
"swift",
"Swift",
[".swift"],
[
"Preserve target structure, Apple framework imports, and protocol-oriented design already in use.",
"Check app lifecycle and platform-specific behavior after edits."
],
LspLanguageId: "swift"),
new(
"scala",
"Scala",
[".scala", ".sc"],
[
"Respect sbt/module structure, functional style, and existing typeclass or Akka patterns if present.",
"Keep public APIs simple and avoid unnecessary type-level churn."
]),
"scala",
"Scala",
[".scala", ".sc"],
[
"Respect sbt/module structure, functional style, and existing typeclass or Akka patterns if present.",
"Keep public APIs simple and avoid unnecessary type-level churn."
]),
new(
"shell",
"Shell",
[".sh", ".bash", ".zsh"],
[
"Prefer safe quoting, explicit exit handling, and repository-local scripts over one-off inline shell.",
"Check portability assumptions and environment-specific commands."
]),
"shell",
"Shell",
[".sh", ".bash", ".zsh"],
[
"Prefer safe quoting, explicit exit handling, and repository-local scripts over one-off inline shell.",
"Check portability assumptions and environment-specific commands."
]),
new(
"powershell",
"PowerShell",
[".ps1", ".psm1", ".psd1", ".bat", ".cmd"],
[
"Prefer native PowerShell cmdlets and safe path handling.",
"Be careful with Windows-specific side effects, quoting, and admin-sensitive operations."
]),
"powershell",
"PowerShell",
[".ps1", ".psm1", ".psd1", ".bat", ".cmd"],
[
"Prefer native PowerShell cmdlets and safe path handling.",
"Be careful with Windows-specific side effects, quoting, and admin-sensitive operations."
]),
new(
"sql",
"SQL",
[".sql"],
[
"Preserve migration ordering, transactional safety, and index/constraint compatibility.",
"Call out destructive or data-migrating changes explicitly."
]),
"sql",
"SQL",
[".sql"],
[
"Preserve migration ordering, transactional safety, and index/constraint compatibility.",
"Call out destructive or data-migrating changes explicitly."
]),
new(
"web",
"HTML / CSS / SCSS",
[".html", ".htm", ".css", ".scss", ".sass", ".less", ".xaml"],
[
"Preserve the existing design system, layout structure, and accessibility semantics.",
"Prefer incremental visual changes and keep selectors/components scoped."
]),
"web",
"HTML / CSS / SCSS",
[".html", ".htm", ".css", ".scss", ".sass", ".less", ".xaml"],
[
"Preserve the existing design system, layout structure, and accessibility semantics.",
"Prefer incremental visual changes and keep selectors/components scoped."
]),
new(
"markup",
"JSON / YAML / XML / Markdown",
[".json", ".jsonc", ".xml", ".yaml", ".yml", ".md", ".txt"],
[
"Preserve schema shape, indentation style, and comment/document conventions already used in the repository.",
"Validate references, keys, and generated consumer impact after edits."
]),
"markup",
"JSON / YAML / XML / Markdown",
[".json", ".jsonc", ".xml", ".yaml", ".yml", ".md", ".txt"],
[
"Preserve schema shape, indentation style, and comment/document conventions already used in the repository.",
"Validate references, keys, and generated consumer impact after edits."
]),
});
private static readonly ReadOnlyDictionary<string, CodeLanguageCapability> s_byKey =
@@ -312,6 +307,7 @@ public static class CodeLanguageCatalog
{
if (string.IsNullOrWhiteSpace(key))
return null;
return s_byKey.TryGetValue(key.Trim(), out var found) ? found : null;
}
@@ -319,6 +315,7 @@ public static class CodeLanguageCatalog
{
if (string.IsNullOrWhiteSpace(extension))
return null;
return s_byExtension.TryGetValue(extension.Trim(), out var found) ? found : null;
}
@@ -380,6 +377,7 @@ public static class CodeLanguageCatalog
sb.Append(BuildLspSupportDescription());
return sb.ToString();
}
public static IReadOnlyList<string> GetManifestHints(string? key)
{
var normalizedKey = FindByKey(key)?.Key;
@@ -446,21 +444,45 @@ public static class CodeLanguageCatalog
return $"{capability.DisplayName}: {string.Join(" | ", parts)}";
}
public static IReadOnlyList<string> BuildWorkspaceWorkflowSummaries(
IEnumerable<string?> extensionsOrKeys,
string? preferredKey = null,
int maxLanguages = 3,
int maxHintsPerKind = 2)
{
var results = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void TryAppend(CodeLanguageCapability? capability)
{
if (capability == null || !seen.Add(capability.Key))
return;
var summary = BuildWorkflowSummary(capability.Key, maxHintsPerKind);
if (!string.IsNullOrWhiteSpace(summary))
results.Add(summary);
}
TryAppend(ResolveCapabilityFromKeyOrExtension(preferredKey));
foreach (var value in extensionsOrKeys ?? [])
{
TryAppend(ResolveCapabilityFromKeyOrExtension(value));
if (results.Count >= Math.Max(1, maxLanguages))
break;
}
return results;
}
public static string BuildFallbackSupportDescription()
=> "LSP 서버가 없거나 연결되지 않아도 확장자, 매니페스트, build/test/lint 힌트 기반의 정적 분석을 계속 제공합니다.";
=> "LSP 서버가 없거나 연결되지 않아도 확장자, 매니페스트, build/test/lint 힌트 기반의 정적 fallback 분석을 계속 제공합니다.";
public static string BuildFallbackSummary(string? filePathOrExtension)
{
CodeLanguageCapability? capability = null;
if (!string.IsNullOrWhiteSpace(filePathOrExtension))
{
capability = filePathOrExtension.StartsWith('.')
? FindByExtension(filePathOrExtension)
: FindByExtension(Path.GetExtension(filePathOrExtension));
}
var capability = ResolveCapabilityFromKeyOrExtension(filePathOrExtension);
if (capability == null)
return "정적 fallback: 확장자와 프로젝트 매니페스트를 먼저 확인하고 관련 build/test 명령 힌트를 따라 수동 검증하세요.";
return "정적 fallback: 확장자와 프로젝트 매니페스트를 먼저 확인하고 관련 build/test/lint 힌트를 따라 수동 검증하세요.";
var sb = new StringBuilder();
sb.AppendLine($"정적 fallback 분석: {capability.DisplayName}");
@@ -484,6 +506,30 @@ public static class CodeLanguageCatalog
return sb.ToString().TrimEnd();
}
private static CodeLanguageCapability? ResolveCapabilityFromKeyOrExtension(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value.Trim();
var capability = FindByKey(normalized);
if (capability != null)
return capability;
if (normalized.StartsWith('.'))
return FindByExtension(normalized);
capability = QuickSelectLanguages
.FirstOrDefault(x => string.Equals(x.QuickSelectKey, normalized, StringComparison.OrdinalIgnoreCase));
if (capability != null)
return capability;
var extension = Path.GetExtension(normalized);
return string.IsNullOrWhiteSpace(extension)
? null
: FindByExtension(extension);
}
private static void AppendHintBlock(List<string> parts, string label, IReadOnlyList<string> hints, int maxHintsPerKind)
{
if (hints.Count == 0)