AX Agent 코워크·코드 후속 호환 과제 정리 및 IBM Qwen 도구호출 경로 보강

이번 커밋은 claude-code 기준 후속 과제를 이어서 반영해 Cowork/Code의 도구 선택 강도와 IBM 배포형 vLLM(Qwen) 호환 경로를 정리했다.

핵심 변경 사항:

- IBM 전용 tool body에서 과거 assistant tool_calls 및 role=tool 이력을 OpenAI 형식으로 재전송하지 않고 평탄한 transcript로 직렬화하도록 변경

- Cowork 프롬프트에서 document_review 및 format_convert를 기본 단계처럼 강제하지 않고 file_read/document_read 중심의 가벼운 검증 흐름으로 완화

- unknown/disallowed tool recovery에서 tool_search를 항상 강제하지 않고 alias 후보나 활성 도구 예시로 바로 선택 가능하면 직접 사용하도록 조정

- Code 탐색에서 정의/참조/구현/호출관계 의도는 lsp_code_intel을 더 우선하도록 보강하고 LSP 결과 요약 품질 개선

- 문서 검증 근거는 file_read/document_read면 충분하도록 단순화

문서 반영:

- README.md, docs/DEVELOPMENT.md에 2026-04-09 22:48 (KST) 기준 작업 이력 추가

검증 결과:

- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\

- 경고 0개, 오류 0개
This commit is contained in:
2026-04-09 22:50:27 +09:00
parent 3c6d2f1ce4
commit 1fab215344
8 changed files with 967 additions and 72 deletions

View File

