From 6bd8d5bb2cb16f1ebe5d39501d5441787930c4d8 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 9 Apr 2026 23:08:33 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20IBM=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=B0=8F=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EC=88=9C=EC=84=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이번 커밋은 후속 과제로 남아 있던 IBM Qwen 응답 포맷 차이와 보조 도구 과노출 문제를 추가 정리했다. 핵심 변경 사항: - LlmService.ToolUse에서 content/reasoning_content/generated_text/output_text가 배열 또는 블록 형태로 와도 텍스트를 추출하도록 메시지 파서 보강 - content 배열 안의 tool_use/tool_call 블록도 직접 ContentBlock으로 복구해 IBM/Qwen 응답 변형에 더 유연하게 대응 - ToolRegistry 활성 도구 목록 노출 순서를 기본 파일/검색/생성/실행 도구 우선으로 재정렬하고 tool_search, MCP, spawn_agent, task 계열은 뒤로 배치 문서 반영: - README.md, docs/DEVELOPMENT.md에 2026-04-09 23:02 (KST) 기준 이력 추가 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\ - 경고 0개, 오류 0개 --- README.md | 5 + docs/DEVELOPMENT.md | 6 + src/AxCopilot/Services/Agent/ToolRegistry.cs | 169 ++++++++++++++++++- src/AxCopilot/Services/LlmService.ToolUse.cs | 168 ++++++++++++++++-- 4 files changed, 331 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 20145ad..2f85118 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-09 23:02 (KST) +- IBM/Qwen 후속 호환을 한 단계 더 보강했습니다. 이제 IBM 배포형 응답에서 `generated_text`, `output_text`, `message.content`, `reasoning_content`가 문자열뿐 아니라 배열/블록 형태로 와도 텍스트를 추출하고, `content` 배열 안의 `tool_use/tool_call` 블록도 직접 읽어 도구 호출로 복구합니다. +- 활성 도구 노출 순서도 다시 정리했습니다. `file_read/file_edit/glob/grep/lsp_code_intel/build_run/document_plan` 같은 기본 도구를 먼저 보여주고, `document_review/format_convert/tool_search/code_search`는 그 다음, `mcp_*`, `spawn_agent`, `wait_agents`, `task_*`는 더 뒤로 미뤄 `claude-code`처럼 기본 작업 도구가 먼저 선택되도록 했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + - 업데이트: 2026-04-09 22:48 (KST) - `claude-code` 기준 후속 과제를 이어서 반영했습니다. IBM 배포형 vLLM/Qwen 경로에서는 과거 `tool_calls`/`role=tool` 이력을 그대로 재전송하지 않고, `...` 중심의 평탄한 transcript로 직렬화해 엄격한 tool history 검사에 덜 걸리도록 전용 분기를 넣었습니다. - Cowork 문서 생성 프롬프트는 `document_review`와 `format_convert`를 상시 기본 단계처럼 밀지 않도록 다시 낮췄습니다. 이제 기본 검증은 `file_read/document_read` 중심이고, `document_review`는 큰 문서 품질 점검이나 명시적 요청일 때만 권장합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c7d67be..864c145 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,6 +3,12 @@ ## claude-code식 후속 호환/선택성 보강 +- 업데이트: 2026-04-09 23:02 (KST) +- `LlmService.ToolUse`의 메시지 파서를 더 느슨하게 확장했습니다. `TryExtractMessageToolBlocks`는 이제 `content`/`reasoning_content`가 문자열뿐 아니라 배열일 때도 `text`, `output_text`, nested `content`를 모아 텍스트를 만들고, 배열 안의 `tool_use`/`tool_call` 블록은 직접 `ContentBlock`으로 복구합니다. +- IBM 스트리밍 응답의 `results[0]`도 같은 기준으로 읽습니다. `generated_text`, `output_text`가 배열/블록이어도 텍스트를 추출하고, `message` 오브젝트가 있을 때는 그 안의 텍스트/도구 호출까지 함께 처리해 Qwen류 응답 포맷 차이에 덜 민감하게 만들었습니다. +- `ToolRegistry`에는 노출 순서 정렬을 추가했습니다. 기본 파일/검색/생성/실행 도구를 가장 앞에 두고, `document_review`·`format_convert`·`tool_search`·`code_search`는 보조 단계, `mcp_*`·`spawn_agent`·`wait_agents`, `task_*` 계열은 더 뒤에 배치해 `claude-code`처럼 기본 작업 도구가 먼저 선택되도록 조정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + - 업데이트: 2026-04-09 22:48 (KST) - IBM 배포형 vLLM/Qwen 호환을 위해 `LlmService.ToolUse`의 `BuildIbmToolBody`를 다시 정리했습니다. 이전 assistant `tool_calls`와 `role=tool` 이력을 OpenAI 형식으로 재전송하던 경로를 제거하고, `_tool_use_blocks`는 `...` transcript로, `tool_result`는 plain user transcript로 평탄화합니다. 기존의 텍스트 기반 `TryExtractToolCallsFromText` 폴백과 함께 IBM 쪽의 엄격한 tool history 검사에 대응하는 방향입니다. - `ChatWindow.SystemPromptBuilder`는 Cowork/Code 모두 “도구 호출 필수” 톤을 더 낮췄습니다. Cowork는 `document_review`와 `format_convert`를 기본 후속 단계처럼 밀지 않고, `file_read/document_read` 중심의 가벼운 검증을 기본으로 삼습니다. Code/Cowork 공통 `Sub-Agent Delegation`도 `spawn_agent`를 병렬성이 실제로 도움이 될 때만 선택하도록 바꿨습니다. diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs index 56a4a55..4e3b9c3 100644 --- a/src/AxCopilot/Services/Agent/ToolRegistry.cs +++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs @@ -62,7 +62,174 @@ public class ToolRegistry : IDisposable if (disabledNames == null) return All; var disabled = new HashSet(disabledNames, StringComparer.OrdinalIgnoreCase); if (disabled.Count == 0) return All; - return _tools.Values.Where(t => !disabled.Contains(t.Name)).ToList().AsReadOnly(); + return OrderToolsForExposure(_tools.Values.Where(t => !disabled.Contains(t.Name))) + .ToList() + .AsReadOnly(); + } + + /// 비활성 도구를 제외하고 현재 탭에 해당하는 도구만 반환합니다. + public IReadOnlyCollection GetActiveToolsForTab(string activeTab, IEnumerable? disabledNames = null) + { + var disabled = disabledNames != null + ? new HashSet(disabledNames, StringComparer.OrdinalIgnoreCase) + : null; + + return OrderToolsForExposure(_tools.Values.Where(t => + { + if (disabled != null && disabled.Contains(t.Name)) return false; + return IsToolAvailableForTab(t, activeTab); + })).ToList().AsReadOnly(); + } + + private static IEnumerable OrderToolsForExposure(IEnumerable tools) + { + return tools + .OrderBy(GetToolExposureBucket) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase); + } + + private static int GetToolExposureBucket(IAgentTool tool) + { + return tool.Name switch + { + "file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "document_read" + or "process" or "dev_env_detect" or "build_run" or "git_tool" or "lsp_code_intel" + or "document_plan" or "document_assemble" or "docx_create" or "html_create" or "markdown_create" + or "excel_create" or "csv_create" or "pptx_create" or "chart_create" => 0, + "document_review" or "format_convert" or "tool_search" or "code_search" => 1, + "mcp_list_resources" or "mcp_read_resource" or "spawn_agent" or "wait_agents" => 2, + _ when tool.Name.StartsWith("task_", StringComparison.OrdinalIgnoreCase) => 3, + _ => 1 + }; + } + + /// 도구가 해당 탭에서 사용 가능한지 확인합니다. + private static bool IsToolAvailableForTab(IAgentTool tool, string activeTab) + { + var category = ResolveTabCategory(tool); + if (string.IsNullOrEmpty(category)) return true; // null = 모든 탭 + // 쉼표 구분 복수 탭: "Cowork,Code" + foreach (var part in category.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (string.Equals(part, activeTab, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// + /// 도구별 탭 카테고리 오버라이드. + /// IAgentTool.TabCategory가 null인 도구는 이 맵을 참조합니다. + /// 키: 도구 이름, 값: 허용 탭 (쉼표 구분). 맵에 없으면 = 모든 탭. + /// + private static readonly Dictionary ToolTabOverrides = new(StringComparer.OrdinalIgnoreCase) + { + // ════════════════════════════════════════════════════════════ + // Chat = 순수 대화 (도구 없음). 아래 맵에 없는 공통 도구도 + // Chat에선 제외하려면 여기에 "Cowork,Code"로 등록. + // ════════════════════════════════════════════════════════════ + + // ── 파일/검색 기본 도구: Cowork + Code ── + ["file_read"] = "Cowork,Code", + ["file_write"] = "Cowork,Code", + ["file_edit"] = "Cowork,Code", + ["glob"] = "Cowork,Code", + ["grep"] = "Cowork,Code", + ["process"] = "Cowork,Code", + ["folder_map"] = "Cowork,Code", + ["document_read"] = "Cowork,Code", + ["file_manage"] = "Cowork,Code", + ["file_info"] = "Cowork,Code", + ["multi_read"] = "Cowork,Code", + ["zip"] = "Cowork,Code", + ["open_external"] = "Cowork,Code", + + // ── 데이터/유틸리티: Cowork + Code ── + ["json"] = "Cowork,Code", + ["regex"] = "Cowork,Code", + ["base64"] = "Cowork,Code", + ["hash"] = "Cowork,Code", + ["datetime"] = "Cowork,Code", + ["math"] = "Cowork,Code", + ["encoding"] = "Cowork,Code", + ["http"] = "Cowork,Code", + ["clipboard"] = "Cowork,Code", + ["env"] = "Cowork,Code", + ["notify"] = "Cowork,Code", + ["user_ask"] = "Cowork,Code", + ["memory"] = "Cowork,Code", + ["skill_manager"] = "Cowork,Code", + ["tool_search"] = "Cowork,Code", + ["mcp_list_resources"] = "Cowork,Code", + ["mcp_read_resource"] = "Cowork,Code", + + // ── 문서 생성/처리: Cowork 전용 ── + ["xlsx_create"] = "Cowork", + ["excel_create"] = "Cowork", + ["docx_create"] = "Cowork", + ["csv_create"] = "Cowork", + ["md_create"] = "Cowork", + ["markdown_create"] = "Cowork", + ["html_create"] = "Cowork", + ["chart_create"] = "Cowork", + ["batch_create"] = "Cowork", + ["pptx_create"] = "Cowork", + ["document_plan"] = "Cowork", + ["document_assemble"] = "Cowork", + ["document_review"] = "Cowork", + ["format_convert"] = "Cowork", + ["data_pivot"] = "Cowork", + ["template_render"] = "Cowork", + ["text_summarize"] = "Cowork", + ["sql"] = "Cowork", + ["xml"] = "Cowork", + ["image_analyze"] = "Cowork", + + // ── 개발 도구: Code 전용 ── + ["dev_env_detect"] = "Code", + ["build_run"] = "Code", + ["git"] = "Code", + ["lsp"] = "Code", + ["code_search"] = "Code", + ["code_review"] = "Code", + ["project_rule"] = "Code", + ["snippet_run"] = "Code", + ["diff"] = "Code", + ["diff_preview"] = "Code", + ["sub_agent"] = "Code", + ["wait_agents"] = "Code", + ["test_loop"] = "Code", + ["file_watch"] = "Code", + + // ── 태스크/워크트리/팀: Code 전용 ── + ["task_tracker"] = "Code", + ["todo_write"] = "Code", + ["task_create"] = "Code", + ["task_get"] = "Code", + ["task_list"] = "Code", + ["task_update"] = "Code", + ["task_stop"] = "Code", + ["task_output"] = "Code", + ["enter_worktree"] = "Code", + ["exit_worktree"] = "Code", + ["team_create"] = "Code", + ["team_delete"] = "Code", + ["cron_create"] = "Code", + ["cron_delete"] = "Code", + ["cron_list"] = "Code", + ["checkpoint"] = "Code", + ["suggest_actions"] = "Code", + ["playbook"] = "Code", + }; + + /// 도구의 실질 탭 카테고리를 결정합니다 (IAgentTool.TabCategory → 오버라이드 맵 순). + private static string? ResolveTabCategory(IAgentTool tool) + { + // 도구 자체에 TabCategory가 명시되어 있으면 우선 + if (!string.IsNullOrEmpty(tool.TabCategory)) + return tool.TabCategory; + // 오버라이드 맵에서 조회 + return ToolTabOverrides.TryGetValue(tool.Name, out var cat) ? cat : null; } /// IDisposable 도구를 모두 해제합니다. diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index fc73795..5930a26 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -1254,11 +1254,22 @@ public partial class LlmService resultsEl.GetArrayLength() > 0) { var first = resultsEl[0]; - var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl) - ? generatedTextEl.SafeGetString() - : first.SafeTryGetProperty("output_text", out var outputTextEl) - ? outputTextEl.SafeGetString() - : null; + string? generatedText = null; + if (first.SafeTryGetProperty("generated_text", out var generatedTextEl)) + generatedText = TryExtractTextContent(generatedTextEl, out var extractedGeneratedText) ? extractedGeneratedText : generatedTextEl.SafeGetString(); + else if (first.SafeTryGetProperty("output_text", out var outputTextEl)) + generatedText = TryExtractTextContent(outputTextEl, out var extractedOutputText) ? extractedOutputText : outputTextEl.SafeGetString(); + else if (first.SafeTryGetProperty("message", out var ibmMessageEl) && + TryExtractMessageToolBlocks(ibmMessageEl, out var ibmMessageText, out var ibmToolBlocks)) + { + generatedText = ibmMessageText; + foreach (var toolBlock in ibmToolBlocks) + { + if (prefetchToolCallAsync != null) + toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock); + yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock); + } + } if (!string.IsNullOrEmpty(generatedText)) { if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal)) @@ -1388,27 +1399,33 @@ public partial class LlmService message = nestedMessage; var consumed = false; - if (message.SafeTryGetProperty("content", out var contentEl) && - contentEl.ValueKind == JsonValueKind.String) + if (message.SafeTryGetProperty("content", out var contentEl)) { - var parsedText = contentEl.SafeGetString(); - if (!string.IsNullOrWhiteSpace(parsedText)) + if (TryExtractTextContent(contentEl, out var parsedText)) { text = parsedText; consumed = true; } + + if (contentEl.ValueKind == JsonValueKind.Array) + { + foreach (var block in contentEl.EnumerateArray()) + { + if (!TryParseContentArrayToolBlock(block, out var toolBlock)) + continue; + + toolBlocks.Add(toolBlock); + consumed = true; + } + } } // Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용 if (!consumed && message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) && - reasoningContentEl.ValueKind == JsonValueKind.String) + TryExtractTextContent(reasoningContentEl, out var reasoningText)) { - var reasoningText = reasoningContentEl.SafeGetString(); - if (!string.IsNullOrWhiteSpace(reasoningText)) - { - text = reasoningText; - consumed = true; - } + text = reasoningText; + consumed = true; } if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) && @@ -1452,6 +1469,125 @@ public partial class LlmService return consumed; } + private static bool TryExtractTextContent(JsonElement element, out string text) + { + text = ""; + + if (element.ValueKind == JsonValueKind.String) + { + var parsed = element.SafeGetString(); + if (!string.IsNullOrWhiteSpace(parsed)) + { + text = parsed; + return true; + } + + return false; + } + + if (element.ValueKind != JsonValueKind.Array) + return false; + + var segments = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var str = item.SafeGetString(); + if (!string.IsNullOrWhiteSpace(str)) + segments.Add(str); + continue; + } + + if (item.ValueKind != JsonValueKind.Object) + continue; + + if (item.SafeTryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String) + { + var str = textEl.SafeGetString(); + if (!string.IsNullOrWhiteSpace(str)) + segments.Add(str); + continue; + } + + if (item.SafeTryGetProperty("content", out var nestedContentEl) && TryExtractTextContent(nestedContentEl, out var nestedText)) + { + segments.Add(nestedText); + continue; + } + + if (item.SafeTryGetProperty("reasoning_content", out var reasoningEl) && TryExtractTextContent(reasoningEl, out var reasoningText)) + { + segments.Add(reasoningText); + continue; + } + + if (item.SafeTryGetProperty("output_text", out var outputTextEl) && outputTextEl.ValueKind == JsonValueKind.String) + { + var str = outputTextEl.SafeGetString(); + if (!string.IsNullOrWhiteSpace(str)) + segments.Add(str); + } + } + + if (segments.Count == 0) + return false; + + text = string.Join("\n", segments.Where(s => !string.IsNullOrWhiteSpace(s))); + return !string.IsNullOrWhiteSpace(text); + } + + private static bool TryParseContentArrayToolBlock(JsonElement block, out ContentBlock toolBlock) + { + toolBlock = default!; + + if (block.ValueKind != JsonValueKind.Object) + return false; + + var type = block.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() ?? "" : ""; + if (!string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase) && + !string.Equals(type, "tool_call", StringComparison.OrdinalIgnoreCase)) + return false; + + JsonElement? parsedArgs = null; + if (block.SafeTryGetProperty("input", out var inputEl)) + parsedArgs = inputEl.Clone(); + else if (block.SafeTryGetProperty("arguments", out var argsEl)) + { + if (argsEl.ValueKind == JsonValueKind.String) + { + try + { + using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}"); + parsedArgs = argsDoc.RootElement.Clone(); + } + catch + { + parsedArgs = null; + } + } + else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + parsedArgs = argsEl.Clone(); + } + } + + var toolName = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(toolName)) + return false; + + toolBlock = new ContentBlock + { + Type = "tool_use", + ToolName = toolName, + ToolId = block.SafeTryGetProperty("id", out var idEl) + ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] + : Guid.NewGuid().ToString("N")[..12], + ToolInput = parsedArgs, + }; + return true; + } + private static bool LooksLikeCompleteJson(string json) { if (string.IsNullOrWhiteSpace(json))