diff --git a/README.md b/README.md index 71638a3..7a41ab7 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,23 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` -- 업데이트: 2026-04-09 13:05 (KST) -- AX Agent transcript 표시를 `claude-code`식 row 타입 기반으로 재정리했습니다. `thinking / waiting / compact / tool activity / permission / tool result / status`를 별도 row 의미로 정규화하고, process feed는 같은 활동 그룹이면 이어붙여 append 수를 줄이도록 바꿨습니다. -- 권한 요청과 도구 결과 카탈로그를 다시 정리해 Cowork/Code 진행 중 무엇을 하는지, 어떤 결과 상태인지가 기본 transcript에서 더 읽히도록 맞췄습니다. 동시에 render 성능 로그에 row kind 분포와 process feed append/merge 수치를 남겨 이후 실검증 때 구조 개선 효과를 바로 판단할 수 있게 했습니다. -- Cowork/Chat 하단 프리셋 안내 카드는 실제 메시지뿐 아니라 execution event가 생긴 뒤에도 자동으로 숨겨 결과/진행 화면을 가리지 않도록 조정했고, 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. +- 업데이트: 2026-04-09 21:03 (KST) +- 핵심 엔진 계층도 `claude-code` 기준으로 더 정리했습니다. 스트리밍 도구 코디네이터는 재시도 전에 중간 스트림 상태를 끊는 `RetryReset` 이벤트를 보내도록 바꿔, 부분 응답이 누적된 채 다시 이어지는 현상을 줄였습니다. +- 조기 실행 대상 읽기 도구는 `file_read`와 `document_read` 중심의 가벼운 도구로 다시 좁혔고, `folder_map` 같은 구조 탐색 도구는 더 이상 엔진 레벨 prefetch 대상에 넣지 않도록 조정했습니다. +- Cowork/Code에서 최종 텍스트 응답이 비어 있을 때도 무조건 완료 문구를 합성하지 않고, 실행 이벤트 근거가 있을 때만 요약을 만들도록 보수적으로 바꿨습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + +- 업데이트: 2026-04-09 20:46 (KST) +- AX Code 핵심 루프의 과한 검증 개입을 줄여 `claude-code`에 더 가깝게 정리했습니다. 일반 코드 수정 뒤에는 즉시 별도 검증 루프를 다시 돌리지 않고, 고영향 수정일 때만 post-tool verification을 유지하도록 조정했습니다. +- Code 종료 게이트도 순서를 다듬어, diff/build/test 근거가 이미 있는 일반 수정은 `CodeQualityGate`를 과하게 반복하지 않게 했고 `FinalReportGate`는 실제 검증 공백이 해소된 뒤에만 작동하도록 바꿨습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + +- 업데이트: 2026-04-09 20:29 (KST) +- Cowork/Code 입력창에 파일명 후보 칩을 추가했습니다. 작업 폴더가 잡힌 상태에서 파일명 일부, 경로 조각, 확장자 힌트를 입력하면 관련 파일 후보를 바로 보여주고 클릭이나 `Tab`으로 삽입할 수 있어 `folder_map` 없이도 특정 파일 지정을 더 빠르게 시작할 수 있습니다. +- `folder_map`이 `0 files, 0 dirs`를 반환했는데 실제로는 보이는 파일이 있는 경우를 자동 복구하도록 에이전트 루프를 보강했습니다. 이제 같은 경로에 대해 `folder_map`을 반복하지 않고, 내부 fallback 스캔으로 후보 파일을 찾아 `glob -> file_read/document_read` 쪽으로 바로 재유도합니다. +- 워크플로우 로그에는 마지막 도구 결과 이후 다음 LLM 호출까지의 대기 구간을 `llm_wait_after_tool_result` 전이로 남기고, `folder_map_empty_recovery` 복구 전이도 함께 기록합니다. 멈춤처럼 보이는 상황을 다음부터는 로그만 보고 더 빨리 구분할 수 있습니다. +- `DocumentPlannerTool`과 스킬 예시 가이드의 탐색 문구도 다시 정리해, 내부 계획/스킬 층까지 `glob/grep -> targeted read -> folder_map(필요 시만)` 기준으로 맞췄습니다. +- Code 루프에는 같은 경로 반복 접근 가드를 추가했습니다. `file_read/document_read/grep/glob`가 동일 파일 또는 동일 프로젝트 경로를 연속으로 재확인하면 4회째부터는 그대로 읽지 않고, 호출부 탐색·`git_tool(diff)`·`build_run/test_loop` 쪽으로 전환하라는 복구 메시지를 먼저 주입합니다. - 업데이트: 2026-04-09 01:33 (KST) - AX Agent transcript 호스트를 `ObservableCollection` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다. @@ -1546,16 +1559,3 @@ MIT License - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. -- 업데이트: 2026-04-09 10:36 (KST) - - `claude-code`의 선택적 탐색 흐름을 다시 대조해, Cowork/Code가 질문 범위와 무관한 워크스페이스 전체를 훑는 경향을 줄이기 시작했습니다. - - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 Cowork/Code 시스템 프롬프트를 조정해 `folder_map`을 항상 첫 단계로 요구하지 않고, 좁은 질문에서는 `glob/grep + targeted read`를 우선하도록 바꿨습니다. - - [FolderMapTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/FolderMapTool.cs)는 기본 depth를 2로, `include_files` 기본값을 `false`로 조정해 첫 탐색 폭을 더 보수적으로 만들었습니다. - - [MultiReadTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MultiReadTool.cs)는 한 번에 읽을 수 있는 최대 파일 수를 20개에서 8개로 낮춰 초기 과탐색 토큰 낭비를 줄이도록 했습니다. - - [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)와 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)에 탐색 범위 분류기와 broad-scan corrective hint를 추가해, 좁은 질문에서 반복적인 `folder_map`/대량 `multi_read`가 나오면 관련 파일만 다시 고르도록 교정합니다. - - [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)는 `%APPDATA%\\AxCopilot\\perf`에 `exploration_breadth` 로그를 남겨 `folder_map` 호출 수, 총 읽은 파일 수, broad scan 여부를 실사용 기준으로 확인할 수 있게 했습니다. -- 업데이트: 2026-04-09 11:12 (KST) - - `claude-code`의 `Messages.tsx`, `MessageRow.tsx`, `GroupedToolUseContent.tsx`, `UserToolResultMessage`, `PermissionRequest` 구조를 다시 대조해 AX transcript 표시 계약을 row 중심으로 정리했습니다. - - [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)에 `TranscriptRowKind`와 `AgentTranscriptRowPresentation`을 도입해 thinking, waiting, compact, tool activity, permission, tool result, status를 한 번에 정규화하도록 바꿨습니다. - - [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)는 이 row 메타를 사용해 고빈도 process feed를 같은 group key 기준으로 교체 렌더하도록 바꿨고, 연속 읽기/검색/단계 이벤트를 더 적은 row 수로 보여주게 했습니다. - - [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)와 [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)를 정리해 실패/거부/취소/승인 필요와 권한 행위를 유형별 카드 메타로 다시 맞췄습니다. - - [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs), [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 grouped process feed append/merge 수를 추적하도록 바꿔, `claude-code`식 activity grouping이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 826a905..80e2e67 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,31 @@ # AX Copilot - 媛쒕컻 臾몄꽌 +## claude-code식 선택 탐색 우선순위 정렬 + +- 업데이트: 2026-04-09 21:03 (KST) +- `StreamingToolExecutionCoordinator`에 `RetryReset` 이벤트를 추가해, 컨텍스트 복구나 일시적 LLM 오류 재시도 전에 부분 스트림 미리보기 상태를 끊도록 했습니다. `claude-code`가 fallback 시 orphaned partial state를 정리하고 executor를 새로 잡는 흐름과 비슷한 방향으로 AX도 재시도 경계를 더 명확히 가지게 됐습니다. +- 엔진 레벨 `PrefetchableReadOnlyTools`는 `file_read`/`document_read` 중심으로 다시 줄였습니다. `folder_map`, `glob`, `grep`, `multi_read`, `code_search` 같은 구조 탐색/광범위 검색 도구는 prefetch 대상에서 빼서, 탐색 정책과 실행 엔진의 우선순위가 서로 어긋나지 않도록 맞췄습니다. +- `AxAgentExecutionEngine`의 Cowork/Code 빈 응답 처리도 보수적으로 조정했습니다. 이제 최종 텍스트가 비어 있을 때는 실행 이벤트에 실제 파일 경로나 유의미한 완료 요약이 있을 때만 합성 메시지를 만들고, 근거가 없으면 로그 확인을 안내하는 쪽으로 바꿨습니다. + +- 업데이트: 2026-04-09 20:46 (KST) +- Code 핵심 루프에서 `claude-code`와 가장 크게 달랐던 “수정 직후 과한 검증 개입”을 줄였습니다. `TryApplyPostToolVerificationTransitionAsync`는 이제 Code 탭에서 고영향 수정일 때만 별도 검증 LLM 턴을 실행하고, 일반 수정은 메인 루프의 `diff/build/test` 근거 흐름에 맡기도록 바꿨습니다. +- `ApplyCodeQualityFollowUpTransition`도 모든 코드 수정 뒤에 추가 검증 프롬프트를 넣지 않고, 고영향 수정만 즉시 후속 검증을 유도하도록 완화했습니다. 이로써 `file_edit -> file_read -> grep -> file_read` 식의 과도한 재접근이 줄어들도록 정리했습니다. +- `TryApplyCodeCompletionGateTransition`은 diff/build/test 근거가 이미 있는 일반 수정에 대해 `CodeQualityGate`를 중복 발동하지 않도록 조정했고, `FinalReportGate`는 코드 검증 공백이 남아 있을 때는 먼저 열리지 않게 순서를 정리했습니다. + +- 업데이트: 2026-04-09 20:29 (KST) +- `AgentLoopExplorationPolicy`에 현재 반복 기준의 도구 필터링을 추가해, `Localized`/`TopicBased` 요청에서는 `glob`, `grep`, `file_read`, `document_read`, `multi_read`를 먼저 노출하고 `folder_map`은 기본적으로 뒤로 미루거나 제외하도록 조정했습니다. +- `folder_map`은 사용자가 폴더 구조/파일 목록/기존 자료 참조를 명시했거나, 선택 탐색이 몇 차례 실패한 뒤에만 다시 허용합니다. Cowork의 문서형 요청과 Code의 코드 수정 요청이 모두 같은 기준을 따르도록 맞췄습니다. +- `TaskTypePolicy`의 `feature`, `bugfix`, `refactor`, `review`, `docs`, `general` 가이드를 다시 정리해 AX가 `claude-code`처럼 `glob/grep -> targeted read`를 먼저 타게 만들었습니다. 기존처럼 `feature/docs`에서 `folder_map`을 선행 단계처럼 유도하던 문구를 제거했습니다. +- `AgentLoopService`의 no-tool 재시도 프롬프트와 탐색 교정 메시지, 실패 복구 우선순위도 `glob/grep -> targeted read -> folder_map(필요 시만)` 순서로 재정렬했습니다. 이로써 `folder_map` 성공 직후 멈춘 것처럼 보이던 일부 흐름과, 계획만 세우고 첫 도구 선택을 망설이던 Code 루프를 함께 보정했습니다. +- `ChatWindow.SystemPromptBuilder`의 Cowork/Code 시스템 프롬프트는 `모든 응답에 도구 호출 강제`, `첫 항목은 반드시 도구`, `애매하면 folder_map부터` 같은 문구를 제거하고, 실제 `claude-code`처럼 좁은 범위 탐색과 마지막 턴의 텍스트 응답을 허용하는 방향으로 완화했습니다. +- `code_개발`, `code_리뷰`, `code_리팩터링`, `cowork_문서작성`, `cowork_보고서` 프리셋도 같은 기준으로 갱신해 프롬프트 층과 런타임 정책 층이 서로 충돌하지 않도록 정렬했습니다. +- `ChatWindow.FileMentionSuggestions`를 추가해 Cowork/Code 입력창에서 파일명 후보 칩을 즉시 제안합니다. 사용자가 파일명 일부, 경로 조각, 확장자를 입력하면 작업 폴더 인덱스를 바탕으로 관련 파일을 추천하고 클릭 또는 `Tab`으로 삽입할 수 있습니다. +- `AgentLoopExplorationRecovery`를 추가해 `folder_map`의 빈 결과를 자동 복구합니다. `0 files, 0 dirs` 응답 뒤에 실제 파일 후보가 보이면 `folder_map_empty_recovery` 전이를 기록하고, LLM에는 `glob -> file_read/document_read`로 전환하라는 시스템 메시지를 추가합니다. +- 마지막 도구 결과 이후 다음 LLM 호출까지의 대기 시간도 `llm_wait_after_tool_result` 전이로 기록하도록 해, 멈춤 체감이 LLM 대기인지 루프 정체인지 워크플로우 로그만으로 더 빨리 판별할 수 있게 했습니다. +- `DocumentPlannerTool`, `SkillService` 예시 가이드도 같은 선택 탐색 순서로 갱신해 내부 문서 계획 지시와 스킬 샘플이 런타임 정책과 어긋나지 않도록 맞췄습니다. +- `AgentLoopPathStagnation`을 추가해 Code 탭의 동일 경로 재접근 루프를 차단합니다. 기존 가드는 “같은 도구+같은 파라미터” 반복만 강하게 막았지만, 실제 루프는 `file_read -> grep -> file_read`처럼 도구를 바꿔 같은 파일을 계속 두드리는 패턴을 허용했습니다. 이제 동일 경로 읽기 접근이 4회 이상 이어지면 읽기를 중단시키고 `grep/glob으로 호출부 탐색 -> git_tool(diff) -> build_run/test_loop` 순서로 전환하라는 복구 메시지를 주입합니다. + ## claude-code식 transcript 표시 구조 정리 - 업데이트: 2026-04-09 13:05 (KST) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index ce89ae2..cc1ea26 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -23,7 +23,7 @@ public partial class AgentLoopService string Content, string? PreviousContent = null); - private readonly LlmService _llm; + private readonly ILlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; private readonly IToolExecutionCoordinator _toolExecutionCoordinator; @@ -82,7 +82,7 @@ public partial class AgentLoopService /// 에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용). public event Action? EventOccurred; - public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings) + public AgentLoopService(ILlmService llm, ToolRegistry tools, SettingsService settings) { _llm = llm; _tools = tools; @@ -180,6 +180,9 @@ public partial class AgentLoopService Scope = ClassifyExplorationScope(userQuery, ActiveTab), SelectiveHit = true, }; + var pathAccessState = new PathAccessTrackingState(); + DateTime? lastToolResultAtUtc = null; + string? lastToolResultToolName = null; // 워크플로우 상세 로그: 에이전트 루프 시작 WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start", @@ -242,6 +245,11 @@ public partial class AgentLoopService var context = BuildContext(); InjectTaskTypeGuidance(messages, taskPolicy); InjectExplorationScopeGuidance(messages, explorationState.Scope); + var preferredInitialToolSequence = BuildPreferredInitialToolSequence( + explorationState, + taskPolicy, + ActiveTab, + userQuery); EmitEvent( AgentEventType.Thinking, "", @@ -518,6 +526,19 @@ public partial class AgentLoopService } } + if (lastToolResultAtUtc is { } toolResultAt) + { + var waitMs = Math.Max(0, (long)(DateTime.UtcNow - toolResultAt).TotalMilliseconds); + WorkflowLogService.LogTransition( + _conversationId, + _currentRunId, + iteration, + "llm_wait_after_tool_result", + $"{lastToolResultToolName ?? "unknown"}:{waitMs}ms"); + lastToolResultAtUtc = null; + lastToolResultToolName = null; + } + EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"); // Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) @@ -554,10 +575,15 @@ public partial class AgentLoopService } // P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨 - var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides); + var cachedActiveTools = FilterExplorationToolsForCurrentIteration( + GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides), + explorationState, + userQuery, + ActiveTab, + totalToolCalls); // LLM에 도구 정의와 함께 요청 - List blocks; + List blocks; var llmCallSw = Stopwatch.StartNew(); try { @@ -612,7 +638,7 @@ public partial class AgentLoopService { switch (evt.Kind) { - case LlmService.ToolStreamEventKind.TextDelta: + case ToolStreamEventKind.TextDelta: if (!string.IsNullOrWhiteSpace(evt.Text)) { streamedTextPreview.Append(evt.Text); @@ -626,7 +652,7 @@ public partial class AgentLoopService } } break; - case LlmService.ToolStreamEventKind.ToolCallReady: + case ToolStreamEventKind.ToolCallReady: if (evt.ToolCall != null) { EmitEvent( @@ -635,9 +661,14 @@ public partial class AgentLoopService $"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}"); } break; - case LlmService.ToolStreamEventKind.Completed: + case ToolStreamEventKind.Completed: await Task.CompletedTask; break; + case ToolStreamEventKind.RetryReset: + streamedTextPreview.Clear(); + lastStreamUiUpdateAt = DateTime.UtcNow; + EmitEvent(AgentEventType.Thinking, "", "스트리밍 중간 응답을 정리하고 재시도합니다."); + break; } }); runState.ContextRecoveryAttempts = 0; @@ -745,7 +776,7 @@ public partial class AgentLoopService // 응답에서 텍스트와 도구 호출 분리 var textParts = new List(); - var toolCalls = new List(); + var toolCalls = new List(); foreach (var block in blocks) { @@ -851,13 +882,15 @@ public partial class AgentLoopService "[System:ToolCallRequired] " + "⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " + "텍스트 설명만 반환하는 것은 허용되지 않습니다. " + + $"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " + "지금 즉시 아래 형식으로 도구를 호출하세요:\n" + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"사용 가능한 도구: {activeToolPreview}", _ => "[System:ToolCallRequired] " + "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " + - "텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" + + $"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " + + "반드시 아래 형식으로 도구를 호출하세요:\n" + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"반드시 사용해야 할 도구 목록: {activeToolPreview}" }; @@ -891,12 +924,13 @@ public partial class AgentLoopService { 1 => "[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " + + $"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " + "지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"사용 가능한 도구: {planToolList}", _ => "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " + - "텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" + + $"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"사용 가능한 도구: {planToolList}" }; @@ -1248,7 +1282,7 @@ public partial class AgentLoopService : resolvedToolName; var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase) ? call - : new LlmService.ContentBlock + : new ContentBlock { Type = call.Type, Text = call.Text, @@ -1306,6 +1340,18 @@ public partial class AgentLoopService continue; } + if (TryHandleRepeatedPathAccessTransition( + effectiveCall, + context, + pathAccessState, + messages, + lastModifiedCodeFilePath, + requireHighImpactCodeVerification, + taskPolicy)) + { + continue; + } + // Task Decomposition: 단계 진행률 추적 if (planSteps.Count > 0) { @@ -1345,7 +1391,7 @@ public partial class AgentLoopService Role = "system", Content = "Exploration correction: The current request is narrow. Stop broad workspace scanning. " + - "Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " + + "Follow this order: glob/grep -> targeted file_read/document_read -> folder_map only if the user explicitly needs folder structure. " + "Do not repeat folder_map unless repository structure is genuinely required." }); EmitEvent( @@ -1405,7 +1451,7 @@ public partial class AgentLoopService if (llm.EnableHookInputMutation && TryGetHookUpdatedInput(preResults, out var updatedInput)) { - effectiveCall = new LlmService.ContentBlock + effectiveCall = new ContentBlock { Type = effectiveCall.Type, Text = effectiveCall.Text, @@ -1533,6 +1579,8 @@ public partial class AgentLoopService ref statsFailCount, ref statsInputTokens, ref statsOutputTokens); + lastToolResultAtUtc = DateTime.UtcNow; + lastToolResultToolName = effectiveCall.ToolName; if (!result.Success) { @@ -1608,7 +1656,14 @@ public partial class AgentLoopService messages.Add(LlmService.CreateToolResultMessage( effectiveCall.ToolId, effectiveCall.ToolName, - BuildLoopToolResultMessage(effectiveCall, result, runState))); + BuildToolResultFeedbackMessage( + effectiveCall, + result, + runState, + context, + messages, + explorationState, + iteration))); if (TryHandleReadOnlyStagnationTransition( consecutiveReadOnlySuccessTools, @@ -1909,7 +1964,8 @@ public partial class AgentLoopService SkillRuntimeOverrides? runtimeOverrides) { var mergedDisabled = MergeDisabledTools(disabledToolNames); - var active = _tools.GetActiveTools(mergedDisabled); + // 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약) + var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled); if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) return active; @@ -4136,7 +4192,7 @@ public partial class AgentLoopService // 검증 응답 처리 var verifyText = new List(); - var verifyToolCalls = new List(); + var verifyToolCalls = new List(); foreach (var block in verifyBlocks) { @@ -4724,7 +4780,7 @@ public partial class AgentLoopService } /// 영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null. - private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) + private string? CheckDecisionRequired(ContentBlock call, AgentContext context) { var app = System.Windows.Application.Current as App; var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal"; @@ -4811,7 +4867,7 @@ public partial class AgentLoopService return null; } - private static string FormatToolCallSummary(LlmService.ContentBlock call) + private static string FormatToolCallSummary(ContentBlock call) { if (call.ToolInput == null) return call.ToolName; try @@ -4829,7 +4885,7 @@ public partial class AgentLoopService catch { return call.ToolName; } } - private static string BuildToolCallSignature(LlmService.ContentBlock call) + private static string BuildToolCallSignature(ContentBlock call) { var name = (call.ToolName ?? "").Trim().ToLowerInvariant(); if (call.ToolInput == null) @@ -4929,7 +4985,7 @@ public partial class AgentLoopService FailureRecoveryKind.Permission => "권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행", FailureRecoveryKind.Path => - "folder_map/glob -> 절대경로 확인 -> file_read로 존재 검증 -> 원도구 재실행", + "glob -> file_read -> folder_map(필요 시만) -> 절대경로 확인 -> 원도구 재실행", FailureRecoveryKind.Command => "도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구", FailureRecoveryKind.Dependency => @@ -4940,7 +4996,7 @@ public partial class AgentLoopService { "build_run" or "test_loop" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", "file_edit" or "file_write" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", - "grep" or "glob" => "file_read -> folder_map -> grep/glob", + "grep" or "glob" => "glob/grep 재구성 -> file_read -> folder_map(필요 시만)", _ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry" } }; diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs index 03c2c12..d19ec70 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs @@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent; public partial class AgentLoopService { private void ApplyDocumentPlanSuccessTransitions( - LlmService.ContentBlock call, + ContentBlock call, ToolResult result, List messages, ref bool documentPlanCalled, @@ -96,9 +96,9 @@ public partial class AgentLoopService } private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync( - LlmService.ContentBlock call, + ContentBlock call, ToolResult result, - List toolCalls, + List toolCalls, List messages, Models.LlmSettings llm, ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, @@ -134,7 +134,7 @@ public partial class AgentLoopService } private async Task TryApplyPostToolVerificationTransitionAsync( - LlmService.ContentBlock call, + ContentBlock call, ToolResult result, List messages, Models.LlmSettings llm, @@ -156,6 +156,16 @@ public partial class AgentLoopService if (!shouldVerify) return false; + if (string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); + var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages); + var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages); + + if (!highImpactCodeChange || (hasDiffEvidence && hasRecentBuildOrTestEvidence)) + return false; + } + await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); return true; } diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs index c561c4c..fb84e1b 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs @@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent; public partial class AgentLoopService { private void ApplyCodeQualityFollowUpTransition( - LlmService.ContentBlock call, + ContentBlock call, ToolResult result, List messages, TaskTypePolicy taskPolicy, @@ -13,9 +13,9 @@ public partial class AgentLoopService ref string? lastModifiedCodeFilePath) { var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); - if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result)) + requireHighImpactCodeVerification = highImpactCodeChange; + if (highImpactCodeChange && ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result)) { - requireHighImpactCodeVerification = highImpactCodeChange; lastModifiedCodeFilePath = result.FilePath; messages.Add(new ChatMessage { @@ -52,8 +52,12 @@ public partial class AgentLoopService var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( messages, requireHighImpactCodeVerification); + var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages); + var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages); + var hasSuccessfulBuildAndTestEvidence = HasSuccessfulBuildAndTestAfterLastModification(messages); if (executionPolicy.CodeVerificationGateMaxRetries > 0 && !hasCodeVerificationEvidence + && !(hasDiffEvidence && hasRecentBuildOrTestEvidence && !requireHighImpactCodeVerification) && runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries) { runState.CodeVerificationGateRetry++; @@ -89,7 +93,10 @@ public partial class AgentLoopService return true; } + var hasBlockingCodeEvidenceGap = !hasCodeVerificationEvidence + || (requireHighImpactCodeVerification && !hasSuccessfulBuildAndTestEvidence); if (executionPolicy.FinalReportGateMaxRetries > 0 + && !hasBlockingCodeEvidenceGap && !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages) && runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries) { diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs index be331a0..d6319a8 100644 --- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -133,7 +133,7 @@ public sealed class AxAgentExecutionEngine int completionTokens = 0, long? responseElapsedMs = null, string? metaRunId = null, - ChatStorageService? storage = null) + IChatStorageService? storage = null) { var assistant = new ChatMessage { @@ -177,7 +177,7 @@ public sealed class AxAgentExecutionEngine int completionTokens = 0, long? responseElapsedMs = null, string? metaRunId = null, - ChatStorageService? storage = null) + IChatStorageService? storage = null) { var normalized = NormalizeAssistantContentForUi(conversation, tab, content); if (tab is "Cowork" or "Code") @@ -209,6 +209,10 @@ public sealed class AxAgentExecutionEngine if (!string.IsNullOrWhiteSpace(content)) return content; + if (runTab is "Cowork" or "Code") + return TryBuildStrictExecutionCompletionMessage(conversation, runTab) + ?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)"; + return BuildFallbackCompletionMessage(conversation, runTab); } @@ -230,7 +234,7 @@ public sealed class AxAgentExecutionEngine public SessionMutationResult AppendExecutionEvent( ChatSessionStateService session, - ChatStorageService storage, + IChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, @@ -247,7 +251,7 @@ public sealed class AxAgentExecutionEngine public SessionMutationResult AppendAgentRun( ChatSessionStateService session, - ChatStorageService storage, + IChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, @@ -272,6 +276,10 @@ public sealed class AxAgentExecutionEngine if (!string.IsNullOrWhiteSpace(content)) return content; + if (runTab is "Cowork" or "Code") + return TryBuildStrictExecutionCompletionMessage(conversation, runTab) + ?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)"; + return BuildFallbackCompletionMessage(conversation, runTab); } @@ -280,6 +288,9 @@ public sealed class AxAgentExecutionEngine /// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다. /// private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab) + => TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(빈 응답)"; + + private static string? TryBuildEvidenceBackedCompletionMessage(ChatConversation conversation, string runTab) { static bool IsSignificantEventType(string t) => !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase) @@ -290,7 +301,8 @@ public sealed class AxAgentExecutionEngine // 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열) static bool IsInternalStatusSummary(string? summary) { - if (string.IsNullOrWhiteSpace(summary)) return false; + if (string.IsNullOrWhiteSpace(summary)) + return false; return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase) @@ -345,6 +357,68 @@ public sealed class AxAgentExecutionEngine return completionLine ?? "(빈 응답)"; } + private static string? TryBuildStrictExecutionCompletionMessage(ChatConversation conversation, string runTab) + { + static bool IsSignificantEventType(string t) + => !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase); + + static bool IsInternalStatusSummary(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + return false; + + return summary.StartsWith("LLM", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase) + || summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase) + || summary.Contains("[System:", StringComparison.OrdinalIgnoreCase); + } + + var completionLine = runTab switch + { + "Cowork" => "코워크 작업이 완료되었습니다.", + "Code" => "코드 작업이 완료되었습니다.", + _ => null, + }; + + var artifactEvent = conversation.ExecutionEvents? + .Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type)) + .OrderByDescending(evt => evt.Timestamp) + .FirstOrDefault(); + if (artifactEvent != null) + { + var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary) + ? $"생성된 파일: {artifactEvent.FilePath}" + : $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}"; + return completionLine != null + ? $"{completionLine}\n\n{fileLine}" + : fileLine; + } + + var latestSummary = conversation.ExecutionEvents? + .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary) + && IsSignificantEventType(evt.Type) + && !IsInternalStatusSummary(evt.Summary)) + .OrderByDescending(evt => evt.Timestamp) + .Select(evt => evt.Summary.Trim()) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(latestSummary)) + { + return completionLine != null + ? $"{completionLine}\n\n{latestSummary}" + : latestSummary; + } + + return null; + } + private static ChatMessage CloneMessage(ChatMessage source) { return new ChatMessage @@ -370,7 +444,7 @@ public sealed class AxAgentExecutionEngine private static SessionMutationResult ApplyConversationMutation( ChatSessionStateService session, - ChatStorageService storage, + IChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, diff --git a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs index a5dd354..cb170f1 100644 --- a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs +++ b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs @@ -11,13 +11,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina { private static readonly HashSet PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) { - "file_read", "glob", "grep", "grep_tool", "folder_map", "document_read", - "search_codebase", "code_search", "env_tool", "datetime_tool", + "file_read", "document_read", + "env_tool", "datetime_tool", "dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool", - "hash_tool", "image_analyze", "multi_read" + "hash_tool", "image_analyze" }; - private readonly LlmService _llm; + private readonly ILlmService _llm; private readonly Func, string> _resolveRequestedToolName; private readonly Func?, CancellationToken, Task> _executeToolAsync; private readonly Action _emitEvent; @@ -27,7 +27,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina private readonly Func _computeTransientBackoffDelayMs; public StreamingToolExecutionCoordinator( - LlmService llm, + ILlmService llm, Func, string> resolveRequestedToolName, Func?, CancellationToken, Task> executeToolAsync, Action emitEvent, @@ -46,8 +46,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina _computeTransientBackoffDelayMs = computeTransientBackoffDelayMs; } - public async Task TryPrefetchReadOnlyToolAsync( - LlmService.ContentBlock block, + public async Task TryPrefetchReadOnlyToolAsync( + ContentBlock block, IReadOnlyCollection tools, AgentContext context, CancellationToken ct) @@ -70,57 +70,60 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement; var result = await _executeToolAsync(resolvedToolName, input, context, null, ct); sw.Stop(); - return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); + return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); } catch (Exception ex) { sw.Stop(); - return new LlmService.ToolPrefetchResult( + return new ToolPrefetchResult( ToolResult.Fail($"조기 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds, resolvedToolName); } } - public async Task> SendWithToolsWithRecoveryAsync( + public async Task> SendWithToolsWithRecoveryAsync( List messages, IReadOnlyCollection tools, CancellationToken ct, string phaseLabel, AgentLoopService.RunState? runState = null, bool forceToolCall = false, - Func>? prefetchToolCallAsync = null, - Func? onStreamEventAsync = null) + Func>? prefetchToolCallAsync = null, + Func? onStreamEventAsync = null) { var transientRetries = runState?.TransientLlmErrorRetries ?? 0; var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0; while (true) { + var streamedAnyPartialState = false; try { if (onStreamEventAsync == null) return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync); - var blocks = new List(); + var blocks = new List(); var textBuilder = new StringBuilder(); await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct)) { await onStreamEventAsync(evt); - if (evt.Kind == LlmService.ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) + if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) { + streamedAnyPartialState = true; textBuilder.Append(evt.Text); } - else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null) + else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null) { + streamedAnyPartialState = true; blocks.Add(evt.ToolCall); } } - var result = new List(); + var result = new List(); var text = textBuilder.ToString().Trim(); if (!string.IsNullOrWhiteSpace(text)) - result.Add(new LlmService.ContentBlock { Type = "text", Text = text }); + result.Add(new ContentBlock { Type = "text", Text = text }); result.AddRange(blocks); return result; } @@ -130,6 +133,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina && contextRecoveryRetries < 2 && _forceContextRecovery(messages)) { + if (onStreamEventAsync != null && streamedAnyPartialState) + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); contextRecoveryRetries++; if (runState != null) runState.ContextRecoveryAttempts = contextRecoveryRetries; @@ -146,6 +151,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina if (_isTransientLlmError(ex) && transientRetries < 3) { + if (onStreamEventAsync != null && streamedAnyPartialState) + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); transientRetries++; if (runState != null) runState.TransientLlmErrorRetries = transientRetries; diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index 67b39ec..17b603f 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -8,6 +8,36 @@ using AxCopilot.Services.Agent; namespace AxCopilot.Services; +/// LLM 응답에서 파싱된 컨텐츠 블록. +public class ContentBlock +{ + public string Type { get; init; } = "text"; // "text" | "tool_use" + public string Text { get; init; } = ""; // text 타입일 때 + public string ToolName { get; init; } = ""; // tool_use 타입일 때 + public string ToolId { get; init; } = ""; // tool_use ID + public JsonElement? ToolInput { get; init; } // tool_use 파라미터 + public string? ResolvedToolName { get; set; } + public Task? PrefetchedExecutionTask { get; set; } +} + +public sealed record ToolPrefetchResult( + Agent.ToolResult Result, + long ElapsedMilliseconds, + string? ResolvedToolName = null); + +public enum ToolStreamEventKind +{ + TextDelta, + ToolCallReady, + RetryReset, + Completed +} + +public sealed record ToolStreamEvent( + ToolStreamEventKind Kind, + string Text = "", + ContentBlock? ToolCall = null); + /// /// LlmService의 Function Calling (tool_use) 확장. /// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다. @@ -15,35 +45,6 @@ namespace AxCopilot.Services; /// public partial class LlmService { - /// LLM 응답에서 파싱된 컨텐츠 블록. - public class ContentBlock - { - public string Type { get; init; } = "text"; // "text" | "tool_use" - public string Text { get; init; } = ""; // text 타입일 때 - public string ToolName { get; init; } = ""; // tool_use 타입일 때 - public string ToolId { get; init; } = ""; // tool_use ID - public JsonElement? ToolInput { get; init; } // tool_use 파라미터 - public string? ResolvedToolName { get; set; } - public Task? PrefetchedExecutionTask { get; set; } - } - - public sealed record ToolPrefetchResult( - Agent.ToolResult Result, - long ElapsedMilliseconds, - string? ResolvedToolName = null); - - public enum ToolStreamEventKind - { - TextDelta, - ToolCallReady, - Completed - } - - public sealed record ToolStreamEvent( - ToolStreamEventKind Kind, - string Text = "", - ContentBlock? ToolCall = null); - /// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다. /// /// true이면 tool_choice: "required"를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. @@ -150,14 +151,14 @@ public partial class LlmService req.Headers.Add("x-api-key", apiKey); req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion); - using var resp = await _http.SendAsync(req, ct); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { - var errBody = await resp.Content.ReadAsStringAsync(ct); + var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); throw new HttpRequestException(ClassifyHttpError(resp, errBody)); } - var respJson = await resp.Content.ReadAsStringAsync(ct); + var respJson = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(respJson); var root = doc.RootElement;