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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user