@@ -11,6 +11,8 @@ public partial class AgentLoopService
TopicBased,
RepoWide,
OpenEnded,
/// <summary>문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용.</summary>
DirectCreation,
}
private sealed class ExplorationTrackingState
@@ -24,6 +26,196 @@ public partial class AgentLoopService
public bool CorrectiveHintInjected { get; set; }
}
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
IReadOnlyCollection<IAgentTool> tools,
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
if (tools.Count == 0)
return tools;
// 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치
if (state.Scope == ExplorationScope.DirectCreation)
{
var creationFirst = new List<IAgentTool>(tools.Count);
creationFirst.AddRange(tools.Where(IsDocumentCreationTool));
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)));
return creationFirst
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
var allowFolderMap = ShouldAllowFolderMapForCurrentIteration(state, userQuery, activeTab, totalToolCalls);
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))));
if (allowFolderMap)
{
ordered.AddRange(tools.Where(t => string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)));
}
return ordered
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
private static bool IsDocumentCreationTool(IAgentTool tool)
{
return tool.Name is "document_plan" or "docx_create" or "html_create" or "excel_create"
or "markdown_create" or "csv_create" or "pptx_create" or "chart_create"
or "document_assemble" or "file_write";
}
private static bool ShouldAllowFolderMapForCurrentIteration(
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
// 문서 생성 모드에서는 folder_map 차단 — 탐색 없이 바로 생성
if (state.Scope == ExplorationScope.DirectCreation)
return false;
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return true;
if (HasExplicitFolderIntent(userQuery))
return true;
if (string.Equals(activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& IsExistingMaterialReferenceRequest(userQuery))
return true;
return totalToolCalls >= 2 && state.TotalFilesRead == 0 && state.FolderMapCalls == 0;
}
private static bool HasExplicitFolderIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"folder",
"directory",
"tree",
"structure",
"list files",
"workspace layout",
"work folder",
"project structure",
"폴더",
"디렉터리",
"폴더 구조",
"디렉터리 구조",
"파일 목록",
"작업 폴더",
"폴더 안",
"구조를 보여",
"구조 확인");
}
private static bool IsExistingMaterialReferenceRequest(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"existing file",
"existing files",
"existing document",
"existing documents",
"reference files",
"reference docs",
"existing materials",
"기존 파일",
"기존 문서",
"기존 자료",
"참고 파일",
"참고 문서",
"폴더 내 자료",
"안의 자료",
"작업 폴더 파일");
}
private static bool IsSelectiveDiscoveryTool(IAgentTool tool)
{
return tool.Name is "glob" or "grep" or "file_read" or "document_read" or "multi_read" or "lsp_code_intel";
}
private static bool HasSemanticNavigationIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"definition",
"reference",
"references",
"implementation",
"implementations",
"caller",
"callers",
"callee",
"call hierarchy",
"symbol",
"symbols",
"interface",
"override",
"정의",
"참조",
"구현",
"호출부",
"호출 관계",
"심볼",
"인터페이스",
"오버라이드");
}
private static string BuildPreferredInitialToolSequence(
ExplorationTrackingState state,
TaskTypePolicy taskPolicy,
string? activeTab,
string userQuery)
{
// 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용
if (state.Scope == ExplorationScope.DirectCreation)
return "document_plan -> docx_create/html_create/excel_create -> self-review";
if (HasExplicitFolderIntent(userQuery))
return "folder_map -> glob/grep -> targeted read";
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
return "glob/grep -> document_read/file_read -> drafting tool";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasSemanticNavigationIntent(userQuery))
return "lsp_code_intel -> targeted file_read -> file_edit/file_write -> build_run/test_loop as needed";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
return "targeted file_read or grep/glob/lsp -> file_edit/file_write -> build_run/test_loop as needed";
if (state.Scope == ExplorationScope.TopicBased)
return "glob/grep -> targeted read -> next action";
return "glob/grep -> file_read/document_read -> next action";
}
private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab)
{
if (string.IsNullOrWhiteSpace(userQuery))
@@ -32,6 +224,11 @@ public partial class AgentLoopService
var q = userQuery.Trim();
var lower = q.ToLowerInvariant();
// Cowork 탭에서 문서 생성 의도가 있으면 탐색을 건너뛰고 바로 생성 도구 사용
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasDocumentCreationIntent(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("전체 점검"))
@@ -52,14 +249,47 @@ public partial class AgentLoopService
: ExplorationScope.OpenEnded;
}
/// <summary>
/// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다.
/// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true.
/// </summary>
private static bool HasDocumentCreationIntent(string lowerQuery)
{
// 생성 동사 키워드
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",
"표", "차트", "스프레드시트", "프레젠테이션",
"정리해", "정리 해");
}
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
{
var guidance = scope switch
{
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.",
ExplorationScope.Localized =>
"Exploration scope = localized. Start with grep/glob and targeted file reads. Avoid folder_map unless structure is unclear.",
"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.",
ExplorationScope.TopicBased =>
"Exploration scope = topic-based. Identify candidate files by topic keywords first, then read only a small targeted set.",
"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.",
_ =>
@@ -93,6 +323,15 @@ public partial class AgentLoopService
string toolName,
string argsJson)
{
// 문서 생성 모드: 탐색 도구가 1회라도 호출되면 즉시 교정
if (state.Scope == ExplorationScope.DirectCreation)
{
return string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase)
|| state.TotalFilesRead >= 1;
}
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return false;

View File

@@ -255,6 +255,7 @@ public partial class AgentLoopService
"",
explorationState.Scope switch
{
ExplorationScope.DirectCreation => "문서 생성 모드 · 바로 문서를 만드는 중",
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
@@ -2832,7 +2833,7 @@ public partial class AgentLoopService
? testReviewLine +
"5. grep 또는 glob으로 공용 API, 호출부, 의존성 등록, 테스트 영향을 모두 다시 확인합니다.\n" +
"6. build_run으로 build와 test를 모두 실행해 통과 여부를 확인합니다.\n" +
"7. 필요하면 spawn_agent로 호출부 분석이나 관련 테스트 탐색을 병렬 조사하게 하고, wait_agents로 결과를 통합합니다.\n" +
"7. 병렬 조사가 실제로 도움이 될 때만 spawn_agent로 호출부 분석이나 관련 테스트 탐색을 위임하고, wait_agents로 결과를 통합합니다.\n" +
"8. 문제가 발견되면 즉시 수정하고 다시 검증합니다.\n" +
"중요: 이 변경은 영향 범위가 넓을 가능성이 큽니다. build/test와 참조 검토가 모두 끝나기 전에는 마무리하지 마세요."
: testReviewLine +
@@ -2983,7 +2984,7 @@ public partial class AgentLoopService
var failureHint = BuildFailureTypeRecoveryHint(ClassifyFailureRecoveryKind(toolName, toolOutput), toolName);
var highImpactLine = highImpactChange
? "4. 공용 API, 인터페이스, DI 등록, 모델 계약, 호출부 전파 영향까지 반드시 확인합니다.\n" +
"5. 필요하면 spawn_agent로 호출부/관련 테스트 조사를 병렬 실행해 근거를 보강합니다.\n" +
"5. 병렬 조사가 실제로 도움이 될 때만 spawn_agent로 호출부/관련 테스트 조사를 실행해 근거를 보강합니다.\n" +
"6. 관련 테스트가 없다면 테스트 부재 사실과 확인 근거를 남깁니다.\n" +
"7. 근본 원인을 수정한 뒤 build/test를 다시 실행합니다.\n" +
"중요: 이 변경은 영향 범위가 넓을 수 있으므로 build와 test 둘 다 확인하기 전에는 종료하지 마세요."
@@ -3859,7 +3860,9 @@ public partial class AgentLoopService
$"- 실패 도구: {unknownToolName}\n" +
aliasHint +
$"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" +
"- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" +
(string.IsNullOrEmpty(aliasHint)
? "- 위 후보만으로도 충분히 선택 가능하면 바로 그 도구를 호출하세요. 그래도 도구가 애매할 때만 tool_search를 사용하세요.\n"
: "- 자동 매핑 후보가 맞으면 바로 그 도구를 호출하세요. 정말 모호할 때만 tool_search를 사용하세요.\n") +
"위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요.";
}
@@ -3879,7 +3882,7 @@ public partial class AgentLoopService
$"- 요청 도구: {requestedToolName}\n" +
policyLine +
$"- 지금 사용 가능한 도구 예시: {activePreview}\n" +
"- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" +
"- 위 허용 도구 예시로 바로 대체할 수 있으면 즉시 호출하세요. 그래도 도구 선택이 모호할 때만 tool_search를 사용하세요.\n" +
"허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다.";
}
@@ -3893,7 +3896,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedUnknownToolCount}\n" +
$"- 실패 도구: {unknownToolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요.";
"- 다음 실행에서는 먼저 위 목록의 실제 도구 이름을 직접 선택하고, 그래도 애매할 때만 tool_search를 사용하세요.";
}
private static string BuildDisallowedToolLoopAbortResponse(
@@ -3906,7 +3909,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedCount}\n" +
$"- 비허용 도구: {toolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요.";
"- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요.";
}
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)

