Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/LspTool.cs
lacvet 0b6d60e959 에이전트 루프와 코드 언어 지원, PPT 생성 품질을 함께 고도화
- AgentCommandQueue를 도입해 실행 중 추가 입력을 우선순위와 인터럽트 여부까지 포함해 처리하도록 정리함
- AgentToolResultBudget와 AgentQueryContextBuilder에 tool result preview 캐시를 연결해 긴 세션에서 축약 결과 재사용을 안정화함
- CodeLanguageCatalog를 추가해 코드 탭의 내장 언어 지원, 인덱싱 확장자, 시스템 프롬프트 언어 가이드, LSP 언어 판정을 한 카탈로그로 통합함
- 설정의 코드 탭에 지원 언어(LSP)와 코드 탭 기본 지원 언어를 명시적으로 표시하도록 보강함
- DocumentPlannerTool의 presentation 구조를 컨설팅형 스토리라인으로 정리하고, PptxSkill에 executive_summary/recommendation/roadmap/comparison/kpi_dashboard 레이아웃을 추가함
- pptx-creator 스킬을 AX native pptx_create 중심으로 재작성하고, 관련 회귀 테스트를 추가했으며 WorkspaceContextGeneratorTests의 nullable 경고도 정리함

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_impl\\ -p:IntermediateOutputPath=obj\\verify_impl\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|AgentCommandQueueTests|AgentToolResultBudgetTests|DocumentPlannerPresentationTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_impl_tests\\ -p:IntermediateOutputPath=obj\\verify_impl_tests\\
2026-04-14 19:53:39 +09:00

339 lines
16 KiB
C#

