핵심 엔진을 claude-code 기준으로 정렬하고 스트리밍 재시도 경계를 정리한다

- StreamingToolExecutionCoordinator에서 조기 실행 대상을 file_read/document_read 중심으로 축소하고 folder_map 등 구조 탐색 도구를 prefetch 대상에서 제거함

- 스트리밍 재시도 전에 RetryReset 이벤트를 추가해 중간 응답 미리보기 누적을 끊고 AgentLoopService가 재시도 경계를 명확히 표시하도록 조정함

- AxAgentExecutionEngine의 Cowork/Code 빈 응답 합성을 보수적으로 바꿔 실행 근거가 있을 때만 완료 요약을 만들고 근거가 없으면 로그 확인 안내를 반환하도록 정리함

- Code 루프의 post-tool verification과 completion gate도 직전 수정에서 함께 정리해 일반 수정의 과검증을 줄였음

- README.md, docs/DEVELOPMENT.md에 2026-04-09 21:03 (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 21:07:49 +09:00
parent 227f5ab0d3
commit 3c6d2f1ce4
8 changed files with 280 additions and 100 deletions

View File

@@ -7,10 +7,23 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서 개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md` `docs/claw-code-parity-plan.md`
- 업데이트: 2026-04-09 13:05 (KST) - 업데이트: 2026-04-09 21:03 (KST)
- AX Agent transcript 표시를 `claude-code`식 row 타입으로 정리했습니다. `thinking / waiting / compact / tool activity / permission / tool result / status`를 별도 row 의미로 정규화하고, process feed는 같은 활동 그룹이면 이어붙여 append 수를 줄이도록 바꿨습니다. - 핵심 엔진 계층도 `claude-code`으로 정리했습니다. 스트리밍 도구 코디네이터는 재시도 전에 중간 스트림 상태를 끊는 `RetryReset` 이벤트를 보내도록 바꿔, 부분 응답이 누적된 채 다시 이어지는 현상을 줄였습니다.
- 권한 요청과 도구 결과 카탈로그를 다시 정리해 Cowork/Code 진행 중 무엇을 하는지, 어떤 결과 상태인지가 기본 transcript에서 더 읽히도록 맞췄습니다. 동시에 render 성능 로그에 row kind 분포와 process feed append/merge 수치를 남겨 이후 실검증 때 구조 개선 효과를 바로 판단할 수 있게 했습니다. - 조기 실행 대상 읽기 도구는 `file_read``document_read` 중심의 가벼운 도구로 다시 좁혔고, `folder_map` 같은 구조 탐색 도구는 더 이상 엔진 레벨 prefetch 대상에 넣지 않도록 조정했습니다.
- Cowork/Chat 하단 프리셋 안내 카드는 실제 메시지뿐 아니라 execution event가 생긴 뒤에도 자동으로 숨겨 결과/진행 화면을 가리지 않도록 조정했고, 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. - 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) - 업데이트: 2026-04-09 01:33 (KST)
- AX Agent transcript 호스트를 `ObservableCollection<UIElement>` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다. - AX Agent transcript 호스트를 `ObservableCollection<UIElement>` 직접 주입 구조에서 `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)의 책임을 더 줄였습니다. - [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.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 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. - [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이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다.

View File

@@ -1,6 +1,31 @@
# AX Copilot - 媛쒕컻 臾몄꽌 # 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 표시 구조 정리 ## claude-code식 transcript 표시 구조 정리
- 업데이트: 2026-04-09 13:05 (KST) - 업데이트: 2026-04-09 13:05 (KST)

View File

@@ -23,7 +23,7 @@ public partial class AgentLoopService
string Content, string Content,
string? PreviousContent = null); string? PreviousContent = null);
private readonly LlmService _llm; private readonly ILlmService _llm;
private readonly ToolRegistry _tools; private readonly ToolRegistry _tools;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator; private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
@@ -82,7 +82,7 @@ public partial class AgentLoopService
/// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary> /// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary>
public event Action<AgentEvent>? EventOccurred; public event Action<AgentEvent>? EventOccurred;
public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings) public AgentLoopService(ILlmService llm, ToolRegistry tools, SettingsService settings)
{ {
_llm = llm; _llm = llm;
_tools = tools; _tools = tools;
@@ -180,6 +180,9 @@ public partial class AgentLoopService
Scope = ClassifyExplorationScope(userQuery, ActiveTab), Scope = ClassifyExplorationScope(userQuery, ActiveTab),
SelectiveHit = true, SelectiveHit = true,
}; };
var pathAccessState = new PathAccessTrackingState();
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
// 워크플로우 상세 로그: 에이전트 루프 시작 // 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start", WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
@@ -242,6 +245,11 @@ public partial class AgentLoopService
var context = BuildContext(); var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy); InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope); InjectExplorationScopeGuidance(messages, explorationState.Scope);
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
taskPolicy,
ActiveTab,
userQuery);
EmitEvent( EmitEvent(
AgentEventType.Thinking, 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})"); EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
// Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) // Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
@@ -554,10 +575,15 @@ public partial class AgentLoopService
} }
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨 // P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides); var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
explorationState,
userQuery,
ActiveTab,
totalToolCalls);
// LLM에 도구 정의와 함께 요청 // LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks; List<ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew(); var llmCallSw = Stopwatch.StartNew();
try try
{ {
@@ -612,7 +638,7 @@ public partial class AgentLoopService
{ {
switch (evt.Kind) switch (evt.Kind)
{ {
case LlmService.ToolStreamEventKind.TextDelta: case ToolStreamEventKind.TextDelta:
if (!string.IsNullOrWhiteSpace(evt.Text)) if (!string.IsNullOrWhiteSpace(evt.Text))
{ {
streamedTextPreview.Append(evt.Text); streamedTextPreview.Append(evt.Text);
@@ -626,7 +652,7 @@ public partial class AgentLoopService
} }
} }
break; break;
case LlmService.ToolStreamEventKind.ToolCallReady: case ToolStreamEventKind.ToolCallReady:
if (evt.ToolCall != null) if (evt.ToolCall != null)
{ {
EmitEvent( EmitEvent(
@@ -635,9 +661,14 @@ public partial class AgentLoopService
$"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}"); $"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}");
} }
break; break;
case LlmService.ToolStreamEventKind.Completed: case ToolStreamEventKind.Completed:
await Task.CompletedTask; await Task.CompletedTask;
break; break;
case ToolStreamEventKind.RetryReset:
streamedTextPreview.Clear();
lastStreamUiUpdateAt = DateTime.UtcNow;
EmitEvent(AgentEventType.Thinking, "", "스트리밍 중간 응답을 정리하고 재시도합니다.");
break;
} }
}); });
runState.ContextRecoveryAttempts = 0; runState.ContextRecoveryAttempts = 0;
@@ -745,7 +776,7 @@ public partial class AgentLoopService
// 응답에서 텍스트와 도구 호출 분리 // 응답에서 텍스트와 도구 호출 분리
var textParts = new List<string>(); var textParts = new List<string>();
var toolCalls = new List<LlmService.ContentBlock>(); var toolCalls = new List<ContentBlock>();
foreach (var block in blocks) foreach (var block in blocks)
{ {
@@ -851,13 +882,15 @@ public partial class AgentLoopService
"[System:ToolCallRequired] " + "[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " + "⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " + "텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
$"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" + "지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}", $"사용 가능한 도구: {activeToolPreview}",
_ => _ =>
"[System:ToolCallRequired] " + "[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " + "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" + $"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
"반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}" $"반드시 사용해야 할 도구 목록: {activeToolPreview}"
}; };
@@ -891,12 +924,13 @@ public partial class AgentLoopService
{ {
1 => 1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " + "[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
$"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" + "지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}", $"사용 가능한 도구: {planToolList}",
_ => _ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " + "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" + $"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}" $"사용 가능한 도구: {planToolList}"
}; };
@@ -1248,7 +1282,7 @@ public partial class AgentLoopService
: resolvedToolName; : resolvedToolName;
var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase) var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase)
? call ? call
: new LlmService.ContentBlock : new ContentBlock
{ {
Type = call.Type, Type = call.Type,
Text = call.Text, Text = call.Text,
@@ -1306,6 +1340,18 @@ public partial class AgentLoopService
continue; continue;
} }
if (TryHandleRepeatedPathAccessTransition(
effectiveCall,
context,
pathAccessState,
messages,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy))
{
continue;
}
// Task Decomposition: 단계 진행률 추적 // Task Decomposition: 단계 진행률 추적
if (planSteps.Count > 0) if (planSteps.Count > 0)
{ {
@@ -1345,7 +1391,7 @@ public partial class AgentLoopService
Role = "system", Role = "system",
Content = Content =
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " + "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." "Do not repeat folder_map unless repository structure is genuinely required."
}); });
EmitEvent( EmitEvent(
@@ -1405,7 +1451,7 @@ public partial class AgentLoopService
if (llm.EnableHookInputMutation && if (llm.EnableHookInputMutation &&
TryGetHookUpdatedInput(preResults, out var updatedInput)) TryGetHookUpdatedInput(preResults, out var updatedInput))
{ {
effectiveCall = new LlmService.ContentBlock effectiveCall = new ContentBlock
{ {
Type = effectiveCall.Type, Type = effectiveCall.Type,
Text = effectiveCall.Text, Text = effectiveCall.Text,
@@ -1533,6 +1579,8 @@ public partial class AgentLoopService
ref statsFailCount, ref statsFailCount,
ref statsInputTokens, ref statsInputTokens,
ref statsOutputTokens); ref statsOutputTokens);
lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName;
if (!result.Success) if (!result.Success)
{ {
@@ -1608,7 +1656,14 @@ public partial class AgentLoopService
messages.Add(LlmService.CreateToolResultMessage( messages.Add(LlmService.CreateToolResultMessage(
effectiveCall.ToolId, effectiveCall.ToolId,
effectiveCall.ToolName, effectiveCall.ToolName,
BuildLoopToolResultMessage(effectiveCall, result, runState))); BuildToolResultFeedbackMessage(
effectiveCall,
result,
runState,
context,
messages,
explorationState,
iteration)));
if (TryHandleReadOnlyStagnationTransition( if (TryHandleReadOnlyStagnationTransition(
consecutiveReadOnlySuccessTools, consecutiveReadOnlySuccessTools,
@@ -1909,7 +1964,8 @@ public partial class AgentLoopService
SkillRuntimeOverrides? runtimeOverrides) SkillRuntimeOverrides? runtimeOverrides)
{ {
var mergedDisabled = MergeDisabledTools(disabledToolNames); var mergedDisabled = MergeDisabledTools(disabledToolNames);
var active = _tools.GetActiveTools(mergedDisabled); // 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약)
var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active; return active;
@@ -4136,7 +4192,7 @@ public partial class AgentLoopService
// 검증 응답 처리 // 검증 응답 처리
var verifyText = new List<string>(); var verifyText = new List<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>(); var verifyToolCalls = new List<ContentBlock>();
foreach (var block in verifyBlocks) foreach (var block in verifyBlocks)
{ {
@@ -4724,7 +4780,7 @@ public partial class AgentLoopService
} }
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary> /// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) private string? CheckDecisionRequired(ContentBlock call, AgentContext context)
{ {
var app = System.Windows.Application.Current as App; var app = System.Windows.Application.Current as App;
var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal"; var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal";
@@ -4811,7 +4867,7 @@ public partial class AgentLoopService
return null; return null;
} }
private static string FormatToolCallSummary(LlmService.ContentBlock call) private static string FormatToolCallSummary(ContentBlock call)
{ {
if (call.ToolInput == null) return call.ToolName; if (call.ToolInput == null) return call.ToolName;
try try
@@ -4829,7 +4885,7 @@ public partial class AgentLoopService
catch { return call.ToolName; } catch { return call.ToolName; }
} }
private static string BuildToolCallSignature(LlmService.ContentBlock call) private static string BuildToolCallSignature(ContentBlock call)
{ {
var name = (call.ToolName ?? "").Trim().ToLowerInvariant(); var name = (call.ToolName ?? "").Trim().ToLowerInvariant();
if (call.ToolInput == null) if (call.ToolInput == null)
@@ -4929,7 +4985,7 @@ public partial class AgentLoopService
FailureRecoveryKind.Permission => FailureRecoveryKind.Permission =>
"권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행", "권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행",
FailureRecoveryKind.Path => FailureRecoveryKind.Path =>
"folder_map/glob -> 절대경로 확인 -> file_read로 존재 검증 -> 원도구 재실행", "glob -> file_read -> folder_map(필요 시만) -> 절대경로 확인 -> 원도구 재실행",
FailureRecoveryKind.Command => FailureRecoveryKind.Command =>
"도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구", "도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구",
FailureRecoveryKind.Dependency => 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", "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", "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" _ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry"
} }
}; };

View File

@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private void ApplyDocumentPlanSuccessTransitions( private void ApplyDocumentPlanSuccessTransitions(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
ref bool documentPlanCalled, ref bool documentPlanCalled,
@@ -96,9 +96,9 @@ public partial class AgentLoopService
} }
private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync( private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<LlmService.ContentBlock> toolCalls, List<ContentBlock> toolCalls,
List<ChatMessage> messages, List<ChatMessage> messages,
Models.LlmSettings llm, Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
@@ -134,7 +134,7 @@ public partial class AgentLoopService
} }
private async Task<bool> TryApplyPostToolVerificationTransitionAsync( private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
Models.LlmSettings llm, Models.LlmSettings llm,
@@ -156,6 +156,16 @@ public partial class AgentLoopService
if (!shouldVerify) if (!shouldVerify)
return false; 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); await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
return true; return true;
} }

View File

@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private void ApplyCodeQualityFollowUpTransition( private void ApplyCodeQualityFollowUpTransition(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
TaskTypePolicy taskPolicy, TaskTypePolicy taskPolicy,
@@ -13,9 +13,9 @@ public partial class AgentLoopService
ref string? lastModifiedCodeFilePath) ref string? lastModifiedCodeFilePath)
{ {
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
{
requireHighImpactCodeVerification = highImpactCodeChange; requireHighImpactCodeVerification = highImpactCodeChange;
if (highImpactCodeChange && ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
{
lastModifiedCodeFilePath = result.FilePath; lastModifiedCodeFilePath = result.FilePath;
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
@@ -52,8 +52,12 @@ public partial class AgentLoopService
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages, messages,
requireHighImpactCodeVerification); requireHighImpactCodeVerification);
var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages);
var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages);
var hasSuccessfulBuildAndTestEvidence = HasSuccessfulBuildAndTestAfterLastModification(messages);
if (executionPolicy.CodeVerificationGateMaxRetries > 0 if (executionPolicy.CodeVerificationGateMaxRetries > 0
&& !hasCodeVerificationEvidence && !hasCodeVerificationEvidence
&& !(hasDiffEvidence && hasRecentBuildOrTestEvidence && !requireHighImpactCodeVerification)
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries) && runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
{ {
runState.CodeVerificationGateRetry++; runState.CodeVerificationGateRetry++;
@@ -89,7 +93,10 @@ public partial class AgentLoopService
return true; return true;
} }
var hasBlockingCodeEvidenceGap = !hasCodeVerificationEvidence
|| (requireHighImpactCodeVerification && !hasSuccessfulBuildAndTestEvidence);
if (executionPolicy.FinalReportGateMaxRetries > 0 if (executionPolicy.FinalReportGateMaxRetries > 0
&& !hasBlockingCodeEvidenceGap
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages) && !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries) && runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
{ {

View File

@@ -133,7 +133,7 @@ public sealed class AxAgentExecutionEngine
int completionTokens = 0, int completionTokens = 0,
long? responseElapsedMs = null, long? responseElapsedMs = null,
string? metaRunId = null, string? metaRunId = null,
ChatStorageService? storage = null) IChatStorageService? storage = null)
{ {
var assistant = new ChatMessage var assistant = new ChatMessage
{ {
@@ -177,7 +177,7 @@ public sealed class AxAgentExecutionEngine
int completionTokens = 0, int completionTokens = 0,
long? responseElapsedMs = null, long? responseElapsedMs = null,
string? metaRunId = null, string? metaRunId = null,
ChatStorageService? storage = null) IChatStorageService? storage = null)
{ {
var normalized = NormalizeAssistantContentForUi(conversation, tab, content); var normalized = NormalizeAssistantContentForUi(conversation, tab, content);
if (tab is "Cowork" or "Code") if (tab is "Cowork" or "Code")
@@ -209,6 +209,10 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content)) if (!string.IsNullOrWhiteSpace(content))
return content; return content;
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
return BuildFallbackCompletionMessage(conversation, runTab); return BuildFallbackCompletionMessage(conversation, runTab);
} }
@@ -230,7 +234,7 @@ public sealed class AxAgentExecutionEngine
public SessionMutationResult AppendExecutionEvent( public SessionMutationResult AppendExecutionEvent(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,
@@ -247,7 +251,7 @@ public sealed class AxAgentExecutionEngine
public SessionMutationResult AppendAgentRun( public SessionMutationResult AppendAgentRun(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,
@@ -272,6 +276,10 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content)) if (!string.IsNullOrWhiteSpace(content))
return content; return content;
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
return BuildFallbackCompletionMessage(conversation, runTab); return BuildFallbackCompletionMessage(conversation, runTab);
} }
@@ -280,6 +288,9 @@ public sealed class AxAgentExecutionEngine
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다. /// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
/// </summary> /// </summary>
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab) private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
=> TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(빈 응답)";
private static string? TryBuildEvidenceBackedCompletionMessage(ChatConversation conversation, string runTab)
{ {
static bool IsSignificantEventType(string t) static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase) => !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
@@ -290,7 +301,8 @@ public sealed class AxAgentExecutionEngine
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열) // 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
static bool IsInternalStatusSummary(string? summary) static bool IsInternalStatusSummary(string? summary)
{ {
if (string.IsNullOrWhiteSpace(summary)) return false; if (string.IsNullOrWhiteSpace(summary))
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase) return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
@@ -345,6 +357,68 @@ public sealed class AxAgentExecutionEngine
return completionLine ?? "(빈 응답)"; 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) private static ChatMessage CloneMessage(ChatMessage source)
{ {
return new ChatMessage return new ChatMessage
@@ -370,7 +444,7 @@ public sealed class AxAgentExecutionEngine
private static SessionMutationResult ApplyConversationMutation( private static SessionMutationResult ApplyConversationMutation(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,

View File

@@ -11,13 +11,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
{ {
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{ {
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read", "file_read", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_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, IReadOnlyCollection<string>, string> _resolveRequestedToolName; private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName;
private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync; private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync;
private readonly Action<AgentEventType, string, string> _emitEvent; private readonly Action<AgentEventType, string, string> _emitEvent;
@@ -27,7 +27,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs; private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
public StreamingToolExecutionCoordinator( public StreamingToolExecutionCoordinator(
LlmService llm, ILlmService llm,
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName, Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync, Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
Action<AgentEventType, string, string> emitEvent, Action<AgentEventType, string, string> emitEvent,
@@ -46,8 +46,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs; _computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
} }
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync( public async Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block, ContentBlock block,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
AgentContext context, AgentContext context,
CancellationToken ct) CancellationToken ct)
@@ -70,57 +70,60 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement; var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await _executeToolAsync(resolvedToolName, input, context, null, ct); var result = await _executeToolAsync(resolvedToolName, input, context, null, ct);
sw.Stop(); sw.Stop();
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
} }
catch (Exception ex) catch (Exception ex)
{ {
sw.Stop(); sw.Stop();
return new LlmService.ToolPrefetchResult( return new ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"), ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
sw.ElapsedMilliseconds, sw.ElapsedMilliseconds,
resolvedToolName); resolvedToolName);
} }
} }
public async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync( public async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct, CancellationToken ct,
string phaseLabel, string phaseLabel,
AgentLoopService.RunState? runState = null, AgentLoopService.RunState? runState = null,
bool forceToolCall = false, bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null, Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null) Func<ToolStreamEvent, Task>? onStreamEventAsync = null)
{ {
var transientRetries = runState?.TransientLlmErrorRetries ?? 0; var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0; var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
while (true) while (true)
{ {
var streamedAnyPartialState = false;
try try
{ {
if (onStreamEventAsync == null) if (onStreamEventAsync == null)
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync); return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
var blocks = new List<LlmService.ContentBlock>(); var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder(); var textBuilder = new StringBuilder();
await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct)) await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct))
{ {
await onStreamEventAsync(evt); 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); 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); blocks.Add(evt.ToolCall);
} }
} }
var result = new List<LlmService.ContentBlock>(); var result = new List<ContentBlock>();
var text = textBuilder.ToString().Trim(); var text = textBuilder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(text)) if (!string.IsNullOrWhiteSpace(text))
result.Add(new LlmService.ContentBlock { Type = "text", Text = text }); result.Add(new ContentBlock { Type = "text", Text = text });
result.AddRange(blocks); result.AddRange(blocks);
return result; return result;
} }
@@ -130,6 +133,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
&& contextRecoveryRetries < 2 && contextRecoveryRetries < 2
&& _forceContextRecovery(messages)) && _forceContextRecovery(messages))
{ {
if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
contextRecoveryRetries++; contextRecoveryRetries++;
if (runState != null) if (runState != null)
runState.ContextRecoveryAttempts = contextRecoveryRetries; runState.ContextRecoveryAttempts = contextRecoveryRetries;
@@ -146,6 +151,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
if (_isTransientLlmError(ex) && transientRetries < 3) if (_isTransientLlmError(ex) && transientRetries < 3)
{ {
if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
transientRetries++; transientRetries++;
if (runState != null) if (runState != null)
runState.TransientLlmErrorRetries = transientRetries; runState.TransientLlmErrorRetries = transientRetries;

View File

@@ -8,13 +8,6 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
/// <summary>
/// LlmService의 Function Calling (tool_use) 확장.
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
/// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다.
/// </summary>
public partial class LlmService
{
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary> /// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock public class ContentBlock
{ {
@@ -36,6 +29,7 @@ public partial class LlmService
{ {
TextDelta, TextDelta,
ToolCallReady, ToolCallReady,
RetryReset,
Completed Completed
} }
@@ -44,6 +38,13 @@ public partial class LlmService
string Text = "", string Text = "",
ContentBlock? ToolCall = null); ContentBlock? ToolCall = null);
/// <summary>
/// LlmService의 Function Calling (tool_use) 확장.
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
/// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다.
/// </summary>
public partial class LlmService
{
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary> /// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall"> /// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. /// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -150,14 +151,14 @@ public partial class LlmService
req.Headers.Add("x-api-key", apiKey); req.Headers.Add("x-api-key", apiKey);
req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion); 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) 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)); 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); using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement; var root = doc.RootElement;