View File

@@ -300,7 +300,7 @@ public partial class AgentLoopService
};
var verificationTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"file_read", "document_read", "document_review"
"file_read", "document_read"
};
var sawVerification = false;
@@ -421,7 +421,7 @@ public partial class AgentLoopService
}
private bool TryHandleRepeatedFailureGuardTransition(
LlmService.ContentBlock call,
ContentBlock call,
string toolCallSignature,
List<ChatMessage> messages,
string? lastFailedToolSignature,
@@ -463,7 +463,7 @@ public partial class AgentLoopService
}
private bool TryHandleNoProgressReadOnlyLoopTransition(
LlmService.ContentBlock call,
ContentBlock call,
string toolCallSignature,
int repeatedSameSignatureCount,
List<ChatMessage> messages,
@@ -911,22 +911,22 @@ public partial class AgentLoopService
}
}
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
private async Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct)
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct);
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
private async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
RunState? runState = null,
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<ToolStreamEvent, Task>? onStreamEventAsync = null)
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
messages,
tools,
@@ -938,7 +938,7 @@ public partial class AgentLoopService
onStreamEventAsync);
private void ApplyToolPostExecutionBookkeeping(
LlmService.ContentBlock call,
ContentBlock call,
ToolResult result,
TokenUsage? tokenUsage,
Models.LlmSettings llm,
@@ -986,7 +986,7 @@ public partial class AgentLoopService
}
private bool TryHandleToolFailureTransition(
LlmService.ContentBlock call,
ContentBlock call,
ToolResult result,
AgentContext context,
TaskTypePolicy taskPolicy,
@@ -1120,7 +1120,7 @@ public partial class AgentLoopService
}
private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync(
LlmService.ContentBlock call,
ContentBlock call,
AgentContext context,
List<ChatMessage> messages)
{
@@ -1193,4 +1193,3 @@ public partial class AgentLoopService
}

View File