using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// LSP 기반 코드 인텔리전스 도구.
/// 정의 이동, 참조 검색, hover, 구현 위치, 심볼 검색, 호출 계층 등 구조적 코드 탐색을 제공합니다.
/// </summary>
public class LspTool : IAgentTool, IDisposable
{
public string Name => "lsp_code_intel";
public string Description =>
"코드 인텔리전스 도구. 정의, 참조, hover, 구현 위치, 문서/워크스페이스 심볼, 호출 계층을 제공합니다.\n" +
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다\n" +
"- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" +
"- action=\"hover\": 심볼의 타입/문서 정보를 가져옵니다\n" +
"- action=\"goto_implementation\": 인터페이스/추상 멤버의 구현 위치를 찾습니다\n" +
"- action=\"symbols\": 파일 내 모든 심볼을 나열합니다\n" +
"- action=\"workspace_symbols\": 워크스페이스 전체 심볼을 검색합니다 (query 권장)\n" +
"- action=\"prepare_call_hierarchy\": 현재 위치의 호출 계층 기준 심볼을 확인합니다\n" +
"- action=\"incoming_calls\": 현재 심볼을 호출하는 상위 호출자를 찾습니다\n" +
"- action=\"outgoing_calls\": 현재 심볼이 호출하는 하위 호출 대상을 찾습니다\n" +
$"지원 언어 서버: {Services.CodeLanguageCatalog.BuildLspSupportDescription()}\n" +
"line/character는 기본적으로 1-based 입력을 기대하며, 0-based 값도 호환 처리합니다.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업",
Enum = new()
{
"goto_definition",
"find_references",
"hover",
"goto_implementation",
"symbols",
"workspace_symbols",
"prepare_call_hierarchy",
"incoming_calls",
"outgoing_calls"
}
},
["file_path"] = new ToolProperty
{
Type = "string",
Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로). workspace_symbols에서는 작업 폴더 기준 힌트로만 사용 가능."
},
["line"] = new ToolProperty
{
Type = "integer",
Description = "대상 라인 번호. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
},
["character"] = new ToolProperty
{
Type = "integer",
Description = "라인 내 문자 위치. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
},
["query"] = new ToolProperty
{
Type = "string",
Description = "workspace_symbols에서 사용할 심볼 검색어. 비어 있으면 file_path의 파일명/심볼 힌트를 사용합니다."
},
},
Required = new() { "action", "file_path" }
};
// 언어별 LSP 클라이언트 캐시
private readonly Dictionary<string, LspClientService> _clients = new();
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
// 설정 체크
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
var line = args.SafeTryGetProperty("line", out var l) ? NormalizePosition(l.SafeGetInt32()) : 0;
var character = args.SafeTryGetProperty("character", out var ch) ? NormalizePosition(ch.SafeGetInt32()) : 0;
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
// 절대 경로 변환
if (!Path.IsPathRooted(filePath) && !string.IsNullOrEmpty(context.WorkFolder))
filePath = Path.Combine(context.WorkFolder, filePath);
if (!File.Exists(filePath))
return ToolResult.Fail($"파일을 찾을 수 없습니다: {filePath}");
// 언어 감지
var language = DetectLanguage(filePath);
if (language == null)
return ToolResult.Fail($"지원하지 않는 파일 형식: {Path.GetExtension(filePath)}");
// LSP 클라이언트 시작 (캐시)
var client = await GetOrCreateClientAsync(language, context.WorkFolder, ct);
if (client == null || !client.IsConnected)
return ToolResult.Fail($"{language} 언어 서버를 시작할 수 없습니다. 해당 언어 서버가 설치되어 있는지 확인하세요.");
try
{
return action switch
{
"goto_definition" => await GotoDefinitionAsync(client, filePath, line, character, ct),
"find_references" => await FindReferencesAsync(client, filePath, line, character, ct),
"hover" => await HoverAsync(client, filePath, line, character, ct),
"goto_implementation" => await GotoImplementationAsync(client, filePath, line, character, ct),
"symbols" => await GetSymbolsAsync(client, filePath, ct),
"workspace_symbols" => await GetWorkspaceSymbolsAsync(client, filePath, query, ct),
"prepare_call_hierarchy" => await PrepareCallHierarchyAsync(client, filePath, line, character, ct),
"incoming_calls" => await GetIncomingCallsAsync(client, filePath, line, character, ct),
"outgoing_calls" => await GetOutgoingCallsAsync(client, filePath, line, character, ct),
_ => ToolResult.Fail("알 수 없는 action입니다. goto_definition | find_references | hover | goto_implementation | symbols | workspace_symbols | prepare_call_hierarchy | incoming_calls | outgoing_calls 중 선택하세요.")
};
}
catch (Exception ex)
{
return ToolResult.Fail($"LSP 오류: {ex.Message}");
}
}
private async Task<ToolResult> GotoDefinitionAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var loc = await client.GotoDefinitionAsync(filePath, line, character, ct);
if (loc == null)
return ToolResult.Ok("정의를 찾을 수 없습니다 (해당 위치에 심볼이 없거나 외부 라이브러리일 수 있습니다).");
// 정의 위치의 코드를 읽어서 컨텍스트 제공
var contextCode = ReadCodeContext(loc.FilePath, loc.Line, 3);
return ToolResult.Ok(
$"정의 위치: {loc}\n\n```\n{contextCode}\n```",
loc.FilePath);
}
private async Task<ToolResult> FindReferencesAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var locations = await client.FindReferencesAsync(filePath, line, character, ct);
if (locations.Count == 0)
return ToolResult.Ok("참조를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 참조");
sb.AppendLine($"파일 수: {locations.Select(l => l.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
sb.AppendLine($"첫 참조: {locations[0]}");
sb.AppendLine();
foreach (var loc in locations.Take(30))
sb.AppendLine($" {loc}");
if (locations.Count > 30)
sb.AppendLine($" ... 외 {locations.Count - 30}개");
return ToolResult.Ok(sb.ToString(), locations[0].FilePath);
}
private async Task<ToolResult> HoverAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var hover = await client.HoverAsync(filePath, line, character, ct);
if (string.IsNullOrWhiteSpace(hover))
return ToolResult.Ok("hover 정보를 찾을 수 없습니다.");
return ToolResult.Ok($"Hover 정보\n위치: {Path.GetFileName(filePath)}:{line + 1}:{character + 1}\n\n{hover}", filePath);
}
private async Task<ToolResult> GotoImplementationAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var locations = await client.GotoImplementationAsync(filePath, line, character, ct);
if (locations.Count == 0)
return ToolResult.Ok("구현 위치를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 구현 위치");
sb.AppendLine($"파일 수: {locations.Select(l => l.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
sb.AppendLine($"첫 구현: {locations[0]}");
sb.AppendLine();
foreach (var loc in locations.Take(20))
sb.AppendLine($" {loc}");
if (locations.Count > 20)
sb.AppendLine($" ... 외 {locations.Count - 20}개");
return ToolResult.Ok(sb.ToString(), locations[0].FilePath);
}
private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct)
{
var symbols = await client.GetDocumentSymbolsAsync(filePath, ct);
if (symbols.Count == 0)
return ToolResult.Ok("심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {symbols.Count}개 심볼");
sb.AppendLine($"파일: {Path.GetFileName(filePath)}");
sb.AppendLine();
foreach (var sym in symbols)
sb.AppendLine($" {sym}");
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> GetWorkspaceSymbolsAsync(LspClientService client, string filePath, string query, CancellationToken ct)
{
var fallbackQuery = !string.IsNullOrWhiteSpace(query)
? query
: Path.GetFileNameWithoutExtension(filePath);
var symbols = await client.SearchWorkspaceSymbolsAsync(fallbackQuery, ct);
if (symbols.Count == 0)
return ToolResult.Ok($"워크스페이스 심볼을 찾을 수 없습니다. query=\"{fallbackQuery}\"");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"query=\"{fallbackQuery}\" 결과 {symbols.Count}개");
sb.AppendLine($"파일 수: {symbols.Select(s => s.Location?.FilePath).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
if (symbols.FirstOrDefault() is { } firstSymbol)
sb.AppendLine($"첫 결과: {firstSymbol}");
sb.AppendLine();
foreach (var sym in symbols.Take(30))
sb.AppendLine($" {sym}");
if (symbols.Count > 30)
sb.AppendLine($" ... 외 {symbols.Count - 30}개");
return ToolResult.Ok(sb.ToString(), symbols.FirstOrDefault(s => s.Location != null)?.Location?.FilePath);
}
private async Task<ToolResult> PrepareCallHierarchyAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var items = await client.PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return ToolResult.Ok("호출 계층 기준 심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"호출 계층 기준 {items.Count}개");
sb.AppendLine($"대표 심볼: {items[0]}");
sb.AppendLine();
foreach (var item in items.Take(10))
sb.AppendLine($" {item}");
if (items.Count > 10)
sb.AppendLine($" ... 외 {items.Count - 10}개");
return ToolResult.Ok(sb.ToString(), items[0].Location.FilePath);
}
private async Task<ToolResult> GetIncomingCallsAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var calls = await client.GetIncomingCallsAsync(filePath, line, character, ct);
if (calls.Count == 0)
return ToolResult.Ok("incoming call을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"상위 호출자 {calls.Count}개");
sb.AppendLine($"대표 호출자: {calls[0]}");
sb.AppendLine();
foreach (var call in calls.Take(20))
sb.AppendLine($" {call}");
if (calls.Count > 20)
sb.AppendLine($" ... 외 {calls.Count - 20}개");
return ToolResult.Ok(sb.ToString(), calls[0].Location.FilePath);
}
private async Task<ToolResult> GetOutgoingCallsAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var calls = await client.GetOutgoingCallsAsync(filePath, line, character, ct);
if (calls.Count == 0)
return ToolResult.Ok("outgoing call을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"하위 호출 대상 {calls.Count}개");
sb.AppendLine($"첫 호출 대상: {calls[0]}");
sb.AppendLine();
foreach (var call in calls.Take(20))
sb.AppendLine($" {call}");
if (calls.Count > 20)
sb.AppendLine($" ... 외 {calls.Count - 20}개");
return ToolResult.Ok(sb.ToString(), calls[0].Location.FilePath);
}
private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct)
{
if (_clients.TryGetValue(language, out var existing) && existing.IsConnected)
return existing;
var client = new LspClientService(language);
var started = await client.StartAsync(workFolder, ct);
if (!started)
{
client.Dispose();
return null;
}
_clients[language] = client;
return client;
}
private static string? DetectLanguage(string filePath)
=> Services.CodeLanguageCatalog.DetectLspLanguageId(filePath);
private static int NormalizePosition(int value)
{
if (value <= 0)
return 0;
return value - 1;
}
public void Dispose()
{
foreach (var client in _clients.Values)
client.Dispose();
_clients.Clear();
}
private static string ReadCodeContext(string filePath, int targetLine, int contextLines)
{
try
{
var read = TextFileCodec.ReadAllText(filePath);
var lines = TextFileCodec.SplitLines(read.Text);
var start = Math.Max(0, targetLine - contextLines);
var end = Math.Min(lines.Length - 1, targetLine + contextLines);
var sb = new System.Text.StringBuilder();
for (int i = start; i <= end; i++)
{
var marker = i == targetLine ? ">>>" : " ";
sb.AppendLine($"{marker} {i + 1,4}: {lines[i].TrimEnd('\r')}");
}
return sb.ToString().TrimEnd();
}
catch { return "(코드를 읽을 수 없습니다)"; }
}
}