@@ -5,18 +5,24 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// LSP 기반 코드 인텔리전스 도구.
/// 정의 이동(goto_definition), 참조 검색(find_references), 심볼 목록(symbols) 3가지 액션을 제공합니다.
/// 정의 이동, 참조 검색, hover, 구현 위치, 심볼 검색, 호출 계층 등 구조적 코드 탐색을 제공합니다.
/// </summary>
public class LspTool : IAgentTool, IDisposable
{
public string Name => "lsp_code_intel";
public string Description =>
"코드 인텔리전스 도구. 정의 이동, 참조 검색, 심볼 목록을 제공합니다.\n" +
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다 (파일, 라인, 컬럼)\n" +
"코드 인텔리전스 도구. 정의, 참조, hover, 구현 위치, 문서/워크스페이스 심볼, 호출 계층을 제공합니다.\n" +
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다\n" +
"- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" +
"- action=\"symbols\": 파일 내 모든 심볼(클래스, 메서드, 필드 등)을 나열합니다\n" +
"file_path, line, character 파라미터가 필요합니다 (line과 character는 0-based).";
"- 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" +
"line/character는 기본적으로 1-based 입력을 기대하며, 0-based 값도 호환 처리합니다.";
public ToolParameterSchema Parameters => new()
{
@@ -25,23 +31,39 @@ public class LspTool : IAgentTool, IDisposable
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: goto_definition | find_references | symbols",
Enum = new() { "goto_definition", "find_references", "symbols" }
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 = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로)"
Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로). workspace_symbols에서는 작업 폴더 기준 힌트로만 사용 가능."
},
["line"] = new ToolProperty
{
Type = "integer",
Description = "대상 라인 번호 (0-based). symbols 액션에서는 불필요."
Description = "대상 라인 번호. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
},
["character"] = new ToolProperty
{
Type = "integer",
Description = "라인 내 문자 위치 (0-based). symbols 액션에서는 불필요."
Description = "라인 내 문자 위치. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
},
["query"] = new ToolProperty
{
Type = "string",
Description = "workspace_symbols에서 사용할 심볼 검색어. 비어 있으면 file_path의 파일명/심볼 힌트를 사용합니다."
},
},
Required = new() { "action", "file_path" }
@@ -59,8 +81,9 @@ public class LspTool : IAgentTool, IDisposable
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) ? l.GetInt32() : 0;
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
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가 필요합니다.");
@@ -88,8 +111,14 @@ public class LspTool : IAgentTool, IDisposable
{
"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),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. goto_definition | find_references | symbols 중 선택하세요.")
"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)
@@ -118,13 +147,44 @@ public class LspTool : IAgentTool, IDisposable
return ToolResult.Ok("참조를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 참조:");
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());
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)
@@ -134,13 +194,92 @@ public class LspTool : IAgentTool, IDisposable
return ToolResult.Ok("심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {symbols.Count}개 심볼:");
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)
@@ -173,6 +312,14 @@ public class LspTool : IAgentTool, IDisposable
};
}
private static int NormalizePosition(int value)
{
if (value <= 0)
return 0;
return value - 1;
}
public void Dispose()
{
foreach (var client in _clients.Values)

View File

@@ -870,59 +870,28 @@ public partial class LlmService
{
if (m.Role == "system") continue;
// tool_result → OpenAI role:"tool" 형식 (watsonx /text/chat 지원)
// IBM/Qwen 배포형은 과거 tool_calls/tool 메시지 이력을 엄격하게 검사하는 경우가 있어
// 이전 tool-use 대화는 평탄한 transcript로 재구성한다.
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").SafeGetString(),
});
msgs.Add(new { role = "user", content = BuildIbmToolResultTranscript(root) });
continue;
}
catch { }
}
// _tool_use_blocks → OpenAI tool_calls 형식
// _tool_use_blocks도 assistant + tool_calls로 다시 보내지 않고 plain assistant transcript로 평탄화
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").SafeGetString();
if (bType == "text")
textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").SafeGetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson,
}
});
}
}
msgs.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
tool_calls = toolCallsList,
});
msgs.Add(new { role = "assistant", content = BuildIbmAssistantTranscript(blocksArr) });
continue;
}
catch { }
@@ -999,6 +968,60 @@ public partial class LlmService
};
}
private static string BuildIbmAssistantTranscript(JsonElement blocksArr)
{
var textSegments = new List<string>();
var toolSegments = new List<string>();
foreach (var block in blocksArr.EnumerateArray())
{
var blockType = block.GetProperty("type").SafeGetString();
if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase))
{
var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
textSegments.Add(text.Trim());
continue;
}
if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase))
continue;
var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var args = block.SafeTryGetProperty("input", out var inputEl) ? inputEl.GetRawText() : "{}";
if (string.IsNullOrWhiteSpace(name))
continue;
toolSegments.Add($"<tool_call>\n{{\"name\":\"{name}\",\"arguments\":{args}}}\n</tool_call>");
}
var parts = new List<string>();
if (textSegments.Count > 0)
parts.Add(string.Join("\n\n", textSegments));
if (toolSegments.Count > 0)
parts.Add(string.Join("\n", toolSegments));
return parts.Count == 0
? "<tool_call>\n{\"name\":\"unknown_tool\",\"arguments\":{}}\n</tool_call>"
: string.Join("\n\n", parts);
}
private static string BuildIbmToolResultTranscript(JsonElement root)
{
var toolCallId = root.SafeTryGetProperty("tool_use_id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : "";
var header = string.IsNullOrWhiteSpace(toolName)
? "[Tool Result]"
: $"[Tool Result: {toolName}]";
if (!string.IsNullOrWhiteSpace(toolCallId))
header += $" (id={toolCallId})";
return string.IsNullOrWhiteSpace(content)
? $"{header}\n(no output)"
: $"{header}\n{content}";
}
private sealed class ToolCallAccumulator
{
public int Index { get; init; }