11 Commits
Author SHA1 Message Date
lacvet 227f5ab0d3 에이전트 진행 표시 구조를 claude-code식 row 기반으로 재정리
Release Gate / gate (push) Has been cancelled
- AgentTranscriptDisplayCatalog를 row presentation 중심으로 재구성해 thinking/waiting/compact/tool activity/permission/tool result/status를 타입별로 분리함
- PermissionRequestPresentationCatalog와 ToolResultPresentationCatalog를 정리해 권한 요청과 결과 상태를 행위/상태 기준으로 더 명확하게 표현함
- ChatWindow.AgentEventRendering에서 process feed 계열 이벤트를 GroupKey 기준으로 병합해 append 수를 줄이고 진행 흐름이 기본 transcript에 남도록 조정함
- FooterPresentation에서 Cowork/Chat 프리셋 안내 카드가 execution event 이후 자동으로 숨겨지도록 하고 입력 워터마크와 footer 기본 문구를 정리함
- render_messages 성능 로그에 processFeed append/merge 수치와 rowKindCounts를 추가해 %APPDATA%\\AxCopilot\\perf 기준 실검증이 가능하도록 함
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
2026-04-09 14:49:53 +09:00
lacvet 33c1db4dae 에이전트 선택적 탐색 구조 개선과 경고 정리 반영
Release Gate / gate (push) Has been cancelled
- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00
lacvet 7931566212 구조 개선: transcript 지연 가상화와 tool executor 분리 적용
이번 변경은 claude-code 기준 구조 강건성을 높이기 위한 리팩터링입니다.

핵심 수정 사항:

- AX Agent transcript를 TranscriptVisualItem/TranscriptVisualHost 기반 지연 materialization 구조로 전환해 MessageList 가상화 기반을 강화했습니다.

- StreamingToolExecutionCoordinator를 IToolExecutionCoordinator 뒤로 분리해 AgentLoopService가 구체 구현에 직접 묶이지 않도록 정리했습니다.

- 라이브 진행 카드 렌더를 ChatWindow.LiveProgressPresentation partial로 이동해 ChatWindow.xaml.cs의 책임을 더 줄였습니다.

- 기존 메시지 bubble 조립 로직을 유지하면서 transcript host가 필요 시점에 bubble을 만들 수 있도록 helper 경로를 추가했습니다.

검증 결과:

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

- 경고 0 / 오류 0
2026-04-09 01:37:08 +09:00
lacvet 4f3c19c15c 문서: claude-code와 AX Agent 구조 비교 기준 문서 추가
Release Gate / gate (push) Has been cancelled
- claude-code 구조, 특징 기능, 에이전트 루프, Cowork/Code 결과 품질 요인, 프롬프트 전략을 정리한 비교 문서 추가

- AX Agent 구조, 실행 계층, 메모리/도구/transcript 구조와 현재 강점·약점을 정리

- README와 DEVELOPMENT 문서에 신규 비교 문서와 업데이트 이력 반영

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-09 01:09:21 +09:00
lacvet 0ceca202e9 AX Agent loop 정책을 분리하고 transcript 가상화·성능 계측 구조를 강화한다
Release Gate / gate (push) Has been cancelled
- AgentLoop 검증/문서/compact 정책 메서드를 partial 파일로 분리해 loop 본문의 책임을 줄임
- transcript 렌더에 cache pruning과 deferred scrolling을 적용해 긴 세션의 UI 부담을 낮춤
- AgentPerformanceLogService를 추가해 transcript 렌더와 agent loop 실행 요약을 perf 로그로 남김
- README와 DEVELOPMENT 문서에 2026-04-09 10:08 (KST) 기준 구조 개선 및 계측 이력을 반영함
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 검증 결과 경고 0개 오류 0개
2026-04-09 00:57:38 +09:00
lacvet 594d38e4a9 AX Agent transcript 렌더 계획/적용 구조를 분리해 유지보수성을 높이고 문서를 갱신한다
Release Gate / gate (push) Has been cancelled
- ChatWindow transcript 렌더를 planner/execution 단계로 분리해 RenderMessages 오케스트레이션을 단순화함
- TranscriptRenderPlanner/TranscriptRenderExecution partial을 추가해 planning과 host 적용 책임을 나눔
- README와 DEVELOPMENT 문서에 2026-04-09 09:37 (KST) 기준 구조 개선 이력을 반영함
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 검증 결과 경고 0개 오류 0개
2026-04-09 00:39:47 +09:00
lacvet 8643562319 AX Agent 구조를 claw-code 기준으로 추가 정리해 transcript 렌더와 tool streaming 책임을 분리함
Release Gate / gate (push) Has been cancelled
- ChatWindow.TranscriptRendering partial을 추가해 transcript windowing, 증분 렌더, 스크롤 보존 로직을 메인 ChatWindow.xaml.cs에서 분리
- StreamingToolExecutionCoordinator를 도입해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 계층으로 이동
- AgentLoopRuntimeThresholds helper를 추가해 no-tool, plan retry, terminal evidence gate 임계값 계산을 AgentLoopService에서 분리
- AgentLoopTransitions.Execution은 coordinator thin wrapper 중심 구조로 정리해 이후 executor 고도화와 정책 변경이 덜 위험하도록 개선
- README와 docs/DEVELOPMENT.md를 2026-04-09 09:14 (KST) 기준으로 갱신
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-09 00:16:54 +09:00
lacvet 74d43e701c AX Agent transcript 호스트를 컬렉션 기반으로 재구성해 코워크·코드 스트리밍 중 UI 부하를 줄임
Release Gate / gate (push) Has been cancelled
- 메시지 영역을 ScrollViewer+StackPanel 직접 조작 구조에서 ListBox+ObservableCollection 기반 transcript 호스트로 전환
- ChatWindow.TranscriptHost 도입으로 transcript 요소 추가·교체·삭제·스크롤 접근을 공용 helper로 정리
- 코워크/코드 실행 중 RenderMessages와 라이브 카드가 MessagePanel.Children 직접 조작에 덜 의존하도록 정리
- MessageBubble, AgentEventRendering, Timeline, UserAsk, ConversationManagement 등 transcript 관련 partial을 새 호스트 구조에 맞게 전환
- 향후 claw-code의 VirtualMessageList 수준 가상화를 적용할 수 있는 기반을 마련
- README와 DEVELOPMENT 문서를 2026-04-08 12:52 (KST) 기준으로 갱신
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-09 00:00:37 +09:00
lacvet 8db1a1ffb0 코워크·코드 경량 모드 애니메이션 비용 추가 완화
Release Gate / gate (push) Has been cancelled
- 실행 중 경량 라이브 진행 모드에서 메시지 진입 애니메이션과 진행 마커 펄스를 줄여 UI 스레드 부담을 더 낮춤

- AX Agent 라이브 카드의 등장·퇴장 애니메이션과 아이콘 opacity 펄스도 경량 모드에서는 생략하도록 조정함

- README와 DEVELOPMENT 문서를 2026-04-08 12:40 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-08 23:49:38 +09:00
lacvet 117320af02 코워크·코드 스트리밍 렌더 윈도우 축소 및 타이머 재예약 최적화
Release Gate / gate (push) Has been cancelled
- claude-code의 virtualized message list 방향을 참고해 AX transcript에 실행 중 경량 렌더 윈도우를 도입함

- 코워크/코드 스트리밍 중 최근 항목 위주로 더 작은 타임라인만 렌더하도록 조정해 render 대상 수를 줄임

- execution history, agent UI event, task summary, input UI 타이머는 경량 라이브 진행 모드에서 이미 예약되어 있으면 다시 stop/start 하지 않도록 정리함

- README와 DEVELOPMENT 문서를 2026-04-08 12:33 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-08 23:44:52 +09:00
lacvet 6bccc229b0 코워크·코드 실행 중 보조 UI 갱신 부담 완화
Release Gate / gate (push) Has been cancelled
- claude-code 구조 비교 결과를 바탕으로 코워크/코드 경량 라이브 진행 모드에서 transcript 재렌더에 이어 보조 UI 갱신 빈도도 함께 낮춤

- input UI refresh timer와 task summary refresh timer를 실행 중 경량 모드에서 더 느슨한 간격으로 조정함

- Thinking, ToolCall 같은 고빈도 이벤트는 작업 요약 갱신을 매번 다시 예약하지 않고 상태 변화가 큰 이벤트 중심으로만 갱신하도록 정리함

- README와 DEVELOPMENT 문서를 2026-04-08 12:26 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-08 23:33:33 +09:00
139 changed files with 6963 additions and 8080 deletions
+56
View File
@@ -7,6 +7,16 @@ 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 01:33 (KST)
- AX Agent transcript 호스트를 `ObservableCollection<UIElement>` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다.
- `StreamingToolExecutionCoordinator``IToolExecutionCoordinator` 인터페이스 뒤로 숨겨 Cowork/Code tool streaming executor를 루프 구현에서 구조적으로 분리했습니다. 이후 executor 교체, 테스트 더블 주입, 모델별 executor 분기를 더 쉽게 할 수 있는 상태입니다.
- AX Agent 라이브 진행 카드 렌더 책임을 `ChatWindow.LiveProgressPresentation.cs`로 이동해 `ChatWindow.xaml.cs`의 오케스트레이션 밀도를 더 낮췄습니다. transcript/라이브 카드/루프 쪽 책임 분해가 한 단계 더 진행됐습니다.
- 업데이트: 2026-04-07 09:19 (KST)
- AX Agent 하단 상태바의 전체 토큰 집계가 유휴 전환 후 사라지지 않도록 conversation aggregate 복원 경로를 추가했습니다. 실행 중 누적 토큰이 0이어도 현재 대화 전체의 prompt/completion 합계를 다시 계산해 상태바에 유지합니다.
- 컨텍스트 사용량 팝업이 마지막 실제 압축 결과와 맞지 않던 문제를 수정했습니다. 이제 최근 압축의 실제 before/after 토큰과 자동/수동 여부, 누적 압축 횟수와 절감 토큰을 기준으로 표시합니다.
@@ -1503,3 +1513,49 @@ MIT License
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `IsLightweightLiveProgressMode()`를 추가해, 코워크/코드 + 실행 히스토리 접힘 상태에서는 라이브 진행 카드를 우선 사용하고 transcript 재렌더 빈도를 더 강하게 낮추도록 조정했습니다.
- 같은 조건에서 `_executionHistoryRenderTimer`, `_agentUiEventTimer` 간격도 더 느슨하게 조정해, 스트리밍 중 작은 진행 이벤트가 여러 타이머를 통해 UI 전체를 자주 흔들던 문제를 줄였습니다.
- 코워크/코드 실행 중 접힌 히스토리 상태에서는 process feed 계열 이벤트가 더 이상 매번 transcript 렌더를 요청하지 않고, 완료/오류/문서 생성 결과처럼 실제로 기록 가치가 큰 이벤트만 강하게 렌더 요청을 남기도록 정리했습니다.
- 업데이트: 2026-04-08 12:26 (KST)
- 코워크/코드 실행 중 보조 UI 갱신도 더 느슨하게 조정했습니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `입력 보조 UI``작업 요약` 타이머도 경량 라이브 진행 모드일 때 더 긴 간격으로 동작하도록 바꿨습니다.
- 경량 모드에서는 `Thinking`, `ToolCall` 같은 잦은 이벤트마다 `작업 요약` 갱신을 다시 예약하지 않고, `계획`, `권한 요청`, `도구 결과`, `완료/오류`처럼 실제로 요약 상태가 달라지는 이벤트 중심으로만 요약 UI를 갱신하도록 정리했습니다.
- 결과적으로 코워크/코드 처리 중 transcript 외의 보조 UI 측정/배치 비용도 함께 줄어, 입력 지연과 스크롤 버벅임 완화에 직접적으로 기여하도록 조정했습니다.
- 업데이트: 2026-04-08 12:33 (KST)
- `claw-code`의 가상화 메시지 리스트에 바로 가기 전 단계로, AX transcript에 `실행 중 렌더 윈도우 축소`를 적용했습니다. 코워크/코드 스트리밍 중에는 최근 항목 위주로 더 작은 타임라인만 렌더하고, 평상시에는 기존 범위를 유지합니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `GetActiveTimelineRenderLimit()`를 추가해, 일반 스트리밍은 96개, 경량 라이브 진행 모드는 60개만 렌더하도록 조정했습니다.
- `ScheduleExecutionHistoryRender`, `ScheduleAgentUiEvent`, `ScheduleTaskSummaryRefresh`, `ScheduleInputUiRefresh`도 경량 모드에서는 이미 타이머가 대기 중일 때 다시 stop/start 하지 않게 바꿔, 이벤트 폭주 시 dispatcher 예약 churn을 줄였습니다.
- 업데이트: 2026-04-08 12:40 (KST)
- 코워크/코드 경량 라이브 진행 모드에서는 메시지 진입 애니메이션과 진행 마커 펄스도 줄였습니다.
- [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs)의 메시지 엔트리 애니메이션은 실행 중 경량 모드에서 즉시 표시로 바뀌어, 새 UI 요소가 들어올 때마다 opacity/translate 애니메이션이 누적되지 않게 했습니다.
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 진행 마커 펄스는 경량 모드에서 정적 점 상태로 간소화했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 AX Agent 라이브 카드도 경량 모드에서는 등장/퇴장 애니메이션과 아이콘 opacity 펄스를 줄여, 실행 중 레이아웃/애니메이션 비용을 더 낮췄습니다.
- 업데이트: 2026-04-08 12:52 (KST)
- AX Agent transcript 호스트를 `ScrollViewer + StackPanel + Children.Add` 구조에서 `ListBox + ObservableCollection<UIElement>` 기반으로 옮겼습니다.
- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs)를 추가해 transcript 요소 추가/교체/삭제와 스크롤 접근을 공용 helper로 정리했고, 내부 ScrollViewer도 한 번만 찾아 재사용하도록 바꿨습니다.
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 메시지 영역은 `VirtualizingStackPanel`을 쓰는 `ListBox` 기반 호스트로 교체해, 이후 `claw-code``VirtualMessageList`에 더 가까운 가상화 구조로 밀어갈 수 있는 기반을 만들었습니다.
- 관련 렌더 코드([ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs), [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs), [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs), [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs))도 모두 컬렉션 기반 조작으로 맞춰, 실행 중 `Children` 직접 조작에 따른 레이아웃 churn을 줄였습니다.
- 업데이트: 2026-04-09 09:14 (KST)
- `claw-code`와의 구조 대조 결과를 바탕으로, transcript 렌더와 tool streaming 실행 책임을 더 분리했습니다.
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)를 추가해 `RenderMessages()`와 transcript windowing/증분 렌더 흐름을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 옮겼습니다.
- [StreamingToolExecutionCoordinator.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs)를 추가해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 coordinator 계층으로 분리했습니다.
- [AgentLoopRuntimeThresholds.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs)를 추가해 no-tool, plan retry, terminal evidence gate 같은 임계값 계산을 `AgentLoopService`에서 분리했습니다.
- 결과적으로 Cowork/Code의 핵심 루프는 정책 소비자에 더 가까워졌고, 이후 transcript 진짜 가상화와 모델별 실행 정책 조정도 덜 위험하게 진행할 수 있는 구조가 됐습니다.
- 업데이트: 2026-04-09 09:37 (KST)
- transcript 렌더 구조를 planning/execution 단계로 한 번 더 쪼갰습니다. [ChatWindow.TranscriptRenderPlanner.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRenderPlanner.cs) 에서 visible window 계산, render key 집계, 전체/증분 렌더 계획 생성을 맡기고, [ChatWindow.TranscriptRenderExecution.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRenderExecution.cs) 에서 host 적용과 viewport 보존을 맡기도록 정리했습니다.
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)의 `RenderMessages()`는 이제 `데이터 수집 -> render plan 생성 -> 증분/전체 적용`만 오케스트레이션하는 얇은 진입점이 됐습니다.
- `claw-code``Messages.tsx``VirtualMessageList.tsx`처럼 transcript planning과 실제 host 조작을 분리하는 방향에 더 가까워졌고, 이후 실제 가상화 윈도우 정책을 다듬을 때 변경 범위를 더 안전하게 제한할 수 있게 됐습니다.
- 업데이트: 2026-04-09 10:08 (KST)
- 구조 개선과 실검증을 함께 하기 위해 loop 정책과 transcript 호스트에 성능 계측을 추가했습니다. [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)가 `%APPDATA%\\AxCopilot\\perf`에 transcript 렌더 시간과 agent loop 실행 요약을 JSON 로그로 남깁니다.
- [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이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+238
View File
@@ -0,0 +1,238 @@
# claude-code / AX Agent 구조 비교 문서
업데이트: 2026-04-09 10:20 (KST)
이 문서는 AX Agent를 `claude-code` 동등 품질 기준으로 유지·검증하기 위한 비교 기준 문서입니다.
실제 비교 대상 저장 폴더명은 `claw-code`이지만, 문서 표기는 `claude-code`로 통일합니다.
## 1. claude-code 구조, 특징 기능, 에이전트 루프
### 1-1. 구조 핵심
`claude-code`는 크게 아래 축으로 나뉩니다.
1. 부트스트랩/세션 상태
- `src/bootstrap/state.ts`
- `src/bridge/initReplBridge.ts`
- `src/bridge/sessionRunner.ts`
2. 메인 REPL/대화 화면
- `src/screens/REPL.tsx`
- `src/components/Messages.tsx`
- `src/components/VirtualMessageList.tsx`
- `src/components/StatusLine.tsx`
3. 도구 실행 계층
- `src/services/tools/toolOrchestration.ts`
- `src/services/tools/StreamingToolExecutor.ts`
4. 메모리/컨텍스트/권한
- `04_작동원리.md`
- `05_메모리와컨텍스트.md`
- `06_권한시스템.md`
- `15_스킬.md`
### 1-2. 특징 기능
`claude-code`의 강점은 기능 개수보다 실행 품질에 있습니다.
- 대화, 도구 실행, 권한, 상태줄이 한 흐름으로 이어진다.
- transcript가 단순 채팅 로그가 아니라 실행 관측 UI 역할을 한다.
- 도구 호출이 응답 후처리가 아니라 루프 자체의 핵심 경로다.
- read-only 도구는 병렬화하고, non-concurrent 도구는 직렬화해 체감 반응성을 높인다.
- 메모리 구조가 `managed / user / project / local` 계층으로 명확하다.
- `CLAUDE.md`, `.claude/rules/*.md`, `paths:` frontmatter, `@include` 같은 규칙 기반 메모리 주입이 강하다.
- 권한 시스템이 `default / acceptEdits / plan / bypassPermissions`와 allow/deny rule로 분리되어 있다.
- skill 시스템이 단순 프롬프트 스니펫이 아니라 재사용 가능한 워크플로우 레이어다.
### 1-3. 에이전트 루프 구조
`claude-code`의 기본 루프는 다음 순서에 가깝습니다.
1. 사용자 메시지 수신
2. 시스템 컨텍스트 조립
- Git 상태
- 메모리 계층
- 현재 날짜/환경
- 사용 가능한 도구/권한 상태
3. 모델 호출
4. tool_use block 감지
5. 권한 판단
6. 도구 실행
7. tool_result를 다시 모델에 투입
8. 필요 시 추가 도구 호출
9. 최종 텍스트 응답 커밋
중요한 점은, 이 루프가 단순히 "LLM 호출 후 도구 실행"이 아니라 `도구를 전제로 설계된 루프`라는 점입니다.
### 1-4. Cowork/Code에서 결과가 잘 나오는 이유
#### 1. 도구 우선 오케스트레이션
- `toolOrchestration.ts`가 tool call을 배치로 나누고
- `StreamingToolExecutor.ts`가 스트리밍 도중에도 도구 실행 준비를 진행합니다.
- read-only/concurrency-safe 도구는 병렬 실행해 첫 반응 시간을 줄입니다.
#### 2. 실행 중 관측성이 높음
- `Messages.tsx`
- `VirtualMessageList.tsx`
- `StatusLine.tsx`
이 구조 덕분에 사용자는 "지금 무엇을 읽고, 무엇을 실행하고, 어디서 멈췄는지"를 계속 볼 수 있습니다.
긴 작업에서도 멈춘 것처럼 느껴지지 않는 이유가 여기 있습니다.
#### 3. 긴 세션을 버티는 transcript 구조
- transcript는 전체 렌더를 계속 다시 그리지 않습니다.
- `VirtualMessageList.tsx` 기준으로 가시 영역 중심 렌더와 스크롤/탐색 보조 구조가 분리되어 있습니다.
- 긴 세션, 많은 도구 호출, 많은 중간 상태에서도 UI 부하가 상대적으로 낮습니다.
#### 4. 메모리와 규칙 주입이 명시적
- `CLAUDE.md`
- `.claude/rules/*.md`
- `paths:`
- `@include`
이 구조 때문에 프로젝트/경로/사용자/관리자 규칙이 프롬프트에 일관되게 들어갑니다.
결과가 흔들릴 때도 "왜 그런 성향이 나왔는지" 추적이 쉽습니다.
### 1-5. 주요 성능과 품질에 영향을 주는 프롬프트 전략
1. 도구를 먼저 쓰게 만드는 직접적인 지시
2. 컨텍스트를 계층화해서 넣음
3. 권한과 도구 사용 범위를 명확히 분리
4. 실행 중간 상태를 계속 보여줘 재시도/복구가 자연스러움
5. read-only tool 병렬화로 초기 탐색 시간을 줄임
6. transcript/상태줄이 실제 실행 관측 수단으로 동작
## 2. AX Agent 구조, 특징 기능, 에이전트 루프
### 2-1. 구조 핵심
현재 AX Agent는 다음 축으로 구성됩니다.
1. 에이전트 실행
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`
- `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`
2. 루프 정책 분리 계층
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs`
- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs`
- `src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs`
- `src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs`
3. 메모리 구조
- `src/AxCopilot/Services/AgentMemoryService.cs`
- `src/AxCopilot/Services/Agent/MemoryTool.cs`
4. transcript/UI 구조
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptHost.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptRenderPlanner.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptRenderExecution.cs`
- `src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs`
5. 성능/검증 로그
- `src/AxCopilot/Services/AgentPerformanceLogService.cs`
### 2-2. 특징 기능
- Chat / Cowork / Code를 한 창에서 운영
- 등록 모델별 실행 프로파일 지원
- Cowork/Code 전용 agent loop 사용
- 문서 생성 계열 fallback 및 verification gate 지원
- IBM/vLLM/OpenAI 호환 tool-use 경로 대응
- 계층형 메모리 구조 지원
- `/memory` 계열 도구와 설정 UI에서 메모리 관리 가능
- transcript/진행 표시/UI 구조를 `claude-code` 기준으로 계속 분해해온 상태
### 2-3. 에이전트 루프 구조
현재 AX Agent의 루프는 대략 아래 순서입니다.
1. 실행 컨텍스트 구성
2. 사용자 메시지와 시스템 지시 조립
3. LLM 호출
4. tool call 감지
5. `StreamingToolExecutionCoordinator`를 통한 조기 도구 실행/prefetch
6. 도구 결과 재투입
7. verification/document fallback/compact 정책 수행
8. 최종 결과 커밋
9. transcript/status/perf 로그 반영
### 2-4. 현재 구조적 강점
1. 모델 프로파일 기반 제어
2. 계층형 메모리 구조
3. Cowork/Code용 별도 실행 정책
4. 성능 계측 로그
### 2-5. 현재 구조적 약점
1. `ChatWindow.xaml.cs` 책임이 여전히 큼
2. transcript가 아직 완전한 데이터 기반 가상화는 아님
3. 루프 정책이 여전히 서비스 중심
4. tool streaming executor가 독립 계층으로 강화되었지만 결합이 남음
## 3. claude-code와 AX Agent 비교
### 3-1. 전체 비교 요약
| 항목 | claude-code | AX Agent 현재 상태 |
|------|-------------|-------------------|
| 메모리 계층 | 매우 강함 | 많이 근접 |
| tool orchestration | 매우 강함 | 많이 강화됨 |
| transcript 가상화 | 강함 | 부분 근접 |
| 진행 관측성 | 강함 | 많이 근접 |
| 모델 프로파일성 | 단단함 | 프로파일 시스템 도입 완료 |
| 문서 fallback | 비교적 단순/직접적 | 더 많은 정책 보유 |
| 루프 응집도 | 모듈 분리 우수 | 개선 중, 많이 나아짐 |
### 3-2. AX Agent가 이미 따라잡은 부분
1. 메모리 계층 구조
2. 진행 표시 UX
3. 모델별 실행 성향 제어
4. 도구 호출 강화
### 3-3. AX Agent가 아직 더 개선할 수 있는 부분
1. transcript의 진짜 가상화
2. UI 렌더의 data-template화
3. tool executor의 독립성
4. Cowork/Code 실사용 로그 기반 검증 체계
### 3-4. 성능과 결과 품질에 직접 영향이 큰 구조 요소 비교
#### claude-code 쪽 핵심
- read-only tool 병렬화
- streaming executor
- transcript 가상화
- 상태줄/메시지/도구 실행 분리
- 명시적 메모리 계층
- 권한 시스템과 루프의 강한 연결
#### AX Agent 쪽 핵심
- 모델 프로파일
- document/verification/compact 정책 분리
- IBM/vLLM 도구 호출 보강
- transcript host/windowing/virtualization 보강
- WPF 환경 특화 성능 대응
### 3-5. 현 시점 결론
현 시점 AX Agent는 `claude-code` 대비 다음 상태로 보는 것이 적절합니다.
- 구조적 방향성: 거의 동일한 방향
- 메모리/도구/진행 표시: 상당 부분 근접
- transcript/렌더 구조: 아직 한 단계 차이 존재
- 장기 세션 성능: 실제 로그 기반 추가 검증 필요
즉 AX Agent는 더 이상 "기본 구조가 부족한 상태"는 아니고, 이제부터는 `실사용 검증 기반으로 남은 구조 차이를 줄이는 단계`입니다.
## 4. AX 개발 시 이 문서를 어떻게 쓸지
1. Cowork/Code 품질 이슈가 생기면 먼저 `claude-code` 기준 흐름과 비교
2. 구조 리팩터링이 필요할 때 transcript / tool executor / loop policy / memory 중 어디를 건드리는지 먼저 분류
3. 실사용 성능 검증 시 `%APPDATA%\\AxCopilot\\perf` 로그와 loop elapsed, transcript render elapsed, no-tool loop, verification/fallback/compact 비율을 함께 본다
4. 계획 수립 시 항상 참조 파일, AX 적용 위치, 완료 조건, 품질 판정 기준을 같이 적는다
+406 -5377
View File
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
# 구조적 리팩토링 개발 계획
> 작성: 2026-04-09
---
## 배경
ChatWindow의 두 가지 구조적 결함이 장시간 사용 시 메모리 누수와 UI 프레임 드롭을 유발합니다.
1. **람다 이벤트 핸들러 누적** — 동적 UI 요소에 붙인 람다 핸들러가 `Children.Clear()` 후에도 GC되지 않음
2. **스트리밍 중 인크리멘탈 렌더 실패** — prefix 키 비교가 스트리밍 상황에서 항상 실패하여 전체 재빌드로 폴백
---
## 과제 A: 람다 이벤트 핸들러 누적 해소
### 현황
26개소 이상에서 동적 UI 요소(Border, TextBlock, Button)에 람다 `+=` 핸들러를 붙이고, 컨테이너 `.Clear()` 시 핸들러를 해제하지 않음. 클로저가 캡처한 변수(UI 요소, 문자열)가 GC 대상에서 제외됨.
### 영향도별 분류
| 빈도 | 파일 | Clear 대상 | 핸들러 수/회 | 추정 누적 |
|------|------|-----------|-------------|----------|
| **높음** | `ConversationListPresentation.cs` | `ConversationPanel.Children` | 대화당 5개 × 50항목 = 250 | 탭 전환/필터마다 250개 |
| **높음** | `TopicPresetPresentation.cs` | `TopicButtonPanel.Children` | 프리셋당 3개 × 15항목 = 45 | 탭 전환마다 45개 |
| **중간** | `PermissionPresentation.cs` | `PermissionItems.Children` | 항목당 4개 × 20항목 = 80 | 권한 팝업 열 때마다 80개 |
| **중간** | `PreviewPresentation.cs` | `PreviewTabPanel.Children` | 탭당 5개 × 10탭 = 50 | 미리보기 탭 변경마다 50개 |
| **중간** | `ComposerQueuePresentation.cs` | `DraftQueuePanel.Children` | 항목당 2개 × 5항목 = 10 | 큐 갱신마다 10개 |
| **중간** | `GitBranchPresentation.cs` | `GitBranchItems.Children` | 항목당 3개 × 20항목 = 60 | 브랜치 팝업 열 때마다 60개 |
| **낮음** | `FileBrowserPresentation.cs` | `FileTreeView.Items` | 항목당 3개 × 100항목 = 300 | 트리 재구축마다 300개 |
### 설계 방침
**이벤트 위임(Event Delegation) 패턴** 적용 — 개별 자식 요소에 핸들러를 붙이지 않고, 부모 컨테이너에 단일 핸들러를 두고 `e.Source`/`e.OriginalSource`로 분기.
### 단계별 구현 계획
#### Phase 1: 고빈도 대상 (추정 공수: 2시간)
**1-1. ConversationListPresentation.cs**
현재:
```csharp
// 대화 항목마다 5개 람다 핸들러
border.MouseEnter += (s, _) => { ... };
border.MouseLeave += (s, _) => { ... };
border.MouseLeftButtonDown += (s, _) => { ... };
border.MouseRightButtonUp += (s, _) => { ... };
border.MouseLeftButtonUp += (s, _) => { ... };
ConversationPanel.Children.Add(border);
```
변경 전략:
```csharp
// ConversationPanel에 단일 핸들러 (생성자에서 1회 등록)
ConversationPanel.MouseEnter += ConversationPanel_MouseEnter;
ConversationPanel.MouseLeave += ConversationPanel_MouseLeave;
ConversationPanel.MouseLeftButtonDown += ConversationPanel_MouseLeftButtonDown;
// ...
private void ConversationPanel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// e.OriginalSource에서 가장 가까운 Border를 찾아 DataContext의 ConversationMeta로 분기
if (FindAncestor<Border>(e.OriginalSource as DependencyObject) is { Tag: string conversationId })
HandleConversationClick(conversationId);
}
```
핵심 변경:
- 각 Border의 `Tag` 속성에 `conversationId` 저장
- `ConversationPanel`에 이벤트 5개를 생성자에서 1회만 등록
- `AddConversationItem()`에서 람다 핸들러 제거
- hover 효과는 `ConversationPanel_MouseMove`에서 `VisualTreeHelper.HitTest`로 처리
**1-2. TopicPresetPresentation.cs**
동일 패턴 적용:
- `TopicButtonPanel``PreviewMouseLeftButtonDown`, `MouseEnter`, `MouseLeave` 3개만 등록
- 각 프리셋 카드의 `Tag`에 프리셋 ID 저장
- `AttachTopicCardHover` 메서드를 제거하고 부모 위임으로 대체
#### Phase 2: 중빈도 대상 (추정 공수: 2시간)
**2-1. PermissionPresentation.cs** — 동일 패턴
**2-2. PreviewPresentation.cs** — 탭 닫기 버튼만 주의 (부모 위임 + `Tag` 분기)
**2-3. ComposerQueuePresentation.cs** — 큐 항목 적음, 간단
**2-4. GitBranchPresentation.cs**`CreateFlatPopupRow()` 내부 핸들러를 부모 위임으로 전환
#### Phase 3: 저빈도 + 공통 유틸 (추정 공수: 1시간)
**3-1. FileBrowserPresentation.cs** — TreeViewItem은 WPF 내부 이벤트 라우팅이 복잡하므로, Clear 전에 명시적 해제 방식 적용:
```csharp
private void DetachFileTreeHandlers(ItemCollection items)
{
foreach (var item in items.OfType<TreeViewItem>())
{
item.Expanded -= FileTreeItem_Expanded;
item.MouseDoubleClick -= FileTreeItem_DoubleClick;
item.MouseRightButtonUp -= FileTreeItem_RightClick;
DetachFileTreeHandlers(item.Items); // 재귀
}
}
// BuildFileTree() 시작 시 DetachFileTreeHandlers(FileTreeView.Items) 호출
```
**3-2. 공통 헬퍼 추가**
```csharp
// ChatWindow.VisualInteractionHelpers.cs에 추가
private static T? FindAncestorWithTag<T>(DependencyObject? source) where T : FrameworkElement
{
while (source != null)
{
if (source is T fe && fe.Tag != null)
return fe;
source = VisualTreeHelper.GetParent(source);
}
return null;
}
```
### 검증 방법
1. 수정 전/후 Visual Studio Memory Profiler로 GC Gen2 객체 수 비교
2. 대화 탭 100회 전환 후 핸들러 참조 수 변화 측정
3. UI 기능 회귀 테스트 (hover 효과, 클릭, 우클릭 메뉴)
---
## 과제 B: 스트리밍 인크리멘탈 렌더 실패 해소
### 현황
`TryApplyIncrementalTranscriptRender`는 이론적으로 새 항목만 추가/교체하지만, 스트리밍 중 3가지 이유로 항상 실패하여 `ApplyFullTranscriptRender`로 폴백합니다:
| 실패 원인 | 위치 | 설명 |
|----------|------|------|
| `hiddenCount` 불일치 | `BuildTranscriptRenderPlan` L39 | 스트리밍 시 `GetActiveTimelineRenderLimit()`가 다른 값 반환 → `_lastRenderedHiddenCount != hiddenCount` |
| prefix 키 불일치 | `TryApplyIncrementalTranscriptRender` L13 | hiddenCount 변화로 visible 범위가 밀려서 첫 번째 키가 달라짐 |
| `_agentLiveContainer` 존재 | `BuildTranscriptRenderPlan` L36 | 라이브 카드가 transcript에 삽입되면 `hasExternalChildren=true``canIncremental=false` |
### 현재 렌더 흐름
```
RenderMessages(preserveViewport: true) — 350ms/2200ms 타이머
→ BuildTranscriptRenderPlan()
→ GetActiveTimelineRenderLimit() → 스트리밍이면 더 작은 값
→ hiddenCount 계산 → 매번 다름 → canIncremental=false
→ TryApplyIncrementalTranscriptRender() → 실패
→ ApplyFullTranscriptRender() → ClearTranscriptElements() + 전체 재구축
```
### 설계 방침
**스트리밍 전용 경량 업데이트 경로** 도입 — 스트리밍 중에는 전체 타임라인을 재구축하지 않고, 마지막 메시지 영역만 갱신.
### 단계별 구현 계획
#### Phase 1: hiddenCount 안정화 (추정 공수: 30분)
**문제**: 스트리밍 중 `GetActiveTimelineRenderLimit()`가 더 작은 값을 반환하여 hiddenCount가 변동.
**수정**: 스트리밍 중에는 `_lastRenderedHiddenCount`를 고정하여 비교 안정화.
```csharp
// BuildTranscriptRenderPlan 수정
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
// 스트리밍 중에는 hiddenCount가 증가만 허용 (줄어들지 않음)
// → 이미 렌더링된 stable 항목을 숨기지 않아 prefix 불일치 방지
if (_isStreaming && hiddenCount < _lastRenderedHiddenCount)
hiddenCount = _lastRenderedHiddenCount;
```
#### Phase 2: _agentLiveContainer 분리 (추정 공수: 1시간)
**문제**: 라이브 진행 카드(`_agentLiveContainer`)가 transcript 패널에 삽입되면 `hasExternalChildren=true` → 인크리멘탈 불가.
**수정**: 라이브 카드를 transcript 패널 밖의 별도 오버레이로 분리.
```xml
<!-- ChatWindow.xaml 수정 -->
<Grid>
<!-- 기존 transcript -->
<ScrollViewer x:Name="TranscriptScroll">
<StackPanel x:Name="TranscriptPanel" />
</ScrollViewer>
<!-- 라이브 진행 카드 — transcript 밖 하단 오버레이 -->
<Border x:Name="AgentLiveOverlay"
VerticalAlignment="Bottom"
Margin="0,0,0,8" />
</Grid>
```
변경 사항:
- `_agentLiveContainer``AgentLiveOverlay.Child`로 설정 (transcript 패널에서 분리)
- `ApplyFullTranscriptRender`에서 `_agentLiveContainer` 삽입 코드 제거
- `hasExternalChildren` 체크 불필요 → `canIncremental` 조건 단순화
- 스크롤 위치 조정 로직은 오버레이 높이를 반영
#### Phase 3: 스트리밍 전용 append-only 경로 (추정 공수: 1.5시간)
**목표**: 스트리밍 중에는 `TryApplyIncrementalTranscriptRender`의 prefix 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용.
```csharp
// TranscriptRenderExecution.cs에 추가
private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan)
{
// 스트리밍 전용: prefix 비교 대신 stable 키 집합의 부분집합 관계만 확인
if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0)
return false;
// stable 키가 변하지 않았는지 확인 (순서 무관, 존재 여부만)
var lastStableKeys = new HashSet<string>(
_lastRenderedTimelineKeys.Where(k => !k.StartsWith("_live_")),
StringComparer.Ordinal);
var newStableKeys = renderPlan.NewKeys
.Where(k => !k.StartsWith("_live_"))
.ToList();
// 새 stable 키가 기존의 superset이면 append 가능
if (!lastStableKeys.IsSubsetOf(newStableKeys))
return false;
// 1. 기존 live 항목 제거
for (var i = 0; i < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; i++)
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
// 2. 새로 추가된 stable 항목 렌더
foreach (var item in renderPlan.VisibleTimeline)
{
if (!lastStableKeys.Contains(item.Key))
item.Render();
}
// 3. 새 live 항목 렌더
foreach (var item in renderPlan.VisibleTimeline.Where(
t => t.Key.StartsWith("_live_")))
item.Render();
_lastRenderedTimelineKeys = renderPlan.NewKeys;
_lastRenderedHiddenCount = renderPlan.HiddenCount;
return true;
}
```
호출 순서 수정 (TranscriptRendering.cs):
```csharp
if (!TryApplyStreamingAppendRender(renderPlan) // 스트리밍 전용 빠른 경로
&& !TryApplyIncrementalTranscriptRender(renderPlan) // 일반 인크리멘탈
)
ApplyFullTranscriptRender(renderPlan); // 최후 수단
```
#### Phase 4: 비가시 상태 렌더 차단 (추정 공수: 15분)
```csharp
// RenderMessages 최상단에 추가
if (WindowState == WindowState.Minimized || !IsVisible)
return;
```
### 검증 방법
1. 스트리밍 중 `ApplyFullTranscriptRender` 호출 횟수를 성능 로그로 측정 (수정 전 vs 후)
2. 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit)
3. 50개 이상 메시지가 있는 대화에서 스트리밍 응답 시 스크롤 버벅임 확인
---
## 실행 계획 요약
| 단계 | 과제 | 작업 | 공수 | 우선순위 |
|------|------|------|------|---------|
| A-1 | 핸들러 | ConversationList + TopicPreset 이벤트 위임 | 2h | P1 | ✅ 완료 (2026-04-09) |
| B-1 | 렌더 | hiddenCount 안정화 | 30m | P1 | ✅ 완료 (2026-04-09) |
| B-4 | 렌더 | 비가시 상태 렌더 차단 | 15m | P1 | ✅ 완료 (2026-04-09) |
| B-2 | 렌더 | _agentLiveContainer 인크리멘탈 허용 | 1h | P2 | ✅ 완료 (2026-04-09) |
| B-3 | 렌더 | 스트리밍 append-only 경로 | 1.5h | P2 | ✅ 완료 (2026-04-09) |
| A-2 | 핸들러 | Permission + Preview + GitBranch 위임 | 2h | P2 | ✅ 완료 (2026-04-09) |
| A-3 | 핸들러 | FileBrowser 명시적 해제 + 공통 헬퍼 | 1h | P3 | ✅ 완료 (2026-04-09) |
**총 예상 공수**: ~8시간
### 위험 요소
| 위험 | 영향 | 대응 |
|------|------|------|
| 이벤트 위임 시 hover 효과 깨짐 | UI 품질 저하 | 기존 CSS-style 직접 설정 대신 VisualStateManager 활용 |
| 인크리멘탈 렌더 중 DOM 불일치 | 렌더링 오류 | try/catch + 전체 재빌드 폴백 유지 |
| 라이브 카드 오버레이 분리 시 스크롤 동기화 | 라이브 카드가 transcript와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |
+16 -16
View File
@@ -226,7 +226,7 @@ public static class AgentHookRunner
var structured = false;
if (root.TryGetProperty("updatedInput", out var inputProp) &&
if (root.SafeTryGetProperty("updatedInput", out var inputProp) &&
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
updatedInput = inputProp.Clone();
@@ -245,10 +245,10 @@ public static class AgentHookRunner
structured = true;
}
if (root.TryGetProperty("message", out var msgProp) &&
if (root.SafeTryGetProperty("message", out var msgProp) &&
msgProp.ValueKind == JsonValueKind.String)
{
var msg = msgProp.GetString();
var msg = msgProp.SafeGetString();
if (!string.IsNullOrWhiteSpace(msg))
message = msg.Trim();
}
@@ -281,8 +281,8 @@ public static class AgentHookRunner
updatedPermissions = null;
JsonElement permProp;
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|| root.TryGetProperty("permissionUpdates", out permProp)))
if (!(root.SafeTryGetProperty("updatedPermissions", out permProp)
|| root.SafeTryGetProperty("permissionUpdates", out permProp)))
return false;
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -301,14 +301,14 @@ public static class AgentHookRunner
if (entry.ValueKind != JsonValueKind.Object)
continue;
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
if (!entry.SafeTryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
continue;
var tool = toolProp.GetString()?.Trim();
var tool = toolProp.SafeGetString()?.Trim();
if (string.IsNullOrWhiteSpace(tool))
continue;
if (entry.TryGetProperty("permission", out var permValue) &&
if (entry.SafeTryGetProperty("permission", out var permValue) &&
TryExtractPermissionValue(permValue, out var normalized))
{
map[tool] = normalized;
@@ -329,7 +329,7 @@ public static class AgentHookRunner
if (permissionElement.ValueKind == JsonValueKind.String)
{
var text = permissionElement.GetString();
var text = permissionElement.SafeGetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
@@ -337,10 +337,10 @@ public static class AgentHookRunner
}
}
else if (permissionElement.ValueKind == JsonValueKind.Object &&
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
permissionElement.SafeTryGetProperty("permission", out var nestedPermission) &&
nestedPermission.ValueKind == JsonValueKind.String)
{
var text = nestedPermission.GetString();
var text = nestedPermission.SafeGetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
@@ -354,12 +354,12 @@ public static class AgentHookRunner
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
{
additionalContext = null;
if (!root.TryGetProperty("additionalContext", out var ctxProp))
if (!root.SafeTryGetProperty("additionalContext", out var ctxProp))
return false;
if (ctxProp.ValueKind == JsonValueKind.String)
{
var text = ctxProp.GetString()?.Trim();
var text = ctxProp.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
{
additionalContext = text;
@@ -376,7 +376,7 @@ public static class AgentHookRunner
if (part.ValueKind != JsonValueKind.String)
continue;
var text = part.GetString()?.Trim();
var text = part.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
chunks.Add(text);
}
@@ -394,10 +394,10 @@ public static class AgentHookRunner
// 우선순위: message > content > text
foreach (var key in new[] { "message", "content", "text" })
{
if (!ctxProp.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
if (!ctxProp.SafeTryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
continue;
var text = value.GetString()?.Trim();
var text = value.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
{
additionalContext = text;
@@ -0,0 +1,116 @@
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState)
{
runState.PendingPostCompactionTurn = true;
runState.PostCompactionTurnCounter = 0;
runState.LastCompactionStageSummary = result.StageSummary;
runState.LastCompactionSavedTokens = result.SavedTokens;
SyncRunPostCompactionState(runState);
}
private void NotifyPostCompactionTurnIfNeeded(RunState runState)
{
if (!runState.PendingPostCompactionTurn)
return;
runState.PendingPostCompactionTurn = false;
runState.PostCompactionTurnCounter++;
SyncRunPostCompactionState(runState);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "기본"
: runState.LastCompactionStageSummary;
var saved = runState.LastCompactionSavedTokens > 0
? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감"
: "";
EmitEvent(
AgentEventType.Thinking,
"",
$"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}");
}
private void SyncRunPostCompactionState(RunState runState)
{
_runPendingPostCompactionTurn = runState.PendingPostCompactionTurn;
_runPostCompactionTurnCounter = runState.PostCompactionTurnCounter;
_runLastCompactionStageSummary = runState.LastCompactionStageSummary ?? "";
_runLastCompactionSavedTokens = runState.LastCompactionSavedTokens;
_runPostCompactionToolResultCompactions = runState.PostCompactionToolResultCompactions;
}
private bool ShouldSuppressPostCompactionThinking(string summary)
{
if (string.IsNullOrWhiteSpace(summary))
return false;
var postCompactActive = _runPendingPostCompactionTurn || _runPostCompactionTurnCounter > 0;
if (!postCompactActive)
return false;
if (_runPostCompactionTurnCounter > 1)
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.Ordinal)
|| summary.StartsWith("사용자 프롬프트 제출", StringComparison.Ordinal)
|| summary.StartsWith("무료 티어 모드", StringComparison.Ordinal)
|| summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal);
}
private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState)
{
var output = result.Output ?? "";
if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output))
return TruncateOutput(output, 4000);
runState.PostCompactionToolResultCompactions++;
SyncRunPostCompactionState(runState);
var compacted = CompactToolResultForPostCompaction(call.ToolName, output);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "compact"
: runState.LastCompactionStageSummary;
return $"[POST_COMPACTION_TOOL_RESULT:{call.ToolName}] {stage}\n{compacted}";
}
private static bool ShouldCompactToolResultForPostCompactionTurn(RunState runState, string toolName, string output)
{
if (runState.PostCompactionTurnCounter != 1)
return false;
if (string.IsNullOrWhiteSpace(output))
return false;
if (output.Length < 900)
return false;
return toolName is "process" or "build_run" or "test_loop" or "snippet_runner" or "git_tool" or "http_tool"
|| output.Length > 1800;
}
private static string CompactToolResultForPostCompaction(string toolName, string output)
{
var normalized = output.Replace("\r\n", "\n");
var lines = normalized.Split('\n', StringSplitOptions.None)
.Select(line => line.TrimEnd())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (lines.Count == 0)
return TruncateOutput(output, 1200);
var headCount = toolName is "build_run" or "test_loop" or "process" ? 6 : 4;
var tailCount = toolName is "build_run" or "test_loop" or "process" ? 4 : 3;
var kept = new List<string>();
kept.AddRange(lines.Take(headCount));
if (lines.Count > headCount + tailCount)
kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]...");
if (lines.Count > headCount)
kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount)));
var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal));
return TruncateOutput(compacted, 1400);
}
}
@@ -0,0 +1,154 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private enum ExplorationScope
{
Localized,
TopicBased,
RepoWide,
OpenEnded,
}
private sealed class ExplorationTrackingState
{
public ExplorationScope Scope { get; init; }
public int FolderMapCalls { get; set; }
public int TotalFilesRead { get; set; }
public int MultiReadFilesRead { get; set; }
public bool BroadScanDetected { get; set; }
public bool SelectiveHit { get; set; }
public bool CorrectiveHintInjected { get; set; }
}
private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab)
{
if (string.IsNullOrWhiteSpace(userQuery))
return ExplorationScope.OpenEnded;
var q = userQuery.Trim();
var lower = q.ToLowerInvariant();
if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") ||
lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") ||
lower.Contains("아키텍처") || lower.Contains("전체 점검"))
return ExplorationScope.RepoWide;
if (q.Contains('.') || q.Contains('/') || q.Contains('\\') ||
lower.Contains("file ") || lower.Contains("class ") || lower.Contains("method ") ||
lower.Contains("function ") || lower.Contains("line ") || lower.Contains("bug") ||
lower.Contains("오류") || lower.Contains("버그") || lower.Contains("예외"))
return ExplorationScope.Localized;
if (lower.Contains("정리") || lower.Contains("요약") || lower.Contains("보고서") ||
lower.Contains("주제") || lower.Contains("관련") || lower.Contains("분석"))
return ExplorationScope.TopicBased;
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? ExplorationScope.Localized
: ExplorationScope.OpenEnded;
}
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
{
var guidance = scope switch
{
ExplorationScope.Localized =>
"Exploration scope = localized. Start with grep/glob and targeted file reads. Avoid folder_map unless structure is unclear.",
ExplorationScope.TopicBased =>
"Exploration scope = topic-based. Identify candidate files by topic keywords first, then read only a small targeted set.",
ExplorationScope.RepoWide =>
"Exploration scope = repo-wide. Broad structure inspection is allowed when needed.",
_ =>
"Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans."
};
messages.Add(new ChatMessage
{
Role = "system",
Content = guidance
});
}
private static int CountMultiReadPaths(string argsJson)
{
try
{
using var doc = JsonDocument.Parse(argsJson);
if (doc.RootElement.TryGetProperty("paths", out var pathsEl) && pathsEl.ValueKind == JsonValueKind.Array)
return pathsEl.GetArrayLength();
}
catch
{
}
return 0;
}
private static bool ShouldInjectExplorationCorrection(
ExplorationTrackingState state,
string toolName,
string argsJson)
{
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return false;
if (state.FolderMapCalls >= 2)
return true;
if (state.MultiReadFilesRead >= 6 || state.TotalFilesRead >= 8)
return true;
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
{
try
{
using var doc = JsonDocument.Parse(argsJson);
var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesEl) &&
includeFilesEl.ValueKind is JsonValueKind.True or JsonValueKind.False &&
includeFilesEl.GetBoolean();
var depth = doc.RootElement.TryGetProperty("depth", out var depthEl) && depthEl.ValueKind == JsonValueKind.Number
? depthEl.GetInt32()
: 2;
if (includeFiles || depth >= 3)
return true;
}
catch
{
}
}
return false;
}
private static void TrackExplorationToolUse(
ExplorationTrackingState state,
string toolName,
string argsJson)
{
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
{
state.FolderMapCalls++;
return;
}
if (string.Equals(toolName, "multi_read", StringComparison.OrdinalIgnoreCase))
{
var count = CountMultiReadPaths(argsJson);
state.MultiReadFilesRead += count;
state.TotalFilesRead += count;
if (count >= 6)
state.BroadScanDetected = true;
return;
}
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) ||
string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase))
{
state.TotalFilesRead++;
}
}
}
@@ -0,0 +1,52 @@
namespace AxCopilot.Services.Agent;
internal static class AgentLoopRuntimeThresholds
{
public static int GetNoToolCallResponseThreshold(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
defaultValue,
min: 1,
max: 6);
public static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
defaultValue,
min: 0,
max: 6);
public static int GetPlanExecutionRetryMax(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
defaultValue,
min: 0,
max: 6);
public static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
defaultValue,
min: 0,
max: 3);
public static int ResolveNoToolCallResponseThreshold(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
public static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
public static int ResolvePlanExecutionRetryMax(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
public static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
private static int ResolveThresholdValue(string? raw, int defaultValue, int min, int max)
{
if (!int.TryParse(raw, out var value))
return defaultValue;
return Math.Clamp(value, min, max);
}
}
+172 -216
View File
@@ -26,6 +26,14 @@ public partial class AgentLoopService
private readonly LlmService _llm;
private readonly ToolRegistry _tools;
private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
// P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지
private static readonly JsonSerializerOptions s_jsonOpts = new()
{
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
@@ -79,6 +87,21 @@ public partial class AgentLoopService
_llm = llm;
_tools = tools;
_settings = settings;
_toolExecutionCoordinator = new StreamingToolExecutionCoordinator(
_llm,
ResolveRequestedToolName,
async (toolName, input, context, messages, ct) =>
{
var tool = _tools.Get(toolName);
return tool == null
? ToolResult.Fail($"도구를 찾을 수 없습니다: {toolName}")
: await ExecuteToolWithTimeoutAsync(tool, toolName, input, context, messages, ct);
},
(eventType, toolName, summary) => EmitEvent(eventType, toolName, summary),
IsContextOverflowError,
ForceContextRecovery,
IsTransientLlmError,
ComputeTransientLlmBackoffDelayMs);
}
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
@@ -140,6 +163,7 @@ public partial class AgentLoopService
{
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
var runStopwatch = Stopwatch.StartNew();
IsRunning = true;
_currentRunId = Guid.NewGuid().ToString("N");
_docFallbackAttempted = false;
@@ -151,6 +175,11 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
var explorationState = new ExplorationTrackingState
{
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
SelectiveHit = true,
};
// 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
@@ -194,11 +223,11 @@ public partial class AgentLoopService
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = _llm.GetActiveExecutionPolicy();
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
var planExecutionRetryMax = AgentLoopRuntimeThresholds.GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
var terminalEvidenceGateRetryMax = AgentLoopRuntimeThresholds.GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var runState = new RunState();
var requireHighImpactCodeVerification = false;
@@ -212,6 +241,17 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
EmitEvent(
AgentEventType.Thinking,
"",
explorationState.Scope switch
{
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
_ => "점진 탐색 · 필요한 범위부터 확인하는 중",
});
if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
@@ -513,12 +553,15 @@ public partial class AgentLoopService
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
// LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew();
try
{
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
var activeTools = cachedActiveTools;
if (activeTools.Count == 0)
{
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
@@ -797,7 +840,7 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var activeToolPreview = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -840,7 +883,7 @@ public partial class AgentLoopService
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var planToolList = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -1041,7 +1084,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = textResponse });
foreach (var tc in toolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
@@ -1119,15 +1162,16 @@ public partial class AgentLoopService
if (toolCalls.Count == 0) continue;
}
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
var activeToolNames = cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var call in toolCalls)
{
if (ct.IsCancellationRequested) break;
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var globallyRegisteredTool = _tools.Get(resolvedToolName);
if (globallyRegisteredTool != null &&
@@ -1288,6 +1332,28 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
FormatToolCallSummary(effectiveCall));
var toolInputJson = effectiveCall.ToolInput?.ToString() ?? "{}";
TrackExplorationToolUse(explorationState, effectiveCall.ToolName, toolInputJson);
if (!explorationState.CorrectiveHintInjected &&
ShouldInjectExplorationCorrection(explorationState, effectiveCall.ToolName, toolInputJson))
{
explorationState.CorrectiveHintInjected = true;
explorationState.BroadScanDetected = true;
explorationState.SelectiveHit = false;
messages.Add(new ChatMessage
{
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. " +
"Do not repeat folder_map unless repository structure is genuinely required."
});
EmitEvent(
AgentEventType.Thinking,
"",
"탐색 범위를 좁히는 중 · 관련 파일만 다시 선택합니다");
}
var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages);
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
return decisionTransition.TerminalResponse;
@@ -1395,7 +1461,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }, s_jsonOpts),
"cancelled",
success: false);
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
@@ -1596,7 +1662,8 @@ public partial class AgentLoopService
llm,
executionPolicy,
context,
ct);
ct,
documentPlanCalled);
if (terminalCompleted)
{
if (consumedExtraIteration)
@@ -1647,7 +1714,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }, s_jsonOpts),
"cancelled",
success: false);
}
@@ -1682,6 +1749,20 @@ public partial class AgentLoopService
// 통계 기록 (도구 호출이 1회 이상인 세션만)
if (totalToolCalls > 0)
{
AgentPerformanceLogService.LogExplorationBreadth(
_conversationId,
ActiveTab,
new
{
scope = explorationState.Scope.ToString().ToLowerInvariant(),
folder_map_calls = explorationState.FolderMapCalls,
total_files_read = explorationState.TotalFilesRead,
multi_read_files = explorationState.MultiReadFilesRead,
broad_scan = explorationState.BroadScanDetected,
selective_hit = explorationState.SelectiveHit,
corrective_hint = explorationState.CorrectiveHintInjected
});
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
@@ -1725,6 +1806,30 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
}
}
runStopwatch.Stop();
AgentPerformanceLogService.LogMetric(
"agent_loop",
"run_summary",
_conversationId,
ActiveTab ?? "",
runStopwatch.ElapsedMilliseconds,
new
{
runId = _currentRunId,
iterations = iteration,
toolCalls = totalToolCalls,
toolSuccess = statsSuccessCount,
toolFail = statsFailCount,
inputTokens = statsInputTokens,
outputTokens = statsOutputTokens,
repeatedFailureBlocks = statsRepeatedFailureBlocks,
recoveredAfterFailure = statsRecoveredAfterFailure,
postCompactionNoiseSuppressed = _runPostCompactionSuppressedThinkingCount,
postCompactionToolResultCompactions = _runPostCompactionToolResultCompactions,
taskType = taskPolicy.TaskType,
model = _settings.Settings.Llm.Model ?? "",
});
}
}
@@ -2944,11 +3049,11 @@ public partial class AgentLoopService
try
{
using var doc = JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameProp))
if (doc.RootElement.SafeTryGetProperty("tool_name", out var toolNameProp))
{
toolName = toolNameProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("content", out var contentProp))
content = contentProp.GetString() ?? "";
toolName = toolNameProp.SafeGetString() ?? "";
if (doc.RootElement.SafeTryGetProperty("content", out var contentProp))
content = contentProp.SafeGetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
}
@@ -3675,58 +3780,6 @@ public partial class AgentLoopService
.Select(x => $"{x.Key}({x.Value})"));
}
private static int GetNoToolCallResponseThreshold()
=> GetNoToolCallResponseThreshold(defaultValue: 2);
private static int GetNoToolCallResponseThreshold(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
defaultValue,
min: 1,
max: 6);
private static int GetNoToolCallRecoveryMaxRetries()
=> GetNoToolCallRecoveryMaxRetries(defaultValue: 3);
private static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
defaultValue,
min: 0,
max: 6);
private static int GetPlanExecutionRetryMax()
=> GetPlanExecutionRetryMax(defaultValue: 2);
private static int GetPlanExecutionRetryMax(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
defaultValue,
min: 0,
max: 6);
private static int ResolveNoToolCallResponseThreshold(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
private static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
private static int ResolvePlanExecutionRetryMax(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
private static int GetTerminalEvidenceGateMaxRetries()
=> GetTerminalEvidenceGateMaxRetries(defaultValue: 1);
private static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
defaultValue,
min: 0,
max: 3);
private static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
private static string BuildUnknownToolRecoveryPrompt(
string unknownToolName,
IReadOnlyCollection<string> activeToolNames)
@@ -4103,7 +4156,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = verifyResponse });
foreach (var tc in verifyToolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
messages.Add(assistantMsg);
addedMessages.Add(assistantMsg);
@@ -4264,9 +4317,9 @@ public partial class AgentLoopService
{
foreach (var name in names)
{
if (inputElement.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
if (inputElement.SafeTryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
var value = prop.GetString();
var value = prop.SafeGetString();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
@@ -4308,8 +4361,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_write"))
{
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString() ?? ""
var content = input.SafeTryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.SafeGetString() ?? ""
: "";
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
string? previous = null;
@@ -4335,17 +4388,17 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_edit"))
{
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
if (input.SafeTryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
{
var lines = edits.EnumerateArray()
.Take(6)
.Select((edit, index) =>
{
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.GetString() ?? ""
var oldText = edit.SafeTryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.SafeGetString() ?? ""
: "";
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.GetString() ?? ""
var newText = edit.SafeTryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.SafeGetString() ?? ""
: "";
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
@@ -4363,8 +4416,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
{
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.GetString() ?? target
var command = input.SafeTryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "command",
@@ -4375,8 +4428,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
{
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.GetString() ?? target
var url = input.SafeTryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "web",
@@ -4647,126 +4700,29 @@ public partial class AgentLoopService
};
if (Dispatcher != null)
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
{
Dispatcher(() =>
{
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
EventOccurred?.Invoke(evt);
});
}
else
{
Events.Add(evt);
// Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
lock (Events)
{
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
}
EventOccurred?.Invoke(evt);
}
}
private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState)
{
runState.PendingPostCompactionTurn = true;
runState.PostCompactionTurnCounter = 0;
runState.LastCompactionStageSummary = result.StageSummary;
runState.LastCompactionSavedTokens = result.SavedTokens;
SyncRunPostCompactionState(runState);
}
private void NotifyPostCompactionTurnIfNeeded(RunState runState)
{
if (!runState.PendingPostCompactionTurn)
return;
runState.PendingPostCompactionTurn = false;
runState.PostCompactionTurnCounter++;
SyncRunPostCompactionState(runState);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "기본"
: runState.LastCompactionStageSummary;
var saved = runState.LastCompactionSavedTokens > 0
? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감"
: "";
EmitEvent(
AgentEventType.Thinking,
"",
$"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}");
}
private void SyncRunPostCompactionState(RunState runState)
{
_runPendingPostCompactionTurn = runState.PendingPostCompactionTurn;
_runPostCompactionTurnCounter = runState.PostCompactionTurnCounter;
_runLastCompactionStageSummary = runState.LastCompactionStageSummary ?? "";
_runLastCompactionSavedTokens = runState.LastCompactionSavedTokens;
_runPostCompactionToolResultCompactions = runState.PostCompactionToolResultCompactions;
}
private bool ShouldSuppressPostCompactionThinking(string summary)
{
if (string.IsNullOrWhiteSpace(summary))
return false;
var postCompactActive = _runPendingPostCompactionTurn || _runPostCompactionTurnCounter > 0;
if (!postCompactActive)
return false;
if (_runPostCompactionTurnCounter > 1)
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.Ordinal)
|| summary.StartsWith("사용자 프롬프트 제출", StringComparison.Ordinal)
|| summary.StartsWith("무료 티어 모드", StringComparison.Ordinal)
|| summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal);
}
private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState)
{
var output = result.Output ?? "";
if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output))
return TruncateOutput(output, 4000);
runState.PostCompactionToolResultCompactions++;
SyncRunPostCompactionState(runState);
var compacted = CompactToolResultForPostCompaction(call.ToolName, output);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "compact"
: runState.LastCompactionStageSummary;
return $"[POST_COMPACTION_TOOL_RESULT:{call.ToolName}] {stage}\n{compacted}";
}
private static bool ShouldCompactToolResultForPostCompactionTurn(RunState runState, string toolName, string output)
{
if (runState.PostCompactionTurnCounter != 1)
return false;
if (string.IsNullOrWhiteSpace(output))
return false;
if (output.Length < 900)
return false;
return toolName is "process" or "build_run" or "test_loop" or "snippet_runner" or "git_tool" or "http_tool"
|| output.Length > 1800;
}
private static string CompactToolResultForPostCompaction(string toolName, string output)
{
var normalized = output.Replace("\r\n", "\n");
var lines = normalized.Split('\n', StringSplitOptions.None)
.Select(line => line.TrimEnd())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (lines.Count == 0)
return TruncateOutput(output, 1200);
var headCount = toolName is "build_run" or "test_loop" or "process" ? 6 : 4;
var tailCount = toolName is "build_run" or "test_loop" or "process" ? 4 : 3;
var kept = new List<string>();
kept.AddRange(lines.Take(headCount));
if (lines.Count > headCount + tailCount)
kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]...");
if (lines.Count > headCount)
kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount)));
var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal));
return TruncateOutput(compacted, 1400);
}
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
{
@@ -4778,10 +4734,10 @@ public partial class AgentLoopService
// Git 커밋 — 수준에 관계없이 무조건 확인
if (toolName == "git_tool")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
if (action == "commit")
{
var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : "";
var msg = input?.SafeTryGetProperty("args", out var m) == true ? m.SafeGetString() : "";
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
}
}
@@ -4792,7 +4748,7 @@ public partial class AgentLoopService
// process 도구 (외부 명령 실행)
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
return null;
@@ -4804,14 +4760,14 @@ public partial class AgentLoopService
// 외부 명령 실행
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
// 새 파일 생성
if (toolName == "file_write")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
if (!string.IsNullOrEmpty(path))
{
var fullPath = System.IO.Path.IsPathRooted(path) ? path
@@ -4829,15 +4785,15 @@ public partial class AgentLoopService
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return null;
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
var path = (input?.SafeTryGetProperty("path", out var p1) == true ? p1.SafeGetString() : null)
?? (input?.SafeTryGetProperty("file_path", out var p2) == true ? p2.SafeGetString() : "");
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
// 빌드/테스트 실행
if (toolName is "build_run" or "test_loop")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
}
}
@@ -4847,7 +4803,7 @@ public partial class AgentLoopService
{
if (toolName is "file_write" or "file_edit")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
}
}
@@ -4862,12 +4818,12 @@ public partial class AgentLoopService
{
// 주요 파라미터만 표시
var input = call.ToolInput.Value;
if (input.TryGetProperty("path", out var path))
return $"{call.ToolName}: {path.GetString()}";
if (input.TryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.GetString()}";
if (input.TryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.GetString()}";
if (input.SafeTryGetProperty("path", out var path))
return $"{call.ToolName}: {path.SafeGetString()}";
if (input.SafeTryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.SafeGetString()}";
if (input.SafeTryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.SafeGetString()}";
return call.ToolName;
}
catch { return call.ToolName; }
@@ -0,0 +1,162 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private void ApplyDocumentPlanSuccessTransitions(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
ref bool documentPlanCalled,
ref string? documentPlanPath,
ref string? documentPlanTitle,
ref string? documentPlanScaffold)
{
if (!string.Equals(call.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase))
return;
documentPlanCalled = true;
var po = result.Output ?? string.Empty;
var pm = System.Text.RegularExpressions.Regex.Match(po, @"path:\s*""([^""]+)""");
if (pm.Success) documentPlanPath = pm.Groups[1].Value;
var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)""");
if (tm.Success) documentPlanTitle = tm.Groups[1].Value;
documentPlanScaffold = ExtractDocumentPlanScaffold(po);
if (!ContainsDocumentPlanFollowUpInstruction(po))
return;
var toolHint = ResolveDocumentPlanFollowUpTool(po);
messages.Add(new ChatMessage
{
Role = "user",
Content =
"document_plan이 완료되었습니다. " +
"방금 생성된 골격의 [내용...] 자리와 각 섹션 내용을 실제 상세 본문으로 모두 채운 뒤 " +
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
"설명만 하지 말고 실제 문서 생성 도구 호출로 바로 이어가세요."
});
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완료 · {toolHint} 실행 유도");
}
private static string? ExtractDocumentPlanScaffold(string output)
{
if (string.IsNullOrWhiteSpace(output))
return null;
var markers = new (string Start, string End)[]
{
("--- body 시작 ---", "--- body 끝 ---"),
("--- body start ---", "--- body end ---"),
("<!-- body start marker -->", "<!-- body end marker -->"),
};
foreach (var (startMarker, endMarker) in markers)
{
var start = output.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase);
if (start < 0)
continue;
var contentStart = start + startMarker.Length;
var end = output.IndexOf(endMarker, contentStart, StringComparison.OrdinalIgnoreCase);
if (end <= contentStart)
continue;
var scaffold = output[contentStart..end].Trim();
if (!string.IsNullOrWhiteSpace(scaffold))
return scaffold;
}
return null;
}
private static bool ContainsDocumentPlanFollowUpInstruction(string output)
{
if (string.IsNullOrWhiteSpace(output))
return false;
return output.Contains("즉시 실행", StringComparison.OrdinalIgnoreCase)
|| output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call html_create", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveDocumentPlanFollowUpTool(string output)
{
if (output.Contains("document_assemble", StringComparison.OrdinalIgnoreCase))
return "document_assemble";
if (output.Contains("docx_create", StringComparison.OrdinalIgnoreCase))
return "docx_create";
if (output.Contains("markdown_create", StringComparison.OrdinalIgnoreCase))
return "markdown_create";
if (output.Contains("file_write", StringComparison.OrdinalIgnoreCase))
return "file_write";
return "html_create";
}
private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync(
LlmService.ContentBlock call,
ToolResult result,
List<LlmService.ContentBlock> toolCalls,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct,
bool documentPlanWasCalled = false)
{
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false);
// document_plan 없이 바로 문서 도구가 호출된 경우 — 아직 LLM이 추가 반복을 할 수 있음.
// 한 번에 생성된 문서는 내용이 부실할 수 있으므로 조기 종료하지 않고 LLM에 판단을 맡긴다.
if (!_docFallbackAttempted && !documentPlanWasCalled)
return (false, false);
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
result.Success,
verificationEnabled,
verificationEnabled);
var consumedExtraIteration = false;
if (shouldVerify)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
consumedExtraIteration = true;
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (true, consumedExtraIteration);
}
private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct)
{
if (!result.Success)
return false;
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
result.Success,
verificationEnabled,
verificationEnabled);
if (!shouldVerify)
return false;
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
return true;
}
}
@@ -58,205 +58,6 @@ public partial class AgentLoopService
return true;
}
private bool TryApplyCodeCompletionGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
bool requireHighImpactCodeVerification,
int totalToolCalls,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0)
return false;
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages,
requireHighImpactCodeVerification);
if (executionPolicy.CodeVerificationGateMaxRetries > 0
&& !hasCodeVerificationEvidence
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
{
runState.CodeVerificationGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = requireHighImpactCodeVerification
? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요."
: "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification
? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..."
: "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다...");
return true;
}
if (requireHighImpactCodeVerification
&& executionPolicy.HighImpactBuildTestGateMaxRetries > 0
&& !HasSuccessfulBuildAndTestAfterLastModification(messages)
&& runState.HighImpactBuildTestGateRetry < executionPolicy.HighImpactBuildTestGateMaxRetries)
{
runState.HighImpactBuildTestGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다...");
return true;
}
if (executionPolicy.FinalReportGateMaxRetries > 0
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
{
runState.FinalReportGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification)
});
EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다...");
return true;
}
return false;
}
private bool TryApplyCodeDiffEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries)
return false;
if (HasDiffEvidenceAfterLastModification(messages))
return false;
runState.CodeDiffGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. " +
"git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. " +
"지금 즉시 git_tool 도구를 호출하세요."
});
EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다...");
return true;
}
private bool TryApplyRecentExecutionEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries)
return false;
if (!HasAnyBuildOrTestEvidence(messages))
return false;
if (HasBuildOrTestEvidenceAfterLastModification(messages))
return false;
runState.RecentExecutionGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다...");
return true;
}
private bool TryApplyExecutionSuccessGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries)
return false;
if (!HasAnyBuildOrTestAttempt(messages))
return false;
if (HasAnyBuildOrTestEvidence(messages))
return false;
runState.ExecutionSuccessGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다...");
return true;
}
private bool TryApplyTerminalEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
string userQuery,
int totalToolCalls,
string? lastArtifactFilePath,
RunState runState,
int retryMax)
{
if (totalToolCalls <= 0)
return false;
if (ShouldSkipTerminalEvidenceGateForAnalysisQuery(userQuery, taskPolicy))
return false;
if (runState.TerminalEvidenceGateRetry >= retryMax)
return false;
if (HasTerminalProgressEvidence(taskPolicy, messages, lastArtifactFilePath))
return false;
runState.TerminalEvidenceGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath)
});
EmitEvent(
AgentEventType.Thinking,
"",
$"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})");
return true;
}
private static bool ShouldSkipTerminalEvidenceGateForAnalysisQuery(string userQuery, TaskTypePolicy taskPolicy)
{
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
@@ -1110,53 +911,12 @@ public partial class AgentLoopService
}
}
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
"hash_tool", "image_analyze", "multi_read"
};
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct)
{
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var resolvedToolName = ResolveRequestedToolName(block.ToolName, activeToolNames);
block.ResolvedToolName = resolvedToolName;
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
return null;
var tool = _tools.Get(resolvedToolName);
if (tool == null)
return null;
EmitEvent(
AgentEventType.Thinking,
resolvedToolName,
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
var sw = Stopwatch.StartNew();
try
{
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await ExecuteToolWithTimeoutAsync(tool, resolvedToolName, input, context, null, ct);
sw.Stop();
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
}
catch (Exception ex)
{
sw.Stop();
return new LlmService.ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
sw.ElapsedMilliseconds,
resolvedToolName);
}
}
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct);
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
@@ -1167,75 +927,15 @@ public partial class AgentLoopService
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
{
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
while (true)
{
try
{
if (onStreamEventAsync == null)
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
var blocks = new List<LlmService.ContentBlock>();
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))
{
textBuilder.Append(evt.Text);
}
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{
blocks.Add(evt.ToolCall);
}
}
var result = new List<LlmService.ContentBlock>();
var text = textBuilder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(text))
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
result.AddRange(blocks);
return result;
}
catch (Exception ex)
{
if (IsContextOverflowError(ex.Message)
&& contextRecoveryRetries < 2
&& ForceContextRecovery(messages))
{
contextRecoveryRetries++;
if (runState != null)
runState.ContextRecoveryAttempts = contextRecoveryRetries;
EmitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
continue;
}
// 사용자 취소(ct)인 경우 재시도하지 않고 즉시 전파
if (ct.IsCancellationRequested) throw;
if (IsTransientLlmError(ex) && transientRetries < 3)
{
transientRetries++;
if (runState != null)
runState.TransientLlmErrorRetries = transientRetries;
var delayMs = ComputeTransientLlmBackoffDelayMs(transientRetries, ex);
EmitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
await Task.Delay(delayMs, ct);
continue;
}
throw;
}
}
}
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
messages,
tools,
ct,
phaseLabel,
runState,
forceToolCall,
prefetchToolCallAsync,
onStreamEventAsync);
private void ApplyToolPostExecutionBookkeeping(
LlmService.ContentBlock call,
@@ -1419,189 +1119,6 @@ public partial class AgentLoopService
|| lower.Contains("json parse");
}
private void ApplyDocumentPlanSuccessTransitions(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
ref bool documentPlanCalled,
ref string? documentPlanPath,
ref string? documentPlanTitle,
ref string? documentPlanScaffold)
{
if (!string.Equals(call.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase))
return;
documentPlanCalled = true;
var po = result.Output ?? string.Empty;
var pm = System.Text.RegularExpressions.Regex.Match(po, @"path:\s*""([^""]+)""");
if (pm.Success) documentPlanPath = pm.Groups[1].Value;
var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)""");
if (tm.Success) documentPlanTitle = tm.Groups[1].Value;
documentPlanScaffold = ExtractDocumentPlanScaffold(po);
if (!ContainsDocumentPlanFollowUpInstruction(po))
return;
var toolHint = ResolveDocumentPlanFollowUpTool(po);
messages.Add(new ChatMessage
{
Role = "user",
Content =
"document_plan이 완료되었습니다. " +
"방금 생성된 골격의 [내용...] 자리와 각 섹션 내용을 실제 상세 본문으로 모두 채운 뒤 " +
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
"설명만 하지 말고 실제 문서 생성 도구 호출로 바로 이어가세요."
});
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완료 · {toolHint} 실행 유도");
}
private static string? ExtractDocumentPlanScaffold(string output)
{
if (string.IsNullOrWhiteSpace(output))
return null;
var markers = new (string Start, string End)[]
{
("--- body 시작 ---", "--- body 끝 ---"),
("--- body start ---", "--- body end ---"),
("<!-- body start marker -->", "<!-- body end marker -->"),
};
foreach (var (startMarker, endMarker) in markers)
{
var start = output.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase);
if (start < 0)
continue;
var contentStart = start + startMarker.Length;
var end = output.IndexOf(endMarker, contentStart, StringComparison.OrdinalIgnoreCase);
if (end <= contentStart)
continue;
var scaffold = output[contentStart..end].Trim();
if (!string.IsNullOrWhiteSpace(scaffold))
return scaffold;
}
return null;
}
private static bool ContainsDocumentPlanFollowUpInstruction(string output)
{
if (string.IsNullOrWhiteSpace(output))
return false;
return output.Contains("즉시 실행", StringComparison.OrdinalIgnoreCase)
|| output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call html_create", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveDocumentPlanFollowUpTool(string output)
{
if (output.Contains("document_assemble", StringComparison.OrdinalIgnoreCase))
return "document_assemble";
if (output.Contains("docx_create", StringComparison.OrdinalIgnoreCase))
return "docx_create";
if (output.Contains("markdown_create", StringComparison.OrdinalIgnoreCase))
return "markdown_create";
if (output.Contains("file_write", StringComparison.OrdinalIgnoreCase))
return "file_write";
return "html_create";
}
private void ApplyCodeQualityFollowUpTransition(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
TaskTypePolicy taskPolicy,
ref bool requireHighImpactCodeVerification,
ref string? lastModifiedCodeFilePath)
{
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
{
requireHighImpactCodeVerification = highImpactCodeChange;
lastModifiedCodeFilePath = result.FilePath;
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildCodeQualityFollowUpPrompt(
call.ToolName,
result,
highImpactCodeChange,
HasAnyBuildOrTestEvidence(messages),
taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
? "怨좎쁺??肄붾뱶 蹂€寃쎌쑝濡?李몄“ 寃€利앷낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??."
: "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??.");
}
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
{
requireHighImpactCodeVerification = false;
}
}
private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync(
LlmService.ContentBlock call,
ToolResult result,
List<LlmService.ContentBlock> toolCalls,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct)
{
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false);
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
result.Success,
verificationEnabled,
verificationEnabled);
var consumedExtraIteration = false;
if (shouldVerify)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
consumedExtraIteration = true;
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (true, consumedExtraIteration);
}
private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct)
{
if (!result.Success)
return false;
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
result.Success,
verificationEnabled,
verificationEnabled);
if (!shouldVerify)
return false;
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
return true;
}
private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync(
LlmService.ContentBlock call,
AgentContext context,
@@ -1651,7 +1168,7 @@ public partial class AgentLoopService
return (false, null);
}
private sealed class RunState
internal sealed class RunState
{
public int ContextRecoveryAttempts;
public int WithheldRecoveryAttempts;
@@ -0,0 +1,233 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private void ApplyCodeQualityFollowUpTransition(
LlmService.ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
TaskTypePolicy taskPolicy,
ref bool requireHighImpactCodeVerification,
ref string? lastModifiedCodeFilePath)
{
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
{
requireHighImpactCodeVerification = highImpactCodeChange;
lastModifiedCodeFilePath = result.FilePath;
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildCodeQualityFollowUpPrompt(
call.ToolName,
result,
highImpactCodeChange,
HasAnyBuildOrTestEvidence(messages),
taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
? "고영향 코드 변경으로 분류돼 참조 검증과 build/test 검증을 더 엄격하게 이어갑니다."
: "코드 변경 후 build/test/diff 검증을 이어갑니다.");
}
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
{
requireHighImpactCodeVerification = false;
}
}
private bool TryApplyCodeCompletionGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
bool requireHighImpactCodeVerification,
int totalToolCalls,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0)
return false;
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages,
requireHighImpactCodeVerification);
if (executionPolicy.CodeVerificationGateMaxRetries > 0
&& !hasCodeVerificationEvidence
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
{
runState.CodeVerificationGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = requireHighImpactCodeVerification
? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요."
: "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification
? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..."
: "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다...");
return true;
}
if (requireHighImpactCodeVerification
&& executionPolicy.HighImpactBuildTestGateMaxRetries > 0
&& !HasSuccessfulBuildAndTestAfterLastModification(messages)
&& runState.HighImpactBuildTestGateRetry < executionPolicy.HighImpactBuildTestGateMaxRetries)
{
runState.HighImpactBuildTestGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다...");
return true;
}
if (executionPolicy.FinalReportGateMaxRetries > 0
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
{
runState.FinalReportGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification)
});
EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다...");
return true;
}
return false;
}
private bool TryApplyCodeDiffEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries)
return false;
if (HasDiffEvidenceAfterLastModification(messages))
return false;
runState.CodeDiffGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. 지금 즉시 git_tool 도구를 호출하세요."
});
EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다...");
return true;
}
private bool TryApplyRecentExecutionEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries)
return false;
if (!HasAnyBuildOrTestEvidence(messages))
return false;
if (HasBuildOrTestEvidenceAfterLastModification(messages))
return false;
runState.RecentExecutionGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다...");
return true;
}
private bool TryApplyExecutionSuccessGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries)
return false;
if (!HasAnyBuildOrTestAttempt(messages))
return false;
if (HasAnyBuildOrTestEvidence(messages))
return false;
runState.ExecutionSuccessGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다...");
return true;
}
private bool TryApplyTerminalEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
string userQuery,
int totalToolCalls,
string? lastArtifactFilePath,
RunState runState,
int retryMax)
{
if (totalToolCalls <= 0)
return false;
if (ShouldSkipTerminalEvidenceGateForAnalysisQuery(userQuery, taskPolicy))
return false;
if (runState.TerminalEvidenceGateRetry >= retryMax)
return false;
if (HasTerminalProgressEvidence(taskPolicy, messages, lastArtifactFilePath))
return false;
runState.TerminalEvidenceGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath)
});
EmitEvent(AgentEventType.Thinking, "", $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})");
return true;
}
}
@@ -2,6 +2,28 @@ using System;
namespace AxCopilot.Services.Agent;
internal enum TranscriptRowKind
{
AssistantText,
Thinking,
ToolActivity,
Permission,
ToolResult,
CompactBoundary,
Waiting,
PlanApproval,
Status,
}
internal sealed record AgentTranscriptRowPresentation(
TranscriptRowKind Kind,
string BadgeLabel,
string Title,
string Description,
string GroupKey,
bool CanGroup,
bool Emphasize);
internal static class AgentTranscriptDisplayCatalog
{
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
@@ -15,20 +37,27 @@ internal static class AgentTranscriptDisplayCatalog
{
"file_read" => "파일 읽기",
"file_write" => "파일 쓰기",
"file_edit" => "파일 편집",
"file_watch" => "파일 변경 감시",
"file_edit" => "파일 수정",
"file_watch" => "파일 감시",
"file_info" => "파일 정보",
"file_manage" => "파일 관리",
"glob" => "파일 찾기",
"grep" => "내용 검색",
"folder_map" => "폴더 구조",
"multi_read" => "다중 파일 읽기",
"document_reader" => "문서 읽기",
"document_planner" => "문서 계획",
"document_assembler" => "문서 조합",
"document_read" or "document_reader" => "문서 읽기",
"document_plan" or "document_planner" => "문서 계획",
"document_assemble" or "document_assembler" => "문서 조합",
"document_review" => "문서 검토",
"format_convert" => "형식 변환",
"template_render" => "템플릿 렌더",
"html_create" => "HTML 생성",
"docx_create" => "Word 생성",
"markdown_create" or "md_create" => "Markdown 생성",
"excel_create" or "xlsx_create" => "Excel 생성",
"csv_create" => "CSV 생성",
"pptx_create" => "PowerPoint 생성",
"build_run" => "빌드/실행",
"test_loop" => "테스트 루프",
@@ -42,11 +71,11 @@ internal static class AgentTranscriptDisplayCatalog
"powershell" => "PowerShell",
"web_fetch" => "웹 요청",
"http" => "HTTP 요청",
"user_ask" => "의견 요청",
"user_ask" => "질문 요청",
"suggest_actions" => "다음 작업 제안",
"task_create" => "작업 생성",
"task_update" => "작업 업데이트",
"task_update" => "작업 갱신",
"task_list" => "작업 목록",
"task_get" => "작업 조회",
"task_stop" => "작업 중지",
@@ -104,7 +133,7 @@ internal static class AgentTranscriptDisplayCatalog
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
AgentEventType.SkillCall => $"{displayName} 실행",
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
AgentEventType.PermissionGranted => $"{displayName} 실행 권한이 승인되었습니다.",
AgentEventType.PermissionGranted => $"{displayName} 실행 권한을 확인했습니다.",
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.",
@@ -112,6 +141,257 @@ internal static class AgentTranscriptDisplayCatalog
};
}
public static AgentTranscriptRowPresentation ResolveRowPresentation(AgentEvent evt, string itemDisplayName, string transcriptBadgeLabel)
{
var summary = (evt.Summary ?? string.Empty).Trim();
var toolName = (evt.ToolName ?? string.Empty).Trim().ToLowerInvariant();
var resultPresentation = evt.Type == AgentEventType.ToolResult
? ToolResultPresentationCatalog.Resolve(evt, transcriptBadgeLabel)
: null;
var permissionPresentation = evt.Type switch
{
AgentEventType.PermissionRequest => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: true),
AgentEventType.PermissionGranted => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: false),
_ => null
};
if (string.Equals(toolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Waiting,
"대기",
"처리 중...",
string.IsNullOrWhiteSpace(summary) ? "작업을 계속 진행하기 위한 응답을 기다리는 중입니다." : summary,
"waiting:agent",
true,
true);
}
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.CompactBoundary,
"압축",
"컨텍스트 압축 중...",
string.IsNullOrWhiteSpace(summary) ? "긴 대화를 계속 진행하기 위해 컨텍스트를 정리하고 있습니다." : summary,
"compact:context",
true,
true);
}
if (evt.Type == AgentEventType.Planning)
{
var title = evt.Steps is { Count: > 0 }
? $"계획 {evt.Steps.Count}단계 정리"
: "작업 계획 정리 중";
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Thinking,
"계획",
title,
string.IsNullOrWhiteSpace(summary) ? "실행 순서와 필요한 도구를 정리하고 있습니다." : summary,
"planning",
true,
false);
}
if (evt.Type == AgentEventType.Decision)
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.PlanApproval,
"확인",
"계획 확인 필요",
string.IsNullOrWhiteSpace(summary) ? "사용자 확인이 필요한 계획 단계입니다." : summary,
"plan:approval",
false,
true);
}
if (evt.Type is AgentEventType.StepStart or AgentEventType.StepDone)
{
var title = evt.StepTotal > 0
? evt.Type == AgentEventType.StepStart
? $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행"
: $"{evt.StepCurrent}/{evt.StepTotal} 단계 완료"
: evt.Type == AgentEventType.StepStart ? "단계 진행" : "단계 완료";
return new AgentTranscriptRowPresentation(
TranscriptRowKind.ToolActivity,
"단계",
title,
string.IsNullOrWhiteSpace(summary) ? title : summary,
$"step:{evt.StepCurrent}:{evt.StepTotal}:{evt.Type}",
false,
false);
}
if (evt.Type == AgentEventType.Thinking)
{
var title = ResolveThinkingTitle(summary, toolName);
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Thinking,
"생각",
title,
string.IsNullOrWhiteSpace(summary) ? title : summary,
$"thinking:{ResolveActivityGroup(toolName, summary)}",
true,
false);
}
if (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.SkillCall)
{
var group = ResolveActivityGroup(toolName, summary);
var title = BuildActivityTitle(toolName, itemDisplayName, summary);
var badge = evt.Type == AgentEventType.SkillCall ? "스킬" : "도구";
return new AgentTranscriptRowPresentation(
TranscriptRowKind.ToolActivity,
badge,
title,
BuildActivityDescription(group, summary),
$"activity:{group}",
true,
false);
}
if (permissionPresentation != null)
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Permission,
"권한",
permissionPresentation.Label,
permissionPresentation.Description,
$"permission:{permissionPresentation.Kind}",
false,
true);
}
if (resultPresentation != null)
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.ToolResult,
"결과",
resultPresentation.Label,
resultPresentation.Description,
$"result:{resultPresentation.Kind}:{resultPresentation.StatusKind}",
false,
resultPresentation.NeedsAttention);
}
if (evt.Type == AgentEventType.Error)
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Status,
"오류",
"실행 중 오류 발생",
string.IsNullOrWhiteSpace(summary) ? "에이전트 실행 중 오류가 발생했습니다." : summary,
"status:error",
false,
true);
}
if (evt.Type == AgentEventType.Complete)
{
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Status,
"완료",
"작업 완료",
string.IsNullOrWhiteSpace(summary) ? "에이전트 작업이 완료되었습니다." : summary,
"status:complete",
false,
false);
}
return new AgentTranscriptRowPresentation(
TranscriptRowKind.Status,
transcriptBadgeLabel,
string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary,
string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary,
$"status:{evt.Type}",
false,
false);
}
private static string ResolveThinkingTitle(string summary, string toolName)
{
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase))
return "결과 검증 중...";
if (summary.Contains("diff", StringComparison.OrdinalIgnoreCase))
return "변경 내용 확인 중...";
if (summary.Contains("retry", StringComparison.OrdinalIgnoreCase) || summary.Contains("재시도", StringComparison.OrdinalIgnoreCase))
return "재시도 준비 중...";
if (summary.Contains("fallback", StringComparison.OrdinalIgnoreCase) || summary.Contains("자동 생성", StringComparison.OrdinalIgnoreCase))
return "대체 경로 준비 중...";
if (toolName.Contains("document"))
return "문서 흐름 정리 중...";
return string.IsNullOrWhiteSpace(summary) ? "분석 중..." : summary;
}
private static string ResolveActivityGroup(string toolName, string summary)
{
if (toolName is "file_read" or "document_read" or "glob" or "grep" or "folder_map" or "multi_read")
return "read";
if (toolName is "file_edit" or "file_write")
return "edit";
if (toolName.Contains("build") || toolName.Contains("test") || toolName == "process")
return "execute";
if (toolName.Contains("git") || toolName.Contains("diff"))
return "git";
if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create") || toolName.Contains("markdown_create"))
return "document";
if (toolName.Contains("web") || toolName.Contains("http"))
return "web";
if (summary.Contains("권한", StringComparison.OrdinalIgnoreCase))
return "permission";
return "general";
}
private static string BuildActivityTitle(string toolName, string itemDisplayName, string summary)
{
if (toolName is "multi_read")
return "여러 파일 읽는 중";
if (toolName is "file_read" or "document_read")
return "파일 읽는 중";
if (toolName is "glob")
return "관련 파일 찾는 중";
if (toolName is "grep")
return "코드 검색 중";
if (toolName is "folder_map")
return "구조 확인 중";
if (toolName is "file_edit")
return "파일 수정 중";
if (toolName is "file_write")
return "파일 작성 중";
if (toolName.Contains("build") || toolName.Contains("test"))
return "빌드/테스트 실행 중";
if (toolName == "process" || toolName == "bash" || toolName == "powershell")
return "명령 실행 중";
if (toolName.Contains("git") || toolName.Contains("diff"))
return "Git 작업 중";
if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create"))
return "문서 결과 만드는 중";
if (toolName.Contains("web") || toolName.Contains("http"))
return "웹 정보 확인 중";
if (!string.IsNullOrWhiteSpace(itemDisplayName))
return $"{itemDisplayName} 실행 중";
return string.IsNullOrWhiteSpace(summary) ? "도구 실행 중" : summary;
}
private static string BuildActivityDescription(string group, string summary)
{
if (!string.IsNullOrWhiteSpace(summary))
return summary;
return group switch
{
"read" => "질문과 관련된 파일과 내용만 추려서 확인하고 있습니다.",
"edit" => "필요한 변경만 적용하고 결과를 다시 확인하고 있습니다.",
"execute" => "실행 결과와 로그를 확인해 다음 단계를 판단하고 있습니다.",
"git" => "변경 범위와 저장소 상태를 확인하고 있습니다.",
"document" => "문서 산출물을 준비하고 있습니다.",
"web" => "필요한 외부 정보를 확인하고 있습니다.",
_ => "다음 단계를 진행하기 위한 작업을 실행하고 있습니다."
};
}
private static string GetToolCategoryLabel(string? rawName)
{
if (string.IsNullOrWhiteSpace(rawName))
@@ -119,13 +399,13 @@ internal static class AgentTranscriptDisplayCatalog
return rawName.Trim().ToLowerInvariant() switch
{
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage"
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage" or "multi_read"
=> "파일",
"build_run" or "test_loop" or "dev_env_detect"
=> "빌드",
"git_tool" or "diff_tool" or "diff_preview"
=> "Git",
"document_reader" or "document_planner" or "document_assembler" or "document_review" or "format_convert" or "template_render"
"document_read" or "document_reader" or "document_plan" or "document_planner" or "document_assemble" or "document_assembler" or "document_review" or "format_convert" or "template_render" or "html_create" or "docx_create"
=> "문서",
"user_ask"
=> "질문",
+4 -4
View File
@@ -42,8 +42,8 @@ public class Base64Tool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var text = args.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
try
{
@@ -69,9 +69,9 @@ public class Base64Tool : IAgentTool
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("file_path", out var fp))
if (!args.SafeTryGetProperty("file_path", out var fp))
return ToolResult.Fail("'file_path' is required for b64file action");
var path = fp.GetString() ?? "";
var path = fp.SafeGetString() ?? "";
if (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
+3 -3
View File
@@ -42,9 +42,9 @@ public class BatchSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var path = args.GetProperty("path").SafeGetString() ?? "";
var content = args.GetProperty("content").SafeGetString() ?? "";
var desc = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
+3 -3
View File
@@ -56,9 +56,9 @@ public class BuildRunTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var action = args.GetProperty("action").GetString() ?? "detect";
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "detect";
var customCmd = args.SafeTryGetProperty("command", out var cmd) ? cmd.SafeGetString() ?? "" : "";
var subPath = args.SafeTryGetProperty("project_path", out var pp) ? pp.SafeGetString() ?? "" : "";
var workDir = context.WorkFolder;
if (!string.IsNullOrEmpty(subPath))
+36 -36
View File
@@ -56,12 +56,12 @@ public class ChartSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var title = args.GetProperty("title").GetString() ?? "Chart";
var title = args.GetProperty("title").SafeGetString() ?? "Chart";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -69,12 +69,12 @@ public class ChartSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
}
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
var globalAccent = args.TryGetProperty("accent_color", out var ga) ? ga.GetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "dashboard" : "dashboard";
var layout = args.SafeTryGetProperty("layout", out var l) ? l.SafeGetString() ?? "single" : "single";
var globalAccent = args.SafeTryGetProperty("accent_color", out var ga) ? ga.SafeGetString() : null;
var globalShowValues = true;
if (args.TryGetProperty("show_values", out var sv))
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
if (args.SafeTryGetProperty("show_values", out var sv))
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -89,7 +89,7 @@ public class ChartSkill : IAgentTool
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (!args.TryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
var chartCount = chartsEl.GetArrayLength();
@@ -138,17 +138,17 @@ public class ChartSkill : IAgentTool
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
{
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
var type = chart.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "bar" : "bar";
var chartTitle = chart.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? "" : "";
var unit = chart.SafeTryGetProperty("unit", out var u) ? u.SafeGetString() ?? "" : "";
var labels = ParseStringArray(chart, "labels");
var datasets = ParseDatasets(chart);
// Per-chart overrides
var accentColor = chart.TryGetProperty("accent_color", out var ac) ? ac.GetString() : globalAccent;
var accentColor = chart.SafeTryGetProperty("accent_color", out var ac) ? ac.SafeGetString() : globalAccent;
var showValues = globalShowValues;
if (chart.TryGetProperty("show_values", out var sv))
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
if (chart.SafeTryGetProperty("show_values", out var sv))
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
// Apply accent color to first dataset if single-color context
if (!string.IsNullOrEmpty(accentColor) && datasets.Count > 0 && datasets[0].Color == Palette[0])
@@ -534,17 +534,17 @@ public class ChartSkill : IAgentTool
foreach (var ds in datasets)
{
var pts = new List<(double x, double y)>();
if (chart.TryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
{
foreach (var dEl in dsArr.EnumerateArray())
{
var dName = dEl.TryGetProperty("name", out var nn) ? nn.GetString() : null;
var dName = dEl.SafeTryGetProperty("name", out var nn) ? nn.SafeGetString() : null;
if (dName != ds.Name) continue;
if (dEl.TryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
if (dEl.SafeTryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
{
foreach (var pEl in ptsEl.EnumerateArray())
{
if (pEl.TryGetProperty("x", out var px) && pEl.TryGetProperty("y", out var py)
if (pEl.SafeTryGetProperty("x", out var px) && pEl.SafeTryGetProperty("y", out var py)
&& px.TryGetDouble(out var xd) && py.TryGetDouble(out var yd))
pts.Add((xd, yd));
}
@@ -609,12 +609,12 @@ public class ChartSkill : IAgentTool
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
{
var yLabels = ParseStringArray(chart, "y_labels");
var colorFrom = chart.TryGetProperty("color_from", out var cf) ? cf.GetString() ?? "#EFF6FF" : "#EFF6FF";
var colorTo = chart.TryGetProperty("color_to", out var ct2) ? ct2.GetString() ?? "#1D4ED8" : "#1D4ED8";
var colorFrom = chart.SafeTryGetProperty("color_from", out var cf) ? cf.SafeGetString() ?? "#EFF6FF" : "#EFF6FF";
var colorTo = chart.SafeTryGetProperty("color_to", out var ct2) ? ct2.SafeGetString() ?? "#1D4ED8" : "#1D4ED8";
// Parse 2D values array
var grid = new List<List<double>>();
if (chart.TryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
{
foreach (var row in valsEl.EnumerateArray())
{
@@ -678,19 +678,19 @@ public class ChartSkill : IAgentTool
private static string RenderGauge(JsonElement chart, string unit)
{
var value = chart.TryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
var min = chart.TryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
var max = chart.TryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
var value = chart.SafeTryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
var min = chart.SafeTryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
var max = chart.SafeTryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
if (max <= min) max = min + 1;
// Determine arc color from thresholds (highest threshold below value wins)
var arcColor = Palette[0];
if (chart.TryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
{
var thresholds = thEl.EnumerateArray()
.Select(t => (
At: t.TryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
Color: t.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[0] : Palette[0]))
At: t.SafeTryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
Color: t.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[0] : Palette[0]))
.OrderBy(t => t.At)
.ToList();
foreach (var (at, color) in thresholds)
@@ -754,16 +754,16 @@ public class ChartSkill : IAgentTool
private static List<string> ParseStringArray(JsonElement parent, string prop)
{
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!parent.SafeTryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
return new();
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
return arr.EnumerateArray().Select(e => e.SafeGetString() ?? "").ToList();
}
private List<Dataset> ParseDatasets(JsonElement chart)
{
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
if (!chart.SafeTryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
{
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
{
return new()
{
@@ -782,10 +782,10 @@ public class ChartSkill : IAgentTool
int colorIdx = 0;
foreach (var ds in dsArr.EnumerateArray())
{
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
var name = ds.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
var color = ds.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
var values = new List<double>();
if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
if (ds.SafeTryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
list.Add(new Dataset { Name = name, Values = values, Color = color });
colorIdx++;
@@ -66,9 +66,9 @@ public class CheckpointTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
@@ -87,7 +87,7 @@ public class CheckpointTool : IAgentTool
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed";
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "unnamed" : "unnamed";
// 이름에서 파일 시스템 비안전 문자 제거
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
@@ -206,16 +206,16 @@ public class CheckpointTool : IAgentTool
// ID 또는 이름으로 체크포인트 찾기
string? targetDir = null;
if (args.TryGetProperty("id", out var idEl))
if (args.SafeTryGetProperty("id", out var idEl))
{
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (id >= 0 && id < dirs.Count)
targetDir = dirs[id];
}
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
if (targetDir == null && args.SafeTryGetProperty("name", out var nameEl))
{
var name = nameEl.GetString() ?? "";
var name = nameEl.SafeGetString() ?? "";
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
}
@@ -276,10 +276,10 @@ public class CheckpointTool : IAgentTool
.OrderByDescending(d => d)
.ToList();
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (id < 0 || id >= dirs.Count)
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
@@ -38,7 +38,7 @@ public class ClipboardTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -79,10 +79,10 @@ public class ClipboardTool : IAgentTool
private static ToolResult WriteClipboard(JsonElement args)
{
if (!args.TryGetProperty("text", out var textProp))
if (!args.SafeTryGetProperty("text", out var textProp))
return ToolResult.Fail("'text' parameter is required for write action");
var text = textProp.GetString() ?? "";
var text = textProp.SafeGetString() ?? "";
Clipboard.SetText(text);
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
}
@@ -53,9 +53,9 @@ public class CodeReviewTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var target = args.SafeTryGetProperty("target", out var t) ? t.SafeGetString() ?? "" : "";
var focus = args.SafeTryGetProperty("focus", out var f) ? f.SafeGetString() ?? "all" : "all";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
@@ -50,9 +50,9 @@ public class CodeSearchTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
var maxResults = args.SafeTryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
var reindex = args.SafeTryGetProperty("reindex", out var ri) && ri.GetBoolean();
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
@@ -21,9 +21,9 @@ public sealed class CronCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
var schedule = args.SafeTryGetProperty("schedule", out var s) ? (s.SafeGetString() ?? "").Trim() : "";
var command = args.SafeTryGetProperty("command", out var c) ? (c.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
@@ -23,8 +23,8 @@ public sealed class CronDeleteTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));
+14 -14
View File
@@ -42,31 +42,31 @@ public class CsvSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 검증 ──────────────────────────────────────────────
if (!args.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers' (문자열 배열)가 필요합니다.");
if (!args.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows' (배열의 배열)가 필요합니다.");
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var hint = headersEl.GetArrayLength() > 0
? headersEl[0].GetString() ?? "data"
var hint = headersEl.GetArrayLength() > 0 && headersEl[0].ValueKind == JsonValueKind.String
? headersEl[0].SafeGetString() ?? "data"
: "data";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 40) safe = safe[..40].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
}
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var delimiterKey = args.TryGetProperty("delimiter", out var delimEl) ? delimEl.GetString() ?? "comma" : "comma";
var useBom = !args.TryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
var useSummary = args.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
var delimiterKey = args.SafeTryGetProperty("delimiter", out var delimEl) ? delimEl.SafeGetString() ?? "comma" : "comma";
var useBom = !args.SafeTryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
var useSummary = args.SafeTryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
// ── 구분자 해석 ────────────────────────────────────────────────────
var delimiter = delimiterKey.ToLowerInvariant() switch
@@ -78,9 +78,9 @@ public class CsvSkill : IAgentTool
// ── 열 타입 힌트 ───────────────────────────────────────────────────
var colTypeHints = new List<string>();
if (args.TryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
foreach (var ct2 in colTypesEl.EnumerateArray())
colTypeHints.Add(ct2.GetString()?.ToLowerInvariant() ?? "text");
colTypeHints.Add(ct2.SafeGetString()?.ToLowerInvariant() ?? "text");
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -118,7 +118,7 @@ public class CsvSkill : IAgentTool
// ── 헤더 수집 ──────────────────────────────────────────────────
var headerList = new List<string>();
foreach (var h in headersEl.EnumerateArray())
headerList.Add(h.GetString() ?? "");
headerList.Add(h.SafeGetString() ?? "");
int colCount = headerList.Count;
+13 -13
View File
@@ -56,7 +56,7 @@ public class DataPivotTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var sourcePath = args.GetProperty("source_path").GetString() ?? "";
var sourcePath = args.GetProperty("source_path").SafeGetString() ?? "";
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -81,28 +81,28 @@ public class DataPivotTool : IAgentTool
var originalCount = data.Count;
// 필터 적용
if (args.TryGetProperty("filter", out var filterEl))
if (args.SafeTryGetProperty("filter", out var filterEl))
{
var filterStr = filterEl.GetString() ?? "";
var filterStr = filterEl.SafeGetString() ?? "";
if (!string.IsNullOrWhiteSpace(filterStr))
data = ApplyFilter(data, filterStr);
}
// 그룹화 & 집계
List<Dictionary<string, string>> result;
if (args.TryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
{
var groupCols = new List<string>();
foreach (var g in groupEl.EnumerateArray())
groupCols.Add(g.GetString() ?? "");
groupCols.Add(g.SafeGetString() ?? "");
var aggregates = new List<(string Column, string Function)>();
if (args.TryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
{
foreach (var agg in aggEl.EnumerateArray())
{
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
var col = agg.SafeTryGetProperty("column", out var c) ? c.SafeGetString() ?? "" : "";
var func = agg.SafeTryGetProperty("function", out var f) ? f.SafeGetString() ?? "count" : "count";
if (!string.IsNullOrEmpty(col))
aggregates.Add((col, func));
}
@@ -116,19 +116,19 @@ public class DataPivotTool : IAgentTool
}
// 정렬
if (args.TryGetProperty("sort_by", out var sortEl))
if (args.SafeTryGetProperty("sort_by", out var sortEl))
{
var sortBy = sortEl.GetString() ?? "";
var sortBy = sortEl.SafeGetString() ?? "";
if (!string.IsNullOrWhiteSpace(sortBy))
result = ApplySort(result, sortBy);
}
// Top N
if (args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
if (args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
result = result.Take(topN).ToList();
// 출력 포맷
var outputFormat = args.TryGetProperty("output_format", out var ofmt) ? ofmt.GetString() ?? "table" : "table";
var outputFormat = args.SafeTryGetProperty("output_format", out var ofmt) ? ofmt.SafeGetString() ?? "table" : "table";
var output = FormatOutput(result, outputFormat);
return Task.FromResult(ToolResult.Ok(
@@ -192,7 +192,7 @@ public class DataPivotTool : IAgentTool
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement
: doc.RootElement.TryGetProperty("data", out var d) ? d : doc.RootElement;
: doc.RootElement.SafeTryGetProperty("data", out var d) ? d : doc.RootElement;
if (arr.ValueKind != JsonValueKind.Array) return data;
+10 -10
View File
@@ -58,7 +58,7 @@ public class DateTimeTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -94,7 +94,7 @@ public class DateTimeTool : IAgentTool
private static ToolResult Parse(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var d) ? d.GetString() ?? "" : "";
var dateStr = args.SafeTryGetProperty("date", out var d) ? d.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
@@ -110,8 +110,8 @@ public class DateTimeTool : IAgentTool
private static ToolResult Diff(JsonElement args)
{
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
var d1 = args.SafeTryGetProperty("date", out var v1) ? v1.SafeGetString() ?? "" : "";
var d2 = args.SafeTryGetProperty("date2", out var v2) ? v2.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
return ToolResult.Fail("'date' and 'date2' parameters are required");
@@ -132,9 +132,9 @@ public class DateTimeTool : IAgentTool
private static ToolResult Add(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
var amountStr = args.SafeTryGetProperty("amount", out var av) ? av.SafeGetString() ?? "0" : "0";
var unit = args.SafeTryGetProperty("unit", out var uv) ? uv.SafeGetString() ?? "days" : "days";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
@@ -159,7 +159,7 @@ public class DateTimeTool : IAgentTool
private static ToolResult Epoch(JsonElement args)
{
var input = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var input = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
// 숫자면 epoch → datetime
@@ -186,8 +186,8 @@ public class DateTimeTool : IAgentTool
private static ToolResult FormatDate(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
var pattern = args.SafeTryGetProperty("pattern", out var pv) ? pv.SafeGetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
@@ -40,7 +40,7 @@ public class DevEnvDetectTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var category = args.TryGetProperty("category", out var cat) ? cat.GetString() ?? "all" : "all";
var category = args.SafeTryGetProperty("category", out var cat) ? cat.SafeGetString() ?? "all" : "all";
// 캐시 확인
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60)
@@ -42,9 +42,9 @@ public class DiffPreviewTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var newContent = args.GetProperty("new_content").GetString() ?? "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var newContent = args.GetProperty("new_content").SafeGetString() ?? "";
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
+5 -5
View File
@@ -50,11 +50,11 @@ public class DiffTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var left = args.GetProperty("left").GetString() ?? "";
var right = args.GetProperty("right").GetString() ?? "";
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
var left = args.GetProperty("left").SafeGetString() ?? "";
var right = args.GetProperty("right").SafeGetString() ?? "";
var leftLabel = args.SafeTryGetProperty("left_label", out var ll) ? ll.SafeGetString() ?? "left" : "left";
var rightLabel = args.SafeTryGetProperty("right_label", out var rl) ? rl.SafeGetString() ?? "right" : "right";
try
{
@@ -64,24 +64,24 @@ public class DocumentAssemblerTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Document";
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var path = args.GetProperty("path").SafeGetString() ?? "";
var title = args.GetProperty("title").SafeGetString() ?? "Document";
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood();
var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null;
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("sections 배열이 필요합니다.");
var sections = new List<(string Heading, string Content, int Level)>();
foreach (var sec in sectionsEl.EnumerateArray())
{
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
var heading = sec.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
var level = sec.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
sections.Add((heading, content, level));
}
@@ -84,12 +84,12 @@ public class DocumentPlannerTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var topic = args.GetProperty("topic").GetString() ?? "";
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
var topic = args.GetProperty("topic").SafeGetString() ?? "";
var docType = args.SafeTryGetProperty("document_type", out var dt) ? dt.SafeGetString() ?? "report" : "report";
var targetPages = args.SafeTryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
var sectionsHint = args.SafeTryGetProperty("sections_hint", out var sh) ? sh.SafeGetString() ?? "" : "";
var refSummary = args.SafeTryGetProperty("reference_summary", out var rs) ? rs.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(topic))
return Task.FromResult(ToolResult.Fail("topic이 비어있습니다."));
@@ -49,14 +49,14 @@ public class DocumentReaderTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return ToolResult.Fail("path가 필요합니다.");
var path = pathEl.GetString() ?? "";
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
var path = pathEl.SafeGetString() ?? "";
var maxChars = args.SafeTryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
var offset = args.SafeTryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
var sheetParam = args.SafeTryGetProperty("sheet", out var sh) ? sh.SafeGetString() ?? "" : "";
var pagesParam = args.SafeTryGetProperty("pages", out var pg) ? pg.SafeGetString() ?? "" : "";
var sectionParam = args.SafeTryGetProperty("section", out var sec) ? sec.SafeGetString() ?? "" : "";
if (maxChars < 100) maxChars = DefaultMaxChars;
if (offset < 0) offset = 0;
@@ -565,7 +565,7 @@ public class DocumentReaderTool : IAgentTool
private static int GetIntValue(JsonElement el, int defaultValue)
{
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.SafeGetString(), out var v)) return v;
return defaultValue;
}
}
@@ -33,9 +33,9 @@ public class DocumentReviewTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var path = pathEl.SafeGetString() ?? "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -108,11 +108,11 @@ public class DocumentReviewTool : IAgentTool
}
// 5. 기대 섹션 검사
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
{
foreach (var sec in sections.EnumerateArray())
{
var title = sec.GetString() ?? "";
var title = sec.SafeGetString() ?? "";
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
}
+24 -24
View File
@@ -94,12 +94,12 @@ public class DocxSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -108,12 +108,12 @@ public class DocxSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
}
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
(headerText != null || footerText != null);
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
var theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"];
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -161,7 +161,7 @@ public class DocxSkill : IAgentTool
int tableCount = 0;
foreach (var section in sections.EnumerateArray())
{
var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null;
var blockType = section.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString()?.ToLower() : null;
if (blockType == "pagebreak")
{
@@ -195,9 +195,9 @@ public class DocxSkill : IAgentTool
}
// 일반 섹션 (heading + body)
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
var level = section.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrEmpty(heading))
body.Append(CreateHeadingParagraph(heading, level, theme));
@@ -371,9 +371,9 @@ public class DocxSkill : IAgentTool
private static Table CreateTable(JsonElement section, ThemeColors theme)
{
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
var headers = section.SafeTryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.SafeTryGetProperty("rows", out var rArr) ? rArr : default;
var tableStyle = section.SafeTryGetProperty("style", out var ts) ? ts.SafeGetString() ?? "striped" : "striped";
var table = new Table();
@@ -403,7 +403,7 @@ public class DocxSkill : IAgentTool
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
};
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
var para = new Paragraph(new Run(new Text(h.SafeGetString() ?? ""))
{
RunProperties = new RunProperties
{
@@ -472,15 +472,15 @@ public class DocxSkill : IAgentTool
private static void AppendList(Body body, JsonElement section)
{
var items = section.TryGetProperty("items", out var arr) ? arr : default;
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
var items = section.SafeTryGetProperty("items", out var arr) ? arr : default;
var listStyle = section.SafeTryGetProperty("style", out var ls) ? ls.SafeGetString() ?? "bullet" : "bullet";
if (items.ValueKind != JsonValueKind.Array) return;
int idx = 1;
foreach (var item in items.EnumerateArray())
{
var rawText = item.GetString() ?? item.ToString();
var rawText = item.SafeGetString() ?? item.ToString();
// 서브불릿 감지: 앞에 공백/탭 또는 "- " 시작
bool isSub = rawText.StartsWith(" ") || rawText.StartsWith("\t") || rawText.TrimStart().StartsWith("- ");
@@ -526,9 +526,9 @@ public class DocxSkill : IAgentTool
private static void AppendCallout(Body body, JsonElement section)
{
var style = section.TryGetProperty("style", out var sv) ? sv.GetString() ?? "info" : "info";
var calloutTitle = section.TryGetProperty("title", out var tv) ? tv.GetString() ?? "" : "";
var calloutBody = section.TryGetProperty("body", out var bv) ? bv.GetString() ?? "" : "";
var style = section.SafeTryGetProperty("style", out var sv) ? sv.SafeGetString() ?? "info" : "info";
var calloutTitle = section.SafeTryGetProperty("title", out var tv) ? tv.SafeGetString() ?? "" : "";
var calloutBody = section.SafeTryGetProperty("body", out var bv) ? bv.SafeGetString() ?? "" : "";
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
? cc
@@ -588,8 +588,8 @@ public class DocxSkill : IAgentTool
private static Paragraph CreateHighlightBox(JsonElement section)
{
var text = section.TryGetProperty("text", out var tv) ? tv.GetString() ?? "" : "";
var color = section.TryGetProperty("color", out var cv) ? cv.GetString() ?? "blue" : "blue";
var text = section.SafeTryGetProperty("text", out var tv) ? tv.SafeGetString() ?? "" : "";
var color = section.SafeTryGetProperty("color", out var cv) ? cv.SafeGetString() ?? "blue" : "blue";
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
? hc
@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
/// <summary>DOCX 파일을 HTML로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class DocxToHtmlConverter
{
public static string Convert(string docxPath)
{
try
{
using var doc = WordprocessingDocument.Open(docxPath, false);
var body = doc.MainDocumentPart?.Document?.Body;
if (body == null)
return WrapHtml("<p style='color:#888'>문서 내용이 없습니다.</p>");
var sb = new StringBuilder();
foreach (var element in body.Elements())
{
if (element is Paragraph para)
sb.AppendLine(RenderParagraph(para, doc));
else if (element is Table table)
sb.AppendLine(RenderTable(table, doc));
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>DOCX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderParagraph(Paragraph para, WordprocessingDocument doc)
{
var style = para.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
var text = RenderRuns(para, doc);
if (string.IsNullOrWhiteSpace(text))
return "<p>&nbsp;</p>";
// 제목 스타일 감지
if (!string.IsNullOrEmpty(style))
{
var lower = style.ToLowerInvariant();
if (lower.Contains("heading1") || lower == "1") return $"<h1>{text}</h1>";
if (lower.Contains("heading2") || lower == "2") return $"<h2>{text}</h2>";
if (lower.Contains("heading3") || lower == "3") return $"<h3>{text}</h3>";
if (lower.Contains("heading4") || lower == "4") return $"<h4>{text}</h4>";
if (lower.Contains("heading5") || lower == "5") return $"<h5>{text}</h5>";
if (lower.Contains("heading6") || lower == "6") return $"<h6>{text}</h6>";
if (lower.Contains("title")) return $"<h1 class='title'>{text}</h1>";
if (lower.Contains("subtitle")) return $"<p class='subtitle'>{text}</p>";
if (lower.Contains("listparagraph") || lower.Contains("list"))
return $"<li>{text}</li>";
}
// 글머리 기호 감지
var numProps = para.ParagraphProperties?.NumberingProperties;
if (numProps != null)
return $"<li>{text}</li>";
// 정렬
var align = para.ParagraphProperties?.Justification?.Val?.Value;
var alignStyle = "";
if (align == JustificationValues.Center) alignStyle = " style='text-align:center'";
else if (align == JustificationValues.Right) alignStyle = " style='text-align:right'";
return $"<p{alignStyle}>{text}</p>";
}
private static string RenderRuns(Paragraph para, WordprocessingDocument doc)
{
var sb = new StringBuilder();
foreach (var run in para.Elements<Run>())
{
var text = string.Join("", run.Elements<Text>().Select(t => Escape(t.Text)));
if (string.IsNullOrEmpty(text))
{
// 이미지 체크
var drawing = run.Elements<Drawing>().FirstOrDefault();
if (drawing != null)
{
var img = ExtractImage(drawing, doc);
if (img != null)
sb.Append(img);
}
continue;
}
var rp = run.RunProperties;
if (rp?.Bold is { } bold && (bold.Val is null || bold.Val.Value))
text = $"<strong>{text}</strong>";
if (rp?.Italic is { } italic && (italic.Val is null || italic.Val.Value))
text = $"<em>{text}</em>";
if (rp?.Underline?.Val is { } underlineVal && underlineVal != UnderlineValues.None)
text = $"<u>{text}</u>";
if (rp?.Strike is { } strike && (strike.Val is null || strike.Val.Value))
text = $"<del>{text}</del>";
// 글자 색상
var color = rp?.Color?.Val?.Value;
if (!string.IsNullOrEmpty(color) && color != "000000" && color != "auto")
text = $"<span style='color:#{color}'>{text}</span>";
sb.Append(text);
}
return sb.ToString();
}
private static string? ExtractImage(Drawing drawing, WordprocessingDocument doc)
{
try
{
var blip = drawing.Descendants<DocumentFormat.OpenXml.Drawing.Blip>().FirstOrDefault();
if (blip?.Embed?.Value == null) return null;
var part = doc.MainDocumentPart?.GetPartById(blip.Embed.Value);
if (part == null) return null;
using var stream = part.GetStream();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var base64 = System.Convert.ToBase64String(ms.ToArray());
var mime = part.ContentType ?? "image/png";
return $"<img src='data:{mime};base64,{base64}' style='max-width:100%;height:auto;margin:8px 0' />";
}
catch
{
return null;
}
}
private static string RenderTable(Table table, WordprocessingDocument doc)
{
var sb = new StringBuilder();
sb.AppendLine("<table>");
foreach (var row in table.Elements<TableRow>())
{
sb.Append("<tr>");
foreach (var cell in row.Elements<TableCell>())
{
var cellContent = new StringBuilder();
foreach (var para in cell.Elements<Paragraph>())
cellContent.Append(RenderRuns(para, doc));
// 셀 병합
var gridSpan = cell.TableCellProperties?.GridSpan?.Val?.Value;
var spanAttr = gridSpan > 1 ? $" colspan='{gridSpan}'" : "";
sb.Append($"<td{spanAttr}>{cellContent}</td>");
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table>");
return sb.ToString();
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }}
h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }}
.subtitle {{ font-size: 14px; color: #666; text-align: center; margin-bottom: 20px; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 20px 0 8px; color: #222;
border-bottom: 2px solid #e5e7eb; padding-bottom: 4px; }}
h3 {{ font-size: 15px; font-weight: 600; margin: 16px 0 6px; color: #333; }}
h4,h5,h6 {{ font-size: 13.5px; font-weight: 600; margin: 12px 0 4px; }}
p {{ margin: 6px 0; }}
li {{ margin: 3px 0 3px 24px; }}
table {{ width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px; }}
th, td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
th {{ background: #f3f4f6; font-weight: 600; }}
tr:nth-child(even) {{ background: #f9fafb; }}
img {{ border-radius: 4px; }}
strong {{ font-weight: 600; }}
</style>
</head>
<body>
{body}
</body>
</html>";
}
}
+5 -5
View File
@@ -45,12 +45,12 @@ public class EncodingTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
if (action == "list")
return ListEncodings();
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(rawPath))
return ToolResult.Fail("'path'가 필요합니다.");
@@ -90,7 +90,7 @@ public class EncodingTool : IAgentTool
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
{
var toName = args.TryGetProperty("to_encoding", out var te) ? te.GetString() ?? "utf-8" : "utf-8";
var toName = args.SafeTryGetProperty("to_encoding", out var te) ? te.SafeGetString() ?? "utf-8" : "utf-8";
// 쓰기 권한 확인
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
@@ -98,10 +98,10 @@ public class EncodingTool : IAgentTool
// 소스 인코딩 결정
Encoding fromEnc;
if (args.TryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.GetString()))
if (args.SafeTryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.SafeGetString()))
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
fromEnc = Encoding.GetEncoding(fe.GetString()!);
fromEnc = Encoding.GetEncoding(fe.SafeGetString()!);
}
else
{
@@ -20,13 +20,13 @@ public sealed class EnterWorktreeTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var relative = args.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "").Trim() : "";
var relative = args.SafeTryGetProperty("path", out var pathEl) ? (pathEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(relative))
return Task.FromResult(ToolResult.Fail("path is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var create = !args.TryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
var create = !args.SafeTryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
var full = Path.GetFullPath(Path.Combine(root, relative));
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+10 -10
View File
@@ -47,7 +47,7 @@ public class EnvTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -68,9 +68,9 @@ public class EnvTool : IAgentTool
private static ToolResult Get(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
if (!args.SafeTryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for get action");
var name = n.GetString() ?? "";
var name = n.SafeGetString() ?? "";
var value = Environment.GetEnvironmentVariable(name);
return value != null
? ToolResult.Ok($"{name}={value}")
@@ -79,20 +79,20 @@ public class EnvTool : IAgentTool
private static ToolResult Set(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
if (!args.SafeTryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for set action");
if (!args.TryGetProperty("value", out var v))
if (!args.SafeTryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for set action");
var name = n.GetString() ?? "";
var value = v.GetString() ?? "";
var name = n.SafeGetString() ?? "";
var value = v.SafeGetString() ?? "";
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
}
private static ToolResult ListVars(JsonElement args)
{
var filter = args.TryGetProperty("filter", out var f) ? f.GetString() ?? "" : "";
var filter = args.SafeTryGetProperty("filter", out var f) ? f.SafeGetString() ?? "" : "";
var vars = Environment.GetEnvironmentVariables();
var entries = new List<string>();
@@ -118,9 +118,9 @@ public class EnvTool : IAgentTool
private static ToolResult Expand(JsonElement args)
{
if (!args.TryGetProperty("value", out var v))
if (!args.SafeTryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for expand action");
var input = v.GetString() ?? "";
var input = v.SafeGetString() ?? "";
var expanded = Environment.ExpandEnvironmentVariables(input);
return ToolResult.Ok(expanded);
}
+34 -34
View File
@@ -84,15 +84,15 @@ public class ExcelSkill : IAgentTool
{
// path 미제공 시 title 또는 sheet_name에서 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var hint = (args.TryGetProperty("title", out var tEl) ? tEl.GetString() : null)
?? (args.TryGetProperty("sheet_name", out var snEl) ? snEl.GetString() : null)
var hint = (args.SafeTryGetProperty("title", out var tEl) ? tEl.SafeGetString() : null)
?? (args.SafeTryGetProperty("sheet_name", out var snEl) ? snEl.SafeGetString() : null)
?? "workbook";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
@@ -116,7 +116,7 @@ public class ExcelSkill : IAgentTool
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Determine if we are in multi-sheet mode
var multiSheetMode = args.TryGetProperty("sheets", out var sheetsArr)
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
&& sheetsArr.ValueKind == JsonValueKind.Array
&& sheetsArr.GetArrayLength() > 0;
@@ -137,16 +137,16 @@ public class ExcelSkill : IAgentTool
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
{
if (!args.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers'");
if (!args.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() : null;
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
var isStyled = tableStyle != "plain";
var freezeHeader= args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var freezeHeader= args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(args, "number_formats");
@@ -168,8 +168,8 @@ public class ExcelSkill : IAgentTool
var colCount = headers.GetArrayLength();
var summaryArg = args.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
@@ -218,7 +218,7 @@ public class ExcelSkill : IAgentTool
}
// Use first sheet's theme for the shared stylesheet
var firstThemeName = sheetsArr[0].TryGetProperty("theme", out var ft) ? ft.GetString() : null;
var firstThemeName = sheetsArr[0].SafeTryGetProperty("theme", out var ft) ? ft.SafeGetString() : null;
var firstTheme = GetTheme(firstThemeName);
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
@@ -235,21 +235,21 @@ public class ExcelSkill : IAgentTool
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var sheetName = sheetDef.TryGetProperty("name", out var snEl) ? snEl.GetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
var tableStyle = sheetDef.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "styled" : "styled";
var themeName = sheetDef.TryGetProperty("theme", out var thEl) ? thEl.GetString() : firstThemeName;
var sheetName = sheetDef.SafeTryGetProperty("name", out var snEl) ? snEl.SafeGetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
var tableStyle = sheetDef.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "styled" : "styled";
var themeName = sheetDef.SafeTryGetProperty("theme", out var thEl) ? thEl.SafeGetString() : firstThemeName;
var isStyled = tableStyle != "plain";
var freezeHeader = sheetDef.TryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
var freezeHeader = sheetDef.SafeTryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
var alignments = ParseAlignments(sheetDef, "col_alignments");
if (!sheetDef.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
var colCount = headers.GetArrayLength();
var summaryArg = sheetDef.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = sheetDef.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
@@ -315,7 +315,7 @@ public class ExcelSkill : IAgentTool
{
CellReference = cellRef,
DataType = CellValues.String,
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.GetString() ?? "" : ""),
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.SafeGetString() ?? "" : ""),
StyleIndex = isStyled ? (uint)1 : 0,
};
headerRow.Append(cell);
@@ -374,7 +374,7 @@ public class ExcelSkill : IAgentTool
var mergeCells = new MergeCells();
foreach (var merge in mergesArg.EnumerateArray())
{
var range = merge.GetString();
var range = merge.SafeGetString();
if (!string.IsNullOrEmpty(range))
mergeCells.Append(new MergeCell { Reference = range });
}
@@ -678,7 +678,7 @@ public class ExcelSkill : IAgentTool
private static Columns? CreateColumns(JsonElement args, int colCount)
{
var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
var hasWidths = args.SafeTryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
var columns = new Columns();
for (int i = 0; i < colCount; i++)
@@ -706,8 +706,8 @@ public class ExcelSkill : IAgentTool
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
uint rowNum, int colCount, int dataRowCount, bool isStyled)
{
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
var label = summary.SafeTryGetProperty("label", out var lbl) ? lbl.SafeGetString() ?? "합계" : "합계";
var colFormulas = summary.SafeTryGetProperty("columns", out var cols) ? cols : default;
var summaryRow = new Row { RowIndex = rowNum };
@@ -730,9 +730,9 @@ public class ExcelSkill : IAgentTool
};
if (colFormulas.ValueKind == JsonValueKind.Object &&
colFormulas.TryGetProperty(colLetter, out var funcName))
colFormulas.SafeTryGetProperty(colLetter, out var funcName))
{
var func = funcName.GetString()?.ToUpper() ?? "SUM";
var func = funcName.SafeGetString()?.ToUpper() ?? "SUM";
var startRow = 2;
var endRow = startRow + dataRowCount - 1;
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
@@ -751,20 +751,20 @@ public class ExcelSkill : IAgentTool
private static List<string?> ParseNumberFormats(JsonElement args, string key)
{
var result = new List<string?>();
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString());
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString());
return result;
}
private static List<string?> ParseAlignments(JsonElement args, string key)
{
var result = new List<string?>();
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString()?.ToLower());
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString()?.ToLower());
return result;
}
+46 -5
View File
@@ -24,10 +24,10 @@ public class FileEditTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var oldStr = args.GetProperty("old_string").GetString() ?? "";
var newStr = args.GetProperty("new_string").GetString() ?? "";
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var path = args.GetProperty("path").SafeGetString() ?? "";
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -47,7 +47,11 @@ public class FileEditTool : IAgentTool
var count = CountOccurrences(content, oldStr);
if (count == 0)
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
{
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
var hint = BuildNotFoundHint(content, oldStr);
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
}
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
@@ -113,6 +117,43 @@ public class FileEditTool : IAgentTool
return sb.ToString().TrimEnd();
}
/// <summary>old_string 미발견 시 LLM에 파일 내용 힌트를 제공합니다.</summary>
private static string BuildNotFoundHint(string content, string oldStr)
{
if (string.IsNullOrWhiteSpace(content)) return " 파일이 비어있습니다.";
var sb = new StringBuilder();
// 유사 행 검색: old_string의 첫 줄로 근사 매치 시도
var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r');
if (firstLine.Length >= 8)
{
var lines = content.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].Contains(firstLine, StringComparison.OrdinalIgnoreCase)
|| (firstLine.Length >= 15 && lines[i].Trim().TrimEnd('\r').StartsWith(firstLine[..15], StringComparison.OrdinalIgnoreCase)))
{
var start = Math.Max(0, i - 1);
var end = Math.Min(lines.Length - 1, i + 3);
sb.AppendLine($"\n\n유사한 내용 발견 (줄 {i + 1}):");
for (int j = start; j <= end; j++)
sb.AppendLine($" {j + 1}: {lines[j].TrimEnd('\r')}");
break;
}
}
}
// 파일이 짧으면 전체 내용 표시
if (sb.Length == 0)
{
var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content;
sb.AppendLine($"\n\n현재 파일 내용:\n{preview}");
}
return sb.ToString();
}
private static int CountOccurrences(string text, string search)
{
if (string.IsNullOrEmpty(search)) return 0;
+1 -1
View File
@@ -27,7 +27,7 @@ public class FileInfoTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
@@ -41,9 +41,9 @@ public class FileManageTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var rawPath = args.GetProperty("path").GetString() ?? "";
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var dest = args.SafeTryGetProperty("destination", out var d) ? d.SafeGetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
+4 -4
View File
@@ -22,11 +22,11 @@ public class FileReadTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
var path = pathEl.SafeGetString() ?? "";
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
var fullPath = ResolvePath(path, context.WorkFolder);
@@ -57,12 +57,12 @@ public class FileWatchTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
var path = args.GetProperty("path").SafeGetString() ?? "";
var pattern = args.SafeTryGetProperty("pattern", out var patEl) ? patEl.SafeGetString() ?? "*" : "*";
var sinceStr = args.SafeTryGetProperty("since", out var sinceEl) ? sinceEl.SafeGetString() ?? "24h" : "24h";
var recursive = !args.SafeTryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
var includeSize = !args.SafeTryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
var topN = args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -48,11 +48,11 @@ public class FileWriteTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var encName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var append = args.TryGetProperty("append", out var appEl) && appEl.GetBoolean();
var mkDirs = !args.TryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var content = args.GetProperty("content").SafeGetString() ?? "";
var encName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
var append = args.SafeTryGetProperty("append", out var appEl) && appEl.GetBoolean();
var mkDirs = !args.SafeTryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
+19 -19
View File
@@ -21,8 +21,8 @@ public class FolderMapTool : IAgentTool
Properties = new()
{
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new()
{
@@ -52,34 +52,34 @@ public class FolderMapTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── path ──────────────────────────────────────────────────────────
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
// ── depth ─────────────────────────────────────────────────────────
var depth = 3;
if (args.TryGetProperty("depth", out var d))
var depth = 2;
if (args.SafeTryGetProperty("depth", out var d))
{
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.SafeGetString(), out var dv)) depth = dv;
}
if (depth < 1) depth = 1;
var maxDepth = Math.Min(depth, 10);
// ── include_files ─────────────────────────────────────────────────
var includeFiles = true;
if (args.TryGetProperty("include_files", out var inc))
var includeFiles = false;
if (args.SafeTryGetProperty("include_files", out var inc))
{
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
includeFiles = inc.GetBoolean();
else
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
}
// ── extensions / pattern ──────────────────────────────────────────
HashSet<string>? extSet = null;
if (args.TryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
{
var list = extsEl.EnumerateArray()
.Select(e => e.GetString() ?? "")
.Select(e => e.SafeGetString() ?? "")
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.StartsWith('.') ? s : "." + s)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -88,34 +88,34 @@ public class FolderMapTool : IAgentTool
// Fall back to single pattern if extensions not provided
string extFilter = "";
if (extSet == null)
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : "";
// ── sort_by ───────────────────────────────────────────────────────
var sortBy = args.TryGetProperty("sort_by", out var sb2) ? sb2.GetString() ?? "name" : "name";
var sortBy = args.SafeTryGetProperty("sort_by", out var sb2) ? sb2.SafeGetString() ?? "name" : "name";
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
// ── show_dir_sizes ────────────────────────────────────────────────
var showDirSizes = false;
if (args.TryGetProperty("show_dir_sizes", out var sds))
if (args.SafeTryGetProperty("show_dir_sizes", out var sds))
{
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
showDirSizes = sds.GetBoolean();
else
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase);
}
// ── modified_after ────────────────────────────────────────────────
DateTime? modifiedAfter = null;
if (args.TryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
if (args.SafeTryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(maEl.GetString(), out var mdt))
if (DateTime.TryParse(maEl.SafeGetString(), out var mdt))
modifiedAfter = mdt;
}
// ── max_file_size ─────────────────────────────────────────────────
long? maxFileSizeBytes = null;
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? "");
// ── resolve base directory ────────────────────────────────────────
var baseDir = string.IsNullOrEmpty(subPath)
@@ -36,9 +36,9 @@ public class FormatConvertTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var target = args.GetProperty("target").GetString() ?? "";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var source = args.GetProperty("source").SafeGetString() ?? "";
var target = args.GetProperty("target").SafeGetString() ?? "";
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
+3 -3
View File
@@ -51,10 +51,10 @@ public class GitTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "status";
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
var action = actionEl.SafeGetString() ?? "status";
var extraArgs = args.SafeTryGetProperty("args", out var a) ? a.SafeGetString() ?? "" : "";
var workDir = context.WorkFolder;
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
+6 -6
View File
@@ -27,12 +27,12 @@ public class GlobTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name";
var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : "";
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
+12 -12
View File
@@ -33,20 +33,20 @@ public class GrepTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var maxMatches = args.TryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
var filesOnly = args.TryGetProperty("files_only", out var fo) && fo.GetBoolean();
var wholeWord = args.TryGetProperty("whole_word", out var ww) && ww.GetBoolean();
var invert = args.TryGetProperty("invert", out var inv) && inv.GetBoolean();
var maxFileSizeKb = args.TryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
var globFilter = args.SafeTryGetProperty("glob", out var g) ? g.SafeGetString() ?? "" : "";
var caseSensitive = args.SafeTryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var maxMatches = args.SafeTryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
var filesOnly = args.SafeTryGetProperty("files_only", out var fo) && fo.GetBoolean();
var wholeWord = args.SafeTryGetProperty("whole_word", out var ww) && ww.GetBoolean();
var invert = args.SafeTryGetProperty("invert", out var inv) && inv.GetBoolean();
var maxFileSizeKb = args.SafeTryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
// context_lines is the base for both sides; before/after_lines override individually
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
var beforeLines = args.TryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
var afterLines = args.TryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
var contextLines = args.SafeTryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
var beforeLines = args.SafeTryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
var afterLines = args.SafeTryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
+3 -3
View File
@@ -41,9 +41,9 @@ public class HashTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var input = args.GetProperty("input").GetString() ?? "";
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
var input = args.GetProperty("input").SafeGetString() ?? "";
var algo = args.SafeTryGetProperty("algorithm", out var a) ? a.SafeGetString() ?? "sha256" : "sha256";
try
{
+72 -57
View File
@@ -67,26 +67,26 @@ public class HtmlSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// 필수 파라미터 안전 추출
if (!args.TryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
if (!args.SafeTryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
return ToolResult.Fail("필수 파라미터 누락: 'title'");
// path 미제공 시 title로 자동 생성
args.TryGetProperty("path", out var pathEl);
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.GetString()))
args.SafeTryGetProperty("path", out var pathEl);
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
pathEl = default; // 아래에서 title 기반으로 생성
// body와 sections 둘 다 없으면 오류
bool hasBody = args.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
if (!hasBody && !hasSections)
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
var title = titleEl.GetString() ?? "Report";
var title = titleEl.SafeGetString() ?? "Report";
// path가 없으면 title에서 안전한 파일명 생성
string path;
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -95,14 +95,14 @@ public class HtmlSkill : IAgentTool
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
path = safeName + ".html";
}
var body = hasBody ? (bodyEl.GetString() ?? "") : "";
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.TryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.TryGetProperty("accent_color", out var accentEl) ? accentEl.GetString() : null;
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -187,8 +187,16 @@ public class HtmlSkill : IAgentTool
if (!string.IsNullOrEmpty(tocHtml))
sb.AppendLine(tocHtml);
// 본문
sb.AppendLine(body);
// 본문 — table 태그에 반응형 래퍼 자동 추가
var wrappedBody = System.Text.RegularExpressions.Regex.Replace(
body,
@"(<table\b)",
"<div style=\"overflow-x:auto;margin:0 -4px;padding:0 4px\">$1");
wrappedBody = System.Text.RegularExpressions.Regex.Replace(
wrappedBody,
@"(</table>)",
"$1</div>");
sb.AppendLine(wrappedBody);
sb.AppendLine("</div>");
sb.AppendLine("</body>");
@@ -225,8 +233,8 @@ public class HtmlSkill : IAgentTool
var sb = new StringBuilder();
foreach (var section in sections.EnumerateArray())
{
if (!section.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString() ?? "";
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString() ?? "";
switch (type.ToLowerInvariant())
{
@@ -267,22 +275,22 @@ public class HtmlSkill : IAgentTool
private static string RenderHeading(JsonElement s)
{
var level = s.TryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
return $"<h{level}>{Escape(text)}</h{level}>";
}
private static string RenderParagraph(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
return $"<p>{MarkdownToHtml(text)}</p>";
}
private static string RenderCallout(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "info" : "info";
var title = s.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var text = s.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "";
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "info" : "info";
var title = s.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "";
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "️" };
var sb = new StringBuilder();
sb.AppendLine($"<div class=\"callout-{style}\">");
@@ -299,15 +307,15 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("<div style=\"overflow-x:auto\">");
sb.AppendLine("<table>");
if (s.TryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
if (s.SafeTryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<thead><tr>");
foreach (var h in headers.EnumerateArray())
sb.Append($"<th>{Escape(h.GetString() ?? "")}</th>");
sb.Append($"<th>{Escape(h.SafeGetString() ?? "")}</th>");
sb.AppendLine("</tr></thead>");
}
if (s.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
if (s.SafeTryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<tbody>");
foreach (var row in rows.EnumerateArray())
@@ -315,7 +323,7 @@ public class HtmlSkill : IAgentTool
sb.Append("<tr>");
if (row.ValueKind == JsonValueKind.Array)
foreach (var cell in row.EnumerateArray())
sb.Append($"<td>{MarkdownToHtml(cell.GetString() ?? "")}</td>");
sb.Append($"<td>{MarkdownToHtml(cell.SafeGetString() ?? "")}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
@@ -328,19 +336,19 @@ public class HtmlSkill : IAgentTool
private static string RenderChart(JsonElement s, string? accentColor)
{
var kind = s.TryGetProperty("kind", out var k) ? k.GetString() ?? "bar" : "bar";
var chartTitle = s.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var kind = s.SafeTryGetProperty("kind", out var k) ? k.SafeGetString() ?? "bar" : "bar";
var chartTitle = s.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
if (!s.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return "";
var items = new List<(string label, double value, string color)>();
double maxVal = 0;
foreach (var item in data.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
var color = item.TryGetProperty("color", out var cl) ? cl.GetString() ?? "#2E75B6" : "#2E75B6";
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
var color = item.SafeTryGetProperty("color", out var cl) ? cl.SafeGetString() ?? "#2E75B6" : "#2E75B6";
items.Add((label, value, color));
if (value > maxVal) maxVal = value;
}
@@ -399,17 +407,17 @@ public class HtmlSkill : IAgentTool
private static string RenderCards(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"cards-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin:1rem 0\">");
foreach (var item in items.EnumerateArray())
{
var cardTitle = item.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var cardBody = item.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
var badge = item.TryGetProperty("badge", out var bg) ? bg.GetString() : null;
var icon = item.TryGetProperty("icon", out var ic) ? ic.GetString() : null;
var cardTitle = item.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
var cardBody = item.SafeTryGetProperty("body", out var bd) ? bd.SafeGetString() ?? "" : "";
var badge = item.SafeTryGetProperty("badge", out var bg) ? bg.SafeGetString() : null;
var icon = item.SafeTryGetProperty("icon", out var ic) ? ic.SafeGetString() : null;
sb.AppendLine("<div class=\"card\" style=\"border:1px solid #e5e7eb;border-radius:8px;padding:1.1rem;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.07)\">");
sb.Append("<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:6px\">");
@@ -429,8 +437,8 @@ public class HtmlSkill : IAgentTool
private static string RenderList(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "bullet" : "bullet";
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "bullet" : "bullet";
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var tag = style == "number" ? "ol" : "ul";
@@ -442,7 +450,7 @@ public class HtmlSkill : IAgentTool
bool inSubList = false;
foreach (var item in items.EnumerateArray())
{
var text = item.GetString() ?? "";
var text = item.SafeGetString() ?? "";
bool isSub = text.StartsWith(" ");
if (isSub && !inSubList)
{
@@ -464,8 +472,8 @@ public class HtmlSkill : IAgentTool
private static string RenderQuote(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var a) ? a.GetString() : null;
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var author = s.SafeTryGetProperty("author", out var a) ? a.SafeGetString() : null;
var sb = new StringBuilder();
sb.AppendLine("<blockquote style=\"border-left:4px solid #d1d5db;margin:1.5rem 0;padding:.75rem 1.25rem;background:#f9fafb;border-radius:0 6px 6px 0\">");
sb.AppendLine($"<p style=\"margin:0;font-style:italic;color:#374151\">{MarkdownToHtml(text)}</p>");
@@ -477,17 +485,17 @@ public class HtmlSkill : IAgentTool
private static string RenderKpi(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"kpi-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin:1.25rem 0\">");
foreach (var item in items.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetString() ?? "" : "";
var change = item.TryGetProperty("change", out var ch) ? ch.GetString() : null;
var positive = item.TryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var vl) ? vl.SafeGetString() ?? "" : "";
var change = item.SafeTryGetProperty("change", out var ch) ? ch.SafeGetString() : null;
var positive = item.SafeTryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
var changeColor = positive ? "#16a34a" : "#dc2626";
var changeArrow = positive ? "▲" : "▼";
@@ -515,6 +523,9 @@ public class HtmlSkill : IAgentTool
{
if (string.IsNullOrEmpty(text)) return text;
// 0. LLM이 이미 삽입한 <br> / <br/> 태그를 보존 — 이스케이프 전에 플레이스홀더로 치환
text = Regex.Replace(text, @"<br\s*/?>", "\x00BR\x00");
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
text = text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
@@ -535,6 +546,9 @@ public class HtmlSkill : IAgentTool
// 6. newline → <br>
text = text.Replace("\n", "<br>");
// 7. 보존된 <br> 플레이스홀더를 복원
text = text.Replace("\x00BR\x00", "<br>");
return text;
}
@@ -685,17 +699,18 @@ public class HtmlSkill : IAgentTool
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
var gradient = cover.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
var coverTitle = cover.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? fallbackTitle : fallbackTitle;
var subtitle = cover.SafeTryGetProperty("subtitle", out var sub) ? sub.SafeGetString() ?? "" : "";
var author = cover.SafeTryGetProperty("author", out var auth) ? auth.SafeGetString() ?? "" : "";
var date = cover.SafeTryGetProperty("date", out var dt) ? dt.SafeGetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
var gradient = cover.SafeTryGetProperty("gradient", out var grad) ? grad.SafeGetString() : null;
var styleAttr = "";
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
{
var colors = gradient.Split(',');
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
if (colors.Length >= 2)
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
}
var sb = new StringBuilder();
+7 -7
View File
@@ -58,11 +58,11 @@ public class HttpTool : IAgentTool
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
return ToolResult.Fail("사내모드에서는 HTTP 도구 실행이 차단됩니다. operationMode=external에서만 사용할 수 있습니다.");
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
var url = args.GetProperty("url").GetString() ?? "";
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
var method = (args.SafeGetProperty("method")?.SafeGetString() ?? "GET").ToUpperInvariant();
var url = args.SafeGetProperty("url")?.SafeGetString() ?? "";
var body = args.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
var headers = args.SafeTryGetProperty("headers", out var h) ? h.SafeGetString() ?? "" : "";
var timeout = args.SafeTryGetProperty("timeout", out var t) ? Math.Min(t.SafeGetInt32(30), 120) : 30;
// 보안: 허용된 호스트만
if (!IsAllowedHost(url))
@@ -77,8 +77,8 @@ public class HttpTool : IAgentTool
if (!string.IsNullOrEmpty(headers))
{
using var headerDoc = JsonDocument.Parse(headers);
foreach (var prop in headerDoc.RootElement.EnumerateObject())
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
foreach (var prop in headerDoc.RootElement.SafeEnumerateObject())
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.SafeGetString());
}
// 본문 설정
@@ -0,0 +1,22 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal interface IToolExecutionCoordinator
{
Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct);
Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
AgentLoopService.RunState? runState = null,
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null);
}
@@ -52,10 +52,10 @@ public class ImageAnalyzeTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var imagePath = args.GetProperty("image_path").GetString() ?? "";
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var imagePath = args.GetProperty("image_path").SafeGetString() ?? "";
var task = args.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "describe" : "describe";
var question = args.SafeTryGetProperty("question", out var qEl) ? qEl.SafeGetString() ?? "" : "";
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -88,9 +88,9 @@ public class ImageAnalyzeTool : IAgentTool
// 비교 모드: 두 번째 이미지
string? compareBase64 = null;
string? compareMime = null;
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
if (task == "compare" && args.SafeTryGetProperty("compare_path", out var cpEl))
{
var comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
var comparePath = FileReadTool.ResolvePath(cpEl.SafeGetString() ?? "", context.WorkFolder);
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
{
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);
@@ -0,0 +1,84 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// <see cref="JsonElement"/> 안전 변환 확장 메서드.
/// Qwen 3.5 등 일부 LLM은 문자열 파라미터를 따옴표 없이 숫자로 반환하므로
/// <c>GetString()</c> 호출 시 InvalidOperationException이 발생합니다.
/// 이 확장을 사용하면 Number/Boolean/Null 등 모든 ValueKind를 문자열로 안전하게 변환합니다.
/// </summary>
internal static class JsonElementExtensions
{
/// <summary>
/// JsonElement를 안전하게 문자열로 변환합니다.
/// String이면 GetString(), 그 외(Number, True/False 등)이면 GetRawText()를 반환합니다.
/// </summary>
public static string? SafeGetString(this JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => element.GetRawText(),
};
}
/// <summary>
/// JsonElement를 안전하게 int로 변환합니다.
/// Number이면 GetInt32(), String이면 int.TryParse, 그 외이면 defaultValue를 반환합니다.
/// </summary>
public static int SafeGetInt32(this JsonElement element, int defaultValue = 0)
{
return element.ValueKind switch
{
JsonValueKind.Number => element.GetInt32(),
JsonValueKind.String => int.TryParse(element.GetString(), out var v) ? v : defaultValue,
_ => defaultValue,
};
}
/// <summary>
/// TryGetProperty의 안전 래퍼.
/// element가 Object가 아니면 InvalidOperationException을 던지는 대신 false를 반환합니다.
/// Qwen 3.5 등 일부 LLM이 Object 대신 String을 반환하는 경우에 대비합니다.
/// </summary>
public static bool SafeTryGetProperty(this JsonElement element, string propertyName, out JsonElement value)
{
if (element.ValueKind != JsonValueKind.Object)
{
value = default;
return false;
}
return element.TryGetProperty(propertyName, out value);
}
/// <summary>
/// GetProperty의 안전 래퍼.
/// element가 Object가 아니거나 속성이 없으면 null을 반환합니다.
/// </summary>
public static JsonElement? SafeGetProperty(this JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
return null;
return element.TryGetProperty(propertyName, out var value) ? value : null;
}
/// <summary>
/// EnumerateObject의 안전 래퍼.
/// Object가 아니면 빈 열거자를 반환합니다.
/// </summary>
public static JsonElement.ObjectEnumerator SafeEnumerateObject(this JsonElement element)
{
return element.ValueKind == JsonValueKind.Object ? element.EnumerateObject() : default;
}
/// <summary>
/// EnumerateArray의 안전 래퍼.
/// Array가 아니면 빈 열거자를 반환합니다.
/// </summary>
public static JsonElement.ArrayEnumerator SafeEnumerateArray(this JsonElement element)
{
return element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : default;
}
}
+9 -9
View File
@@ -54,18 +54,18 @@ public class JsonTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var json = args.GetProperty("json").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var json = args.GetProperty("json").SafeGetString() ?? "";
try
{
return Task.FromResult(action switch
{
"validate" => Validate(json),
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
"format" => Format(json, args.SafeTryGetProperty("minify", out var m) && m.SafeGetString() == "true"),
"query" => Query(json, args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""),
"keys" => Keys(json),
"convert" => Convert(json, args.TryGetProperty("target_format", out var tf) ? tf.GetString() ?? "csv" : "csv"),
"convert" => Convert(json, args.SafeTryGetProperty("target_format", out var tf) ? tf.SafeGetString() ?? "csv" : "csv"),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
@@ -122,7 +122,7 @@ public class JsonTool : IAgentTool
}
else
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Key, out var prop))
if (current.ValueKind != JsonValueKind.Object || !current.SafeTryGetProperty(segment.Key, out var prop))
return ToolResult.Fail($"Key '{segment.Key}' not found");
current = prop;
}
@@ -130,7 +130,7 @@ public class JsonTool : IAgentTool
var value = current.ValueKind switch
{
JsonValueKind.String => current.GetString() ?? "",
JsonValueKind.String => current.SafeGetString() ?? "",
JsonValueKind.Number => current.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
@@ -192,9 +192,9 @@ public class JsonTool : IAgentTool
{
var values = allKeys.Select(k =>
{
if (!item.TryGetProperty(k, out var v)) return "\"\"";
if (!item.SafeTryGetProperty(k, out var v)) return "\"\"";
return v.ValueKind == JsonValueKind.String
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
? $"\"{v.SafeGetString()?.Replace("\"", "\"\"") ?? ""}\""
: v.GetRawText();
});
sb.AppendLine(string.Join(",", values));
+4 -4
View File
@@ -57,10 +57,10 @@ public class LspTool : IAgentTool, IDisposable
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
var line = args.SafeTryGetProperty("line", out var l) ? l.GetInt32() : 0;
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
+33 -33
View File
@@ -57,30 +57,30 @@ public class MarkdownSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
// path 미제공 시 title에서 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var baseTitle = (args.TryGetProperty("title", out var autoT) ? autoT.GetString() : null) ?? "document";
var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document";
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
}
var title = args.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -109,7 +109,7 @@ public class MarkdownSkill : IAgentTool
foreach (var prop in frontEl.EnumerateObject())
{
var val = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? ""
? prop.Value.SafeGetString() ?? ""
: prop.Value.ToString();
// 값에 특수 문자가 있으면 인용
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
@@ -143,8 +143,8 @@ public class MarkdownSkill : IAgentTool
// 섹션 렌더링
foreach (var section in sectionsEl.EnumerateArray())
{
if (!section.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString()?.ToLowerInvariant() ?? "";
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
switch (type)
{
@@ -187,7 +187,7 @@ public class MarkdownSkill : IAgentTool
if (useToc)
{
// content에서 헤딩 파싱하여 TOC 생성
var raw = contentEl.GetString() ?? "";
var raw = contentEl.SafeGetString() ?? "";
var headings = ParseHeadingsFromContent(raw);
if (headings.Count > 0)
{
@@ -195,7 +195,7 @@ public class MarkdownSkill : IAgentTool
sb.AppendLine();
}
}
sb.Append(contentEl.GetString() ?? "");
sb.Append(contentEl.SafeGetString() ?? "");
}
// ── 파일 쓰기 ──────────────────────────────────────────────────
@@ -221,9 +221,9 @@ public class MarkdownSkill : IAgentTool
var list = new List<(int, string)>();
foreach (var s in sections.EnumerateArray())
{
if (!s.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrEmpty(text))
list.Add((Math.Clamp(level, 1, 6), text));
}
@@ -273,15 +273,15 @@ public class MarkdownSkill : IAgentTool
private static void RenderHeading(StringBuilder sb, JsonElement s)
{
var level = s.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
sb.AppendLine($"{new string('#', level)} {text}");
sb.AppendLine();
}
private static void RenderParagraph(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
@@ -291,12 +291,12 @@ public class MarkdownSkill : IAgentTool
private static void RenderTable(StringBuilder sb, JsonElement s)
{
if (!s.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return;
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return;
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.GetString() ?? "")).ToList();
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList();
if (headers.Count == 0) return;
// 헤더 행
@@ -322,14 +322,14 @@ public class MarkdownSkill : IAgentTool
private static void RenderList(StringBuilder sb, JsonElement s)
{
if (!s.TryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
return;
var ordered = s.TryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
int idx = 1;
foreach (var item in itemsEl.EnumerateArray())
{
var text = item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : item.ToString();
var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString();
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
var prefix = ordered ? $"{idx++}. " : "- ";
if (text.StartsWith(" ") || text.StartsWith("\t"))
@@ -342,8 +342,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderCallout(StringBuilder sb, JsonElement s)
{
var style = s.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
// 표준 GitHub/Obsidian 콜아웃 형식
sb.AppendLine($"> [!{style}]");
@@ -354,8 +354,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderCode(StringBuilder sb, JsonElement s)
{
var lang = s.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
sb.AppendLine($"```{lang}");
sb.AppendLine(code);
@@ -365,8 +365,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderQuote(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
foreach (var line in text.Split('\n'))
sb.AppendLine($"> {line}");
+2 -2
View File
@@ -32,8 +32,8 @@ public class MathTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var expression = args.GetProperty("expression").GetString() ?? "";
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
var expression = args.GetProperty("expression").SafeGetString() ?? "";
var precision = args.SafeTryGetProperty("precision", out var p) ? p.GetInt32() : 6;
if (string.IsNullOrWhiteSpace(expression))
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
@@ -29,8 +29,8 @@ public class McpListResourcesTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var serverName = args.ValueKind == JsonValueKind.Object &&
args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
args.SafeTryGetProperty("server_name", out var serverProp)
? serverProp.SafeGetString() ?? ""
: "";
var clients = _getClients()
@@ -35,13 +35,13 @@ public class McpReadResourceTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (args.ValueKind != JsonValueKind.Object ||
!args.TryGetProperty("uri", out var uriProp) ||
string.IsNullOrWhiteSpace(uriProp.GetString()))
!args.SafeTryGetProperty("uri", out var uriProp) ||
string.IsNullOrWhiteSpace(uriProp.SafeGetString()))
return ToolResult.Fail("uri가 필요합니다.");
var uri = uriProp.GetString()!;
var serverName = args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
var uri = uriProp.SafeGetString()!;
var serverName = args.SafeTryGetProperty("server_name", out var serverProp)
? serverProp.SafeGetString() ?? ""
: "";
var clients = _getClients()
+1 -1
View File
@@ -57,7 +57,7 @@ public class McpTool : IAgentTool
{
arguments[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString()!,
JsonValueKind.String => prop.Value.SafeGetString()!,
JsonValueKind.Number => prop.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
+11 -11
View File
@@ -51,9 +51,9 @@ public class MemoryTool : IAgentTool
memoryService.Load(context.WorkFolder);
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
return Task.FromResult(action switch
{
@@ -70,8 +70,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var type = args.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "fact" : "fact";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("content가 필요합니다.");
@@ -87,7 +87,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
{
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
@@ -150,7 +150,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
{
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("id가 필요합니다.");
@@ -161,8 +161,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(content))
@@ -176,8 +176,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(query))
@@ -191,7 +191,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteShowScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
@@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent;
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
public class MultiReadTool : IAgentTool
{
private const int MaxFiles = 20;
private const int MaxFiles = 8;
private const int DefaultMaxLines = 300;
private const int HardMaxLines = 2000;
@@ -51,7 +51,7 @@ public class MultiReadTool : IAgentTool
{
// --- Parse parameters ---
var maxLines = DefaultMaxLines;
if (args.TryGetProperty("max_lines", out var mlEl))
if (args.SafeTryGetProperty("max_lines", out var mlEl))
{
maxLines = mlEl.GetInt32();
if (maxLines <= 0) maxLines = DefaultMaxLines;
@@ -60,23 +60,23 @@ public class MultiReadTool : IAgentTool
// offset is 1-based; convert to 0-based skip count
var offsetParam = 1;
if (args.TryGetProperty("offset", out var offEl))
if (args.SafeTryGetProperty("offset", out var offEl))
{
offsetParam = offEl.GetInt32();
if (offsetParam < 1) offsetParam = 1;
}
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
var showEncoding = args.TryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
// --- Validate paths array ---
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
var rawPaths = new List<string>();
foreach (var p in pathsEl.EnumerateArray())
{
var s = p.GetString();
var s = p.SafeGetString();
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
}
+8 -9
View File
@@ -45,9 +45,9 @@ public class NotifyTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.GetProperty("title").GetString() ?? "알림";
var message = args.GetProperty("message").GetString() ?? "";
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
var title = args.GetProperty("title").SafeGetString() ?? "알림";
var message = args.GetProperty("message").SafeGetString() ?? "";
var level = args.SafeTryGetProperty("level", out var lv) ? lv.SafeGetString() ?? "info" : "info";
try
{
@@ -140,14 +140,13 @@ public class NotifyTool : IAgentTool
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
grid.Children.Add(toast);
// 5초 후 자동 제거 (페이드 아웃)
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
timer.Tick += (_, _) =>
// 5초 후 자동 제거 — DispatcherTimer 대신 애니메이션 Completed 사용하여 타이머 누적 방지
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300))
{
timer.Stop();
grid.Children.Remove(toast);
BeginTime = TimeSpan.FromSeconds(5),
};
timer.Start();
fadeOut.Completed += (_, _) => grid.Children.Remove(toast);
toast.BeginAnimation(System.Windows.UIElement.OpacityProperty, fadeOut);
}
}
}
@@ -28,7 +28,7 @@ public class OpenExternalTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
if (string.IsNullOrWhiteSpace(rawPath))
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
@@ -1,3 +1,5 @@
using System;
namespace AxCopilot.Services.Agent;
internal sealed record PermissionRequestPresentation(
@@ -20,104 +22,104 @@ internal static class PermissionRequestPresentationCatalog
if (tool.Contains("bash"))
return Build("bash", pending, "\uE756",
"Bash 실행 권한 요청", "Bash 실행 확인",
" 명령을 실행하기 전에 확인이 필요합니다.",
"Bash 실행이 승인되어 계속 진행합니다.",
"명령과 작업 위치를 확인하세요.",
"Bash 명령을 실행하기 전에 사용자 확인이 필요합니다.",
"Bash 실행을 확인해 주시면 같은 흐름으로 이어집니다.",
"명령과 작업 위치를 먼저 확인해 주세요.",
"#FEF2F2", "#DC2626", "high", false);
if (tool.Contains("powershell"))
return Build("powershell", pending, "\uE756",
"PowerShell 권한 요청", "PowerShell 실행 확인",
"PowerShell 명령을 실행하기 전에 확인이 필요합니다.",
"PowerShell 실행 승인되어 계속 진행합니다.",
"스크립트와 실행 범위를 확인세요.",
"PowerShell 명령을 실행하기 전에 사용자 확인이 필요합니다.",
"PowerShell 실행 승인하면 같은 작업을 이어서 진행합니다.",
"스크립트와 실행 범위를 확인해 주세요.",
"#FEF2F2", "#DC2626", "high", false);
if (tool.Contains("process") || tool.Contains("build") || tool.Contains("test"))
return Build("command", pending, "\uE756",
"명령 실행 권한 요청", "명령 실행 확인",
"명령 또는 빌드 작업 실행 전에 확인이 필요합니다.",
"명령 실행 승인되어 계속 진행합니다.",
"실행 명령과 영향 범위를 확인하세요.",
"명령이나 빌드 작업 실행하기 전에 확인이 필요합니다.",
"명령 실행 승인하면 현재 흐름을 이어서 진행합니다.",
"실행 명령과 영향 범위를 먼저 확인해 주세요.",
"#FEF2F2", "#DC2626", "high", false);
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
return Build("web_fetch", pending, "\uE774",
"웹 요청 권한 요청", "웹 요청 확인",
"외부 요청을 보내기 전에 확인이 필요합니다.",
"웹 요청이 승인되어 계속 진행합니다.",
"조회 URL과 전송 범위를 확인세요.",
"외부 요청을 보내기 전에 확인이 필요합니다.",
"요청 대상을 확인해 주시면 이어서 처리합니다.",
"조회 URL과 전송 범위를 확인해 주세요.",
"#FFF7ED", "#C2410C", "medium", false);
if (tool.Contains("mcp"))
return Build("mcp", pending, "\uE943",
"MCP 도구 권한 요청", "MCP 도구 확인",
"연결된 MCP 도구 사용 전에 확인이 필요합니다.",
"MCP 도구 사용 승인되어 계속 진행합니다.",
"서버와 호출 도를 확인세요.",
"연결된 MCP 도구 사용하기 전에 확인이 필요합니다.",
"MCP 도구 사용 승인하면 흐름을 이어서 진행합니다.",
"서버와 호출 도를 확인해 주세요.",
"#F5F3FF", "#7C3AED", "medium", false);
if (tool.Contains("skill"))
return Build("skill", pending, "\uE8A5",
"스킬 실행 권한 요청", "스킬 실행 확인",
"연결된 스킬을 실행하기 전에 확인이 필요합니다.",
"스킬 실행 승인되어 계속 진행합니다.",
"용 도구와 실행 컨텍스트를 확인세요.",
"스킬 실행 승인하면 같은 작업 흐름을 이어갑니다.",
"용 도구와 실행 컨텍스트를 확인해 주세요.",
"#F5F3FF", "#7C3AED", "medium", false);
if (tool.Contains("ask"))
return Build("question", pending, "\uE897",
"의견 요청 확인", "의견 요청 완료",
"사용자에게 선택이나 답변을 요청합니다.",
"사용자 답변을 받 다음 단계로 진행합니다.",
"질문 의도와 선택지를 확인세요.",
"질문 요청 확인", "질문 요청 완료",
"사용자에게 추가 질문이나 선택을 요청합니다.",
"응답을 받으면 다음 단계로 자동으로 이어집니다.",
"질문 의도와 선택지를 확인해 주세요.",
"#EFF6FF", "#2563EB", "low", false);
if (tool.Contains("file_edit") || tool.Contains("edit"))
return Build("file_edit", pending, "\uE70F",
"파일 수정 권한 요청", "파일 수정 확인",
"파일을 변경하기 전에 확인이 필요합니다.",
"파일 수정이 승인되어 계속 진행합니다.",
"변경 diff와 대상 파일을 확인하세요.",
"파일을 변경하기 전에 사용자 확인이 필요합니다.",
"변경 내용을 확인하면 같은 흐름으로 이어집니다.",
"변경 diff와 대상 파일을 먼저 확인해 주세요.",
"#FFF7ED", "#C2410C", "high", true);
if (tool.Contains("file_write") || tool.Contains("write"))
return Build("file_write", pending, "\uE70F",
"파일 쓰기 권한 요청", "파일 쓰기 확인",
"새 파일 작성 또는 덮어쓰기 전에 확인이 필요합니다.",
"파일 쓰기 승인되어 계속 진행합니다.",
"성 위치와 내용 미리보기를 확인세요.",
"새 파일 생성이나 덮어쓰기 전에 사용자 확인이 필요합니다.",
"파일 쓰기 승인하면 같은 흐름을 이어갑니다.",
"성 위치와 내용 미리보기를 확인해 주세요.",
"#FFF7ED", "#C2410C", "high", true);
if (tool.Contains("git"))
return Build("git", pending, "\uE8A7",
"Git 작업 권한 요청", "Git 작업 확인",
"브랜치나 커밋 상태를 바꾸기 전에 확인이 필요합니다.",
"Git 작업 승인되어 계속 진행합니다.",
"브랜치와 변경 범위를 확인세요.",
"브랜치나 작업트리 상태를 바꾸기 전에 확인이 필요합니다.",
"Git 작업 승인하면 같은 흐름을 이어갑니다.",
"브랜치와 변경 범위를 확인해 주세요.",
"#EFF6FF", "#2563EB", "medium", false);
if (tool.Contains("document") || tool.Contains("template") || tool.Contains("format"))
return Build("document", pending, "\uE8A5",
"문서 작업 권한 요청", "문서 작업 확인",
"문서 생성 또는 변환 작업 전에 확인이 필요합니다.",
"문서 작업 승인되어 계속 진행합니다.",
"출력 형식과 저장 위치를 확인세요.",
"문서 생성이나 변환 작업 전에 확인이 필요합니다.",
"문서 작업 승인하면 같은 흐름을 이어갑니다.",
"출력 형식과 대상 위치를 확인해 주세요.",
"#FFF7ED", "#C2410C", "medium", false);
if (tool.Contains("file") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder"))
return Build("filesystem", pending, "\uE8A5",
"파일 접근 권한 요청", "파일 접근 확인",
"폴더나 파일 내용을 읽기 전에 확인이 필요합니다.",
"파일 접근 승인되어 계속 진행합니다.",
"읽기 범위와 접근 경로를 확인세요.",
"파일이나 폴더를 읽기 전에 확인이 필요합니다.",
"파일 접근 승인하면 같은 흐름으로 이어집니다.",
"읽기 범위와 경로를 확인해 주세요.",
"#FFF7ED", "#C2410C", "medium", false);
return Build("generic", pending, "\uE897",
"권한 요청", "권한 확인",
"계속 진행하기 전에 사용자 확인이 필요합니다.",
"요청 승인되어 계속 진행합니다.",
"실행 의도와 대상 범위를 확인세요.",
"계속 진행하기 전에 사용자 확인이 필요합니다.",
"요청 승인하면 같은 흐름으로 이어집니다.",
"실행 의도와 대상 범위를 확인해 주세요.",
"#FFF7ED", "#C2410C", "medium", false);
}
+12 -12
View File
@@ -63,9 +63,9 @@ public class PlaybookTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
@@ -84,8 +84,8 @@ public class PlaybookTool : IAgentTool
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name))
return ToolResult.Fail("플레이북 name이 필요합니다.");
@@ -94,11 +94,11 @@ public class PlaybookTool : IAgentTool
// steps 파싱
var steps = new List<string>();
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
{
foreach (var step in stepsEl.EnumerateArray())
{
var s = step.GetString();
var s = step.SafeGetString();
if (!string.IsNullOrWhiteSpace(s))
steps.Add(s);
}
@@ -109,11 +109,11 @@ public class PlaybookTool : IAgentTool
// tools_used 파싱
var toolsUsed = new List<string>();
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tool in toolsEl.EnumerateArray())
{
var t = tool.GetString();
var t = tool.SafeGetString();
if (!string.IsNullOrWhiteSpace(t))
toolsUsed.Add(t);
}
@@ -192,10 +192,10 @@ public class PlaybookTool : IAgentTool
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
var playbook = await FindPlaybookById(playbookDir, id, ct);
if (playbook == null)
@@ -222,10 +222,10 @@ public class PlaybookTool : IAgentTool
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (!Directory.Exists(playbookDir))
return ToolResult.Fail("저장된 플레이북이 없습니다.");
+21 -21
View File
@@ -298,12 +298,12 @@ public class PptxSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
var presTitle = args.SafeTryGetProperty("title", out var tt) ? tt.SafeGetString() ?? "Presentation" : "Presentation";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -311,10 +311,10 @@ public class PptxSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
}
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
var aspect = args.TryGetProperty("aspect", out var asp) ? asp.GetString() ?? "widescreen" : "widescreen";
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("slides 배열이 필요합니다.");
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -333,10 +333,10 @@ public class PptxSkill : IAgentTool
// ── 테마 결정 우선순위: theme_file > custom_colors > theme 이름 ──────
FullTheme fullTheme;
if (args.TryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.GetString()))
if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
{
// 기존 PPTX에서 테마 색상 추출 → default layout (professional)
var tfPath = FileReadTool.ResolvePath(tfEl.GetString()!, context.WorkFolder);
var tfPath = FileReadTool.ResolvePath(tfEl.SafeGetString()!, context.WorkFolder);
var extracted = ExtractThemeFromPptx(tfPath);
var baseLayout = FullThemes["professional"].Layout;
fullTheme = extracted != null
@@ -344,12 +344,12 @@ public class PptxSkill : IAgentTool
: FullThemes["professional"];
}
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
&& args.TryGetProperty("custom_colors", out var ccEl)
&& args.SafeTryGetProperty("custom_colors", out var ccEl)
&& ccEl.ValueKind == JsonValueKind.Object)
{
// 사용자 지정 색상 → default layout (professional)
static string Hex(JsonElement obj, string key, string fallback) =>
obj.TryGetProperty(key, out var v) ? (v.GetString() ?? fallback).TrimStart('#') : fallback;
obj.SafeTryGetProperty(key, out var v) ? (v.SafeGetString() ?? fallback).TrimStart('#') : fallback;
var customColors = new ThemeColors(
Primary: Hex(ccEl, "primary", "1F4E79"),
Accent: Hex(ccEl, "accent", "2E75B6"),
@@ -441,7 +441,7 @@ public class PptxSkill : IAgentTool
foreach (var slideEl in slidesEl.EnumerateArray())
{
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
var layout = slideEl.SafeTryGetProperty("layout", out var lay) ? lay.SafeGetString() ?? "content" : "content";
var slidePart = presPart.AddNewPart<SlidePart>();
slidePart.AddPart(layoutPart);
@@ -480,11 +480,11 @@ public class PptxSkill : IAgentTool
}
// 발표자 노트
if (slideEl.TryGetProperty("notes", out var notesEl) &&
if (slideEl.SafeTryGetProperty("notes", out var notesEl) &&
notesEl.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(notesEl.GetString()))
!string.IsNullOrWhiteSpace(notesEl.SafeGetString()))
{
AddNotesSlide(slidePart, notesEl.GetString()!);
AddNotesSlide(slidePart, notesEl.SafeGetString()!);
}
slidePart.Slide.Save();
@@ -928,11 +928,11 @@ public class PptxSkill : IAgentTool
private static void BuildTableSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
{
var title = Str(s, "title");
var headers = s.TryGetProperty("headers", out var hEl)
? hEl.EnumerateArray().Select(x => x.GetString() ?? "").ToList()
var headers = s.SafeTryGetProperty("headers", out var hEl)
? hEl.EnumerateArray().Select(x => x.SafeGetString() ?? "").ToList()
: new List<string>();
var rows = s.TryGetProperty("rows", out var rEl)
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.GetString() ?? "").ToList()).ToList()
var rows = s.SafeTryGetProperty("rows", out var rEl)
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
: new List<List<string>>();
const long M = 450000;
@@ -1233,8 +1233,8 @@ public class PptxSkill : IAgentTool
// ══════════════════════════════════════════════════════════════════════════
private static string Str(JsonElement e, string key)
=> e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? ""
=> e.SafeTryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.SafeGetString() ?? ""
: "";
private static void AddNotesSlide(SlidePart slidePart, string notes)
@@ -0,0 +1,187 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
namespace AxCopilot.Services.Agent;
/// <summary>PPTX 파일을 HTML 슬라이드 카드로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class PptxToHtmlConverter
{
public static string Convert(string pptxPath)
{
try
{
using var doc = PresentationDocument.Open(pptxPath, false);
var presentationPart = doc.PresentationPart;
if (presentationPart?.Presentation?.SlideIdList == null)
return WrapHtml("<p style='color:#888'>프레젠테이션 내용이 없습니다.</p>");
var slideIds = presentationPart.Presentation.SlideIdList.Elements<SlideId>().ToList();
if (slideIds.Count == 0)
return WrapHtml("<p style='color:#888'>슬라이드가 없습니다.</p>");
var sb = new StringBuilder();
sb.AppendLine($"<div class='slide-info'>{slideIds.Count}개 슬라이드</div>");
for (int i = 0; i < slideIds.Count; i++)
{
var slideId = slideIds[i];
var relId = slideId.RelationshipId?.Value;
if (string.IsNullOrEmpty(relId)) continue;
SlidePart slidePart;
try { slidePart = (SlidePart)presentationPart.GetPartById(relId); }
catch { continue; }
sb.AppendLine(RenderSlide(slidePart, i + 1, doc));
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>PPTX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderSlide(SlidePart slidePart, int slideNumber, PresentationDocument doc)
{
var sb = new StringBuilder();
sb.AppendLine($"<div class='slide-card'>");
sb.AppendLine($"<div class='slide-number'>슬라이드 {slideNumber}</div>");
var slide = slidePart.Slide;
if (slide?.CommonSlideData?.ShapeTree == null)
{
sb.AppendLine("<p class='empty'>내용 없음</p>");
sb.AppendLine("</div>");
return sb.ToString();
}
var shapes = slide.CommonSlideData.ShapeTree.Elements<Shape>().ToList();
var titleRendered = false;
foreach (var shape in shapes)
{
var textBody = shape.TextBody;
if (textBody == null) continue;
var paragraphs = textBody.Elements<DocumentFormat.OpenXml.Drawing.Paragraph>().ToList();
if (paragraphs.Count == 0) continue;
// 제목 판별: 첫 번째 shape이고 placeholder가 title 유형인 경우
var isTitle = !titleRendered && IsPlaceholderTitle(shape);
foreach (var para in paragraphs)
{
var text = ExtractParagraphText(para);
if (string.IsNullOrWhiteSpace(text)) continue;
if (isTitle)
{
sb.AppendLine($"<h2 class='slide-title'>{Escape(text)}</h2>");
titleRendered = true;
isTitle = false;
}
else
{
sb.AppendLine($"<p>{Escape(text)}</p>");
}
}
}
// 이미지 추출
var pictures = slide.CommonSlideData.ShapeTree.Elements<Picture>().ToList();
foreach (var pic in pictures)
{
var img = ExtractImage(pic, slidePart);
if (img != null)
sb.AppendLine(img);
}
sb.AppendLine("</div>");
return sb.ToString();
}
private static bool IsPlaceholderTitle(Shape shape)
{
var nvSpPr = shape.NonVisualShapeProperties;
var ph = nvSpPr?.ApplicationNonVisualDrawingProperties?
.GetFirstChild<PlaceholderShape>();
if (ph == null) return false;
var phType = ph.Type?.Value;
return phType == PlaceholderValues.Title
|| phType == PlaceholderValues.CenteredTitle;
}
private static string ExtractParagraphText(DocumentFormat.OpenXml.Drawing.Paragraph para)
{
var sb = new StringBuilder();
foreach (var run in para.Elements<DocumentFormat.OpenXml.Drawing.Run>())
{
var text = run.Text?.Text;
if (!string.IsNullOrEmpty(text))
sb.Append(text);
}
return sb.ToString().Trim();
}
private static string? ExtractImage(Picture pic, SlidePart slidePart)
{
try
{
var blipFill = pic.BlipFill;
var blip = blipFill?.Blip;
if (blip?.Embed?.Value == null) return null;
var part = slidePart.GetPartById(blip.Embed.Value);
if (part == null) return null;
using var stream = part.GetStream();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var base64 = System.Convert.ToBase64String(ms.ToArray());
var mime = part.ContentType ?? "image/png";
return $"<img src='data:{mime};base64,{base64}' class='slide-img' />";
}
catch
{
return null;
}
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a;
line-height: 1.6; padding: 20px; font-size: 13px; }}
.slide-info {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; text-align: center; }}
.slide-card {{ background: #fff; border-radius: 12px; padding: 28px 32px; margin-bottom: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08); min-height: 120px;
border: 1px solid #e5e7eb; }}
.slide-number {{ color: #9ca3af; font-size: 10px; font-weight: 600; margin-bottom: 8px;
text-transform: uppercase; letter-spacing: 1px; }}
.slide-title {{ font-size: 18px; font-weight: 700; color: #111; margin-bottom: 10px; }}
.slide-card p {{ margin: 4px 0; color: #374151; }}
.slide-img {{ max-width: 100%; height: auto; margin: 10px 0; border-radius: 6px; }}
.empty {{ color: #d1d5db; font-style: italic; }}
</style>
</head>
<body>
{body}
</body>
</html>";
}
}
+4 -4
View File
@@ -37,11 +37,11 @@ public class ProcessTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("command", out var cmdEl))
if (!args.SafeTryGetProperty("command", out var cmdEl))
return ToolResult.Fail("command가 필요합니다.");
var command = cmdEl.GetString() ?? "";
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
var command = cmdEl.SafeGetString() ?? "";
var shell = args.SafeTryGetProperty("shell", out var sh) ? sh.SafeGetString() ?? "cmd" : "cmd";
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
if (string.IsNullOrWhiteSpace(command))
return ToolResult.Fail("명령이 비어 있습니다.");
@@ -53,10 +53,10 @@ public class ProjectRuleTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
var section = args.SafeTryGetProperty("section", out var s) ? s.SafeGetString() ?? "" : "";
var ruleName = args.SafeTryGetProperty("rule_name", out var rn) ? rn.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
+5 -5
View File
@@ -56,11 +56,11 @@ public class RegexTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var pattern = args.GetProperty("pattern").GetString() ?? "";
var text = args.GetProperty("text").GetString() ?? "";
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var text = args.GetProperty("text").SafeGetString() ?? "";
var replacement = args.SafeTryGetProperty("replacement", out var r) ? r.SafeGetString() ?? "" : "";
var flags = args.SafeTryGetProperty("flags", out var f) ? f.SafeGetString() ?? "" : "";
try
{
@@ -42,8 +42,8 @@ public class SkillManagerTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var skillName = args.SafeTryGetProperty("skill_name", out var s) ? s.SafeGetString() ?? "" : "";
return action switch
{
@@ -59,9 +59,9 @@ public class SnippetRunnerTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var language = args.GetProperty("language").GetString() ?? "";
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
var language = args.GetProperty("language").SafeGetString() ?? "";
var code = args.SafeTryGetProperty("code", out var c) ? c.SafeGetString() ?? "" : "";
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
if (string.IsNullOrWhiteSpace(code))
return ToolResult.Fail("code가 비어 있습니다.");
+8 -8
View File
@@ -50,8 +50,8 @@ public class SqlTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var dbPath = args.GetProperty("db_path").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var dbPath = args.GetProperty("db_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(dbPath))
dbPath = Path.Combine(context.WorkFolder, dbPath);
@@ -86,10 +86,10 @@ public class SqlTool : IAgentTool
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for query action");
var sql = sqlProp.GetString() ?? "";
var sql = sqlProp.SafeGetString() ?? "";
// SELECT만 허용
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
@@ -97,8 +97,8 @@ public class SqlTool : IAgentTool
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
var maxRows = args.TryGetProperty("max_rows", out var mr) && int.TryParse(mr.GetString(), out var mrv)
? Math.Min(mrv, 1000) : 100;
var maxRows = args.SafeTryGetProperty("max_rows", out var mr)
? Math.Min(mr.SafeGetInt32(100), 1000) : 100;
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
@@ -135,10 +135,10 @@ public class SqlTool : IAgentTool
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for execute action");
var sql = sqlProp.GetString() ?? "";
var sql = sqlProp.SafeGetString() ?? "";
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
var trimmed = sql.TrimStart().ToUpperInvariant();
@@ -0,0 +1,166 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordinator
{
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
"hash_tool", "image_analyze", "multi_read"
};
private readonly LlmService _llm;
private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName;
private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync;
private readonly Action<AgentEventType, string, string> _emitEvent;
private readonly Func<string?, bool> _isContextOverflowError;
private readonly Func<List<ChatMessage>, bool> _forceContextRecovery;
private readonly Func<Exception, bool> _isTransientLlmError;
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
public StreamingToolExecutionCoordinator(
LlmService llm,
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
Action<AgentEventType, string, string> emitEvent,
Func<string?, bool> isContextOverflowError,
Func<List<ChatMessage>, bool> forceContextRecovery,
Func<Exception, bool> isTransientLlmError,
Func<int, Exception, int> computeTransientBackoffDelayMs)
{
_llm = llm;
_resolveRequestedToolName = resolveRequestedToolName;
_executeToolAsync = executeToolAsync;
_emitEvent = emitEvent;
_isContextOverflowError = isContextOverflowError;
_forceContextRecovery = forceContextRecovery;
_isTransientLlmError = isTransientLlmError;
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
}
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct)
{
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var resolvedToolName = _resolveRequestedToolName(block.ToolName, activeToolNames);
block.ResolvedToolName = resolvedToolName;
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
return null;
_emitEvent(
AgentEventType.Thinking,
resolvedToolName,
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
var sw = Stopwatch.StartNew();
try
{
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);
}
catch (Exception ex)
{
sw.Stop();
return new LlmService.ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
sw.ElapsedMilliseconds,
resolvedToolName);
}
}
public async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
AgentLoopService.RunState? runState = null,
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
{
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
while (true)
{
try
{
if (onStreamEventAsync == null)
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
var blocks = new List<LlmService.ContentBlock>();
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))
{
textBuilder.Append(evt.Text);
}
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{
blocks.Add(evt.ToolCall);
}
}
var result = new List<LlmService.ContentBlock>();
var text = textBuilder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(text))
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
result.AddRange(blocks);
return result;
}
catch (Exception ex)
{
if (_isContextOverflowError(ex.Message)
&& contextRecoveryRetries < 2
&& _forceContextRecovery(messages))
{
contextRecoveryRetries++;
if (runState != null)
runState.ContextRecoveryAttempts = contextRecoveryRetries;
_emitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
continue;
}
if (ct.IsCancellationRequested)
throw;
if (_isTransientLlmError(ex) && transientRetries < 3)
{
transientRetries++;
if (runState != null)
runState.TransientLlmErrorRetries = transientRetries;
var delayMs = _computeTransientBackoffDelayMs(transientRetries, ex);
_emitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
await Task.Delay(delayMs, ct);
continue;
}
throw;
}
}
}
}
+29 -9
View File
@@ -44,8 +44,8 @@ public class SubAgentTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
@@ -72,11 +72,15 @@ public class SubAgentTool : IAgentTool
StartedAt = DateTime.Now,
};
// P2: 부모 취소 토큰 연동 — 부모 에이전트 중지 시 자식도 즉시 취소
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
subTask.Cts = cts;
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
{
try
{
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
@@ -89,6 +93,20 @@ public class SubAgentTool : IAgentTool
Timestamp = DateTime.Now,
});
}
catch (OperationCanceledException)
{
subTask.Result = "Cancelled: parent agent was stopped.";
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = $"Sub-agent '{id}' cancelled.",
Result = subTask.Result,
Timestamp = DateTime.Now,
});
}
catch (Exception ex)
{
subTask.Result = $"Error: {ex.Message}";
@@ -106,8 +124,9 @@ public class SubAgentTool : IAgentTool
finally
{
subTask.CompletedAt = DateTime.Now;
cts.Dispose();
}
}, CancellationToken.None);
}, cts.Token);
lock (_lock)
_activeTasks[id] = subTask;
@@ -125,7 +144,7 @@ public class SubAgentTool : IAgentTool
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct)
{
var settings = CreateSubAgentSettings(parentContext);
using var llm = new LlmService(settings);
@@ -150,7 +169,7 @@ public class SubAgentTool : IAgentTool
}
};
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
var finalText = await loop.RunAsync(messages, ct).ConfigureAwait(false);
var eventSummary = SummarizeEvents(loop.Events);
var sb = new StringBuilder();
@@ -451,16 +470,16 @@ public class WaitAgentsTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
List<string>? ids = null;
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = idsEl.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Select(x => x.SafeGetString() ?? "")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
var completedOnly = args.SafeTryGetProperty("completed_only", out var completedEl) &&
completedEl.ValueKind == JsonValueKind.True;
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
@@ -477,6 +496,7 @@ public class SubAgentTask
public bool Success { get; set; }
public string? Result { get; set; }
public Task? RunTask { get; set; }
public CancellationTokenSource? Cts { get; set; }
}
public enum SubAgentRunStatus
@@ -40,16 +40,16 @@ public class SuggestActionsTool : IAgentTool
{
try
{
if (!args.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
var actions = new List<Dictionary<string, string>>();
foreach (var item in actionsEl.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
var label = item.SafeTryGetProperty("label", out var l) ? l.SafeGetString() ?? "" : "";
var command = item.SafeTryGetProperty("command", out var c) ? c.SafeGetString() ?? "" : "";
var icon = item.SafeTryGetProperty("icon", out var i) ? i.SafeGetString() ?? "" : "";
var priority = item.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
if (string.IsNullOrWhiteSpace(label))
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
@@ -76,7 +76,7 @@ public class SuggestActionsTool : IAgentTool
if (actions.Count < 1 || actions.Count > 5)
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
var contextSummary = args.TryGetProperty("context", out var ctx) ? ctx.GetString() ?? "" : "";
var contextSummary = args.SafeTryGetProperty("context", out var ctx) ? ctx.SafeGetString() ?? "" : "";
// 구조화된 JSON 응답 생성
var result = new Dictionary<string, object>
@@ -23,14 +23,14 @@ public sealed class TaskCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.TryGetProperty("title", out var titleEl) ? (titleEl.GetString() ?? "").Trim() : "";
var title = args.SafeTryGetProperty("title", out var titleEl) ? (titleEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(title))
return Task.FromResult(ToolResult.Fail("title is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
var description = args.SafeTryGetProperty("description", out var descEl) ? (descEl.SafeGetString() ?? "").Trim() : "";
var priority = args.SafeTryGetProperty("priority", out var priEl) ? (priEl.SafeGetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
if (!TaskBoardStore.IsValidPriority(priority))
priority = "medium";
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed class TaskGetTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
+1 -1
View File
@@ -25,7 +25,7 @@ public sealed class TaskListTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var status = args.TryGetProperty("status", out var statusEl) ? (statusEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
var status = args.SafeTryGetProperty("status", out var statusEl) ? (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
if (!string.IsNullOrWhiteSpace(status))
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();
@@ -24,19 +24,19 @@ public sealed class TaskOutputTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (!args.TryGetProperty("output", out var outEl))
if (!args.SafeTryGetProperty("output", out var outEl))
return Task.FromResult(ToolResult.Fail("output is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var output = (outEl.GetString() ?? "").Trim();
var output = (outEl.SafeGetString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(output))
return Task.FromResult(ToolResult.Fail("output is empty."));
var append = !args.TryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
var append = !args.SafeTryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)
+2 -2
View File
@@ -22,13 +22,13 @@ public sealed class TaskStopTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var reason = args.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : "";
var reason = args.SafeTryGetProperty("reason", out var reasonEl) ? (reasonEl.SafeGetString() ?? "").Trim() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)
@@ -54,7 +54,7 @@ public class TaskTrackerTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -75,8 +75,8 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
{
var extStr = args.TryGetProperty("extensions", out var e)
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
var extStr = args.SafeTryGetProperty("extensions", out var e)
? e.SafeGetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
: ".cs,.py,.js,.ts,.java,.cpp,.c";
var exts = new HashSet<string>(
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
@@ -128,11 +128,11 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult AddTask(JsonElement args, AgentContext context)
{
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(title))
return ToolResult.Fail("'title'이 필요합니다.");
var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
var priority = args.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
var tasks = LoadTasks(context);
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
@@ -167,7 +167,7 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult MarkDone(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("'id'가 필요합니다.");
var id = idEl.GetInt32();
@@ -26,7 +26,7 @@ public sealed class TaskUpdateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
@@ -37,27 +37,27 @@ public sealed class TaskUpdateTool : IAgentTool
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
if (args.TryGetProperty("status", out var statusEl))
if (args.SafeTryGetProperty("status", out var statusEl))
{
var status = (statusEl.GetString() ?? "").Trim().ToLowerInvariant();
var status = (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidStatus(status))
return Task.FromResult(ToolResult.Fail("Invalid status."));
task.Status = status;
}
if (args.TryGetProperty("title", out var titleEl))
if (args.SafeTryGetProperty("title", out var titleEl))
{
var title = (titleEl.GetString() ?? "").Trim();
var title = (titleEl.SafeGetString() ?? "").Trim();
if (!string.IsNullOrWhiteSpace(title))
task.Title = title;
}
if (args.TryGetProperty("description", out var descEl))
task.Description = (descEl.GetString() ?? "").Trim();
if (args.SafeTryGetProperty("description", out var descEl))
task.Description = (descEl.SafeGetString() ?? "").Trim();
if (args.TryGetProperty("priority", out var priEl))
if (args.SafeTryGetProperty("priority", out var priEl))
{
var priority = (priEl.GetString() ?? "").Trim().ToLowerInvariant();
var priority = (priEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidPriority(priority))
return Task.FromResult(ToolResult.Fail("Invalid priority."));
task.Priority = priority;
@@ -20,13 +20,13 @@ public sealed class TeamCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("name is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var role = args.TryGetProperty("role", out var r) ? (r.GetString() ?? "worker").Trim() : "worker";
var role = args.SafeTryGetProperty("role", out var r) ? (r.SafeGetString() ?? "worker").Trim() : "worker";
var members = TeamStore.Load(context.WorkFolder);
var member = new TeamStore.Member { Name = name, Role = role };
members.Add(member);
@@ -24,8 +24,8 @@ public sealed class TeamDeleteTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));
@@ -51,25 +51,25 @@ public class TemplateRenderTool : IAgentTool
{
// 템플릿 텍스트 로드
string template;
if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString()))
if (args.SafeTryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.SafeGetString()))
{
var templatePath = FileReadTool.ResolvePath(tpEl.GetString()!, context.WorkFolder);
var templatePath = FileReadTool.ResolvePath(tpEl.SafeGetString()!, context.WorkFolder);
if (!context.IsPathAllowed(templatePath))
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
if (!File.Exists(templatePath))
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
}
else if (args.TryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.GetString()))
else if (args.SafeTryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.SafeGetString()))
{
template = ttEl.GetString()!;
template = ttEl.SafeGetString()!;
}
else
{
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
}
if (!args.TryGetProperty("variables", out var varsEl))
if (!args.SafeTryGetProperty("variables", out var varsEl))
return ToolResult.Fail("variables가 필요합니다.");
try
@@ -78,9 +78,9 @@ public class TemplateRenderTool : IAgentTool
var rendered = Render(template, varsEl);
// 출력
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
if (args.SafeTryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.SafeGetString()))
{
var outputPath = FileReadTool.ResolvePath(opEl.GetString()!, context.WorkFolder);
var outputPath = FileReadTool.ResolvePath(opEl.SafeGetString()!, context.WorkFolder);
if (context.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
if (!context.IsPathAllowed(outputPath))
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
@@ -117,7 +117,7 @@ public class TemplateRenderTool : IAgentTool
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return "";
if (!variables.SafeTryGetProperty(key, out var val)) return "";
if (val.ValueKind == JsonValueKind.Array)
{
@@ -167,11 +167,11 @@ public class TemplateRenderTool : IAgentTool
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return body;
if (!variables.SafeTryGetProperty(key, out var val)) return body;
if (val.ValueKind == JsonValueKind.False ||
val.ValueKind == JsonValueKind.Null ||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.GetString())))
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.SafeGetString())))
return body;
return "";
@@ -188,11 +188,11 @@ public class TemplateRenderTool : IAgentTool
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
{
var key = match.Groups[1].Value;
if (!variables.TryGetProperty(key, out var val)) return match.Value;
if (!variables.SafeTryGetProperty(key, out var val)) return match.Value;
return val.ValueKind switch
{
JsonValueKind.String => val.GetString() ?? "",
JsonValueKind.String => val.SafeGetString() ?? "",
JsonValueKind.Number => val.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
@@ -184,7 +184,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 16px; padding: 56px 52px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; }
@@ -220,7 +220,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 8px; padding: 48px;
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
border-top: 4px solid #1e3a5f; }
@@ -260,7 +260,7 @@ public static class TemplateService
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95);
.container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
h1 { font-size: 30px; font-weight: 700;
@@ -338,7 +338,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 860px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 4px; padding: 56px 52px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border: 1px solid #e8e4dd; }
@@ -377,7 +377,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #161b22;
.container { max-width: 1080px; margin: 0 auto; background: #161b22;
border-radius: 12px; padding: 52px;
border: 1px solid #30363d;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
@@ -418,7 +418,7 @@ public static class TemplateService
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 20px; padding: 52px;
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
@@ -457,7 +457,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0;
.container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
border-bottom: 3px solid #ff6600; }
@@ -499,7 +499,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 2px; padding: 0; overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
+3 -3
View File
@@ -46,7 +46,7 @@ public class TestLoopTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
return action switch
{
@@ -60,7 +60,7 @@ public class TestLoopTool : IAgentTool
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
{
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
@@ -146,7 +146,7 @@ public class TestLoopTool : IAgentTool
private static ToolResult AnalyzeTestOutput(JsonElement args)
{
var output = args.TryGetProperty("test_output", out var o) ? o.GetString() ?? "" : "";
var output = args.SafeTryGetProperty("test_output", out var o) ? o.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(output))
return ToolResult.Fail("test_output이 필요합니다.");
@@ -60,12 +60,12 @@ public class TextSummarizeTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
var style = args.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "bullet" : "bullet";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
var source = args.GetProperty("source").SafeGetString() ?? "";
var maxLength = args.SafeTryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
var style = args.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "bullet" : "bullet";
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
var focus = args.SafeTryGetProperty("focus", out var focEl) ? focEl.SafeGetString() ?? "" : "";
var bySections = args.SafeTryGetProperty("sections", out var secEl) && secEl.GetBoolean();
string text;
@@ -31,7 +31,7 @@ public sealed class TodoWriteTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var actionEl) ? (actionEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
var action = args.SafeTryGetProperty("action", out var actionEl) ? (actionEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(action))
return Task.FromResult(ToolResult.Fail("action is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
@@ -49,7 +49,7 @@ public sealed class TodoWriteTool : IAgentTool
private static ToolResult Add(string todoPath, JsonElement args)
{
var text = args.TryGetProperty("text", out var textEl) ? (textEl.GetString() ?? "").Trim() : "";
var text = args.SafeTryGetProperty("text", out var textEl) ? (textEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("text is required for add.");
@@ -86,7 +86,7 @@ public sealed class TodoWriteTool : IAgentTool
{
if (!File.Exists(todoPath))
return ToolResult.Fail("TODO file does not exist.");
if (!args.TryGetProperty("index", out var indexEl))
if (!args.SafeTryGetProperty("index", out var indexEl))
return ToolResult.Fail("index is required for done.");
var targetIndex = indexEl.GetInt32();
@@ -1,3 +1,5 @@
using System;
namespace AxCopilot.Services.Agent;
internal sealed record ToolResultPresentation(
@@ -29,7 +31,7 @@ internal static class ToolResultPresentationCatalog
"\uE711",
$"{baseLabel} 취소",
"요청이 중단되어 결과가 취소되었습니다.",
"필요하면 같은 요청을 다시 실행하세요.",
"필요하면 같은 요청을 다시 실행할 수 있습니다.",
"#F8FAFC",
"#475569",
"cancel",
@@ -45,7 +47,7 @@ internal static class ToolResultPresentationCatalog
"\uE783",
$"{baseLabel} 거부",
"권한이 거부되어 작업이 중단되었습니다.",
"권한 모드를 바꾸거나 다시 인하면 이어서 진행할 수 있습니다.",
"권한 모드를 바꾸거나 다시 인하면 이어서 진행할 수 있습니다.",
"#FEF2F2",
"#DC2626",
"reject",
@@ -58,8 +60,8 @@ internal static class ToolResultPresentationCatalog
return new ToolResultPresentation(
kind,
"\uE8D7",
$"{baseLabel} 승인 대기",
"다음 단계로 진행하려면 사용자 승인이 필요합니다.",
$"{baseLabel} 승인 필요",
"다음 단계로 진행하려면 사용자 승인이나 확인이 필요합니다.",
"승인 후 같은 작업 흐름이 이어집니다.",
"#FFF7ED",
"#C2410C",
@@ -74,8 +76,8 @@ internal static class ToolResultPresentationCatalog
kind,
"\uE7BA",
$"{baseLabel} 부분 완료",
"일부 단계만 완료되어 후속 확인이나 재실행이 필요할 수 있습니다.",
"남은 단계나 누락된 결과를 확인세요.",
"일부 단계만 완료되어 후속 확인이나 재시도가 필요할 수 있습니다.",
"후속 단계와 파일 결과를 확인해 주세요.",
"#FFFBEA",
"#A16207",
"partial",
@@ -114,7 +116,7 @@ internal static class ToolResultPresentationCatalog
return "file_edit";
if (tool.Contains("file_write"))
return "file_write";
if (tool.Contains("file_read") || tool.Contains("glob") || tool.Contains("grep"))
if (tool.Contains("file_read") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder_map") || tool.Contains("multi_read"))
return "filesystem";
if (tool.Contains("file"))
return "file";
@@ -141,111 +143,93 @@ internal static class ToolResultPresentationCatalog
return "generic";
}
private static string BuildSuccessLabel(string kind, string baseLabel)
private static string BuildSuccessLabel(string kind, string baseLabel) => kind switch
{
return kind switch
{
"file_edit" => "파일 수정 완료",
"file_write" => "파일 쓰기 완료",
"filesystem" => "파일 탐색 완료",
"file" => "파일 작업 완료",
"build_test" => "빌드/테스트 완료",
"git" => "Git 작업 완료",
"document" => "문서 작업 완료",
"skill" => "스킬 실행 완료",
"mcp" => "MCP 도구 완료",
"question" => "의견 요청 완료",
"web" => "웹 요청 완료",
"command" => "명령 실행 완료",
_ => baseLabel,
};
}
"file_edit" => "파일 수정 완료",
"file_write" => "파일 쓰기 완료",
"filesystem" => "파일 탐색 완료",
"file" => "파일 작업 완료",
"build_test" => "빌드/테스트 완료",
"git" => "Git 작업 완료",
"document" => "문서 작업 완료",
"skill" => "스킬 실행 완료",
"mcp" => "MCP 도구 완료",
"question" => "질문 요청 완료",
"web" => "웹 요청 완료",
"command" => "명령 실행 완료",
_ => baseLabel,
};
private static string BuildFailureLabel(string kind, string baseLabel)
private static string BuildFailureLabel(string kind, string baseLabel) => kind switch
{
return kind switch
{
"file_edit" => "파일 수정 실패",
"file_write" => "파일 쓰기 실패",
"filesystem" => "파일 탐색 실패",
"file" => "파일 작업 실패",
"build_test" => "빌드/테스트 실패",
"git" => "Git 작업 실패",
"document" => "문서 작업 실패",
"skill" => "스킬 실행 실패",
"mcp" => "MCP 도구 실패",
"question" => "의견 요청 실패",
"web" => "웹 요청 실패",
"command" => "명령 실행 실패",
_ => $"{baseLabel} 실패",
};
}
"file_edit" => "파일 수정 실패",
"file_write" => "파일 쓰기 실패",
"filesystem" => "파일 탐색 실패",
"file" => "파일 작업 실패",
"build_test" => "빌드/테스트 실패",
"git" => "Git 작업 실패",
"document" => "문서 작업 실패",
"skill" => "스킬 실행 실패",
"mcp" => "MCP 도구 실패",
"question" => "질문 요청 실패",
"web" => "웹 요청 실패",
"command" => "명령 실행 실패",
_ => $"{baseLabel} 실패",
};
private static string BuildSuccessDescription(string kind)
private static string BuildSuccessDescription(string kind) => kind switch
{
return kind switch
{
"file_edit" => "파일 수정 결과가 저장되었습니다.",
"file_write" => "파일 작성 결과가 저장되었습니다.",
"filesystem" => "파일과 폴더 정보를 성공적으로 읽었습니다.",
"file" => "파일 관련 작업이 정상적으로 끝났습니다.",
"build_test" => "빌드 또는 테스트 단계가 성공적으로 끝났습니다.",
"git" => "Git 관련 작업이 정상적으로 끝났습니다.",
"document" => "문서 생성 또는 변환 작업이 완료되었습니다.",
"skill" => "선택한 스킬이 정상적으로 실행되었습니다.",
"mcp" => "등록된 MCP 도구 호출이 성공적으로 끝났습니다.",
"question" => "사용자 응답을 받아 다음 단계로 넘어갈 수 있습니다.",
"web" => "요청이 정상적으로 끝났습니다.",
"command" => "명령 실행이 정상적으로 끝났습니다.",
_ => "요청한 작업이 정상적으로 완료되었습니다.",
};
}
"file_edit" => "파일 수정 결과가 정상적으로 반영되었습니다.",
"file_write" => "새 파일 생성이나 쓰기 작업이 완료되었습니다.",
"filesystem" => "질문과 관련된 파일과 구조 정보를 찾았습니다.",
"file" => "파일 관련 작업이 정상적으로 끝났습니다.",
"build_test" => "빌드나 테스트 단계가 정상적으로 끝났습니다.",
"git" => "저장소 상태 확인 또는 변경 작업이 완료되었습니다.",
"document" => "문서 산출물 생성 또는 조립이 완료되었습니다.",
"skill" => "선택한 스킬이 정상적으로 실행되었습니다.",
"mcp" => "등록된 MCP 도구 호출이 완료되었습니다.",
"question" => "사용자 응답을 받아 다음 단계로 이어갈 수 있습니다.",
"web" => "필요한 웹 정보 조회가 완료되었습니다.",
"command" => "명령 실행이 완료되어 결과를 확인할 수 있습니다.",
_ => "요청한 작업이 정상적으로 완료되었습니다.",
};
private static string BuildFailureDescription(string kind)
private static string BuildFailureDescription(string kind) => kind switch
{
return kind switch
{
"file_edit" => "파일 변경 과정에서 문제가 발생했습니다.",
"file_write" => "파일 작성 또는 저장 과정에서 문제가 발생했습니다.",
"filesystem" => "파일/폴더 접근 중 문제가 발생했습니다.",
"file" => "파일 처리문제가 발생했습니다.",
"build_test" => "빌드 또는 테스트 단계에서 실패가 발생했습니다.",
"git" => "Git 관련 작업이 실패했습니다.",
"document" => "문서 생성 또는 변환 작업이 실패했습니다.",
"skill" => "스킬 실행 중 문제가 발생했습니다.",
"mcp" => "MCP 도구 호출 중 문제가 발생했습니다.",
"question" => "사용자 의견 요청 과정에서 문제가 발생했습니다.",
"web" => "웹 요청 처리에 실패했습니다.",
"command" => "명령 실행 중 오류가 발생했습니다.",
_ => "작업 처리 중 오류가 발생했습니다.",
};
}
"file_edit" => "파일 변경 과정에서 문제가 발생했습니다.",
"file_write" => "파일 생성 또는 쓰기 과정에서 문제가 발생했습니다.",
"filesystem" => "파일이나 폴더를 확인하는 과정에서 문제가 발생했습니다.",
"file" => "파일 처리 중 문제가 발생했습니다.",
"build_test" => "빌드 또는 테스트 단계에서 실패가 발생했습니다.",
"git" => "Git 작업오류가 발생했습니다.",
"document" => "문서 생성 또는 조합 과정이 실패했습니다.",
"skill" => "스킬 실행 중 문제가 발생했습니다.",
"mcp" => "MCP 도구 호출 중 문제가 발생했습니다.",
"question" => "사용자 질문/응답 처리 단계에서 문제가 발생했습니다.",
"web" => "웹 요청 처리에 실패했습니다.",
"command" => "명령 실행 중 오류가 발생했습니다.",
_ => "작업 처리 중 오류가 발생했습니다.",
};
private static string BuildSuccessFollowUp(string kind)
private static string BuildSuccessFollowUp(string kind) => kind switch
{
return kind switch
{
"file_edit" or "file_write" => "변경 내용을 preview나 diff에서 다시 확인할 수 있습니다.",
"build_test" => "출력 로그와 후속 수정 필요 여부를 확인세요.",
"git" => "브랜치 상태나 변경 요약을 이어서 확인하세요.",
"document" => "생성된 산출물 경로를 열어 결과를 확인하세요.",
"skill" => "같은 스킬을 다른 입력으로 이어서 실행할 수 있습니다.",
_ => "필요하면 후속 요청을 이어서 실행할 수 있습니다.",
};
}
"file_edit" or "file_write" => "변경 내용은 preview나 diff에서 다시 확인할 수 있습니다.",
"build_test" => "출력 로그와 후속 수정 필요 여부를 확인해 주세요.",
"git" => "브랜치와 변경 수치를 이어서 확인해 주세요.",
"document" => "생성된 산출물 경로를 열어 결과를 확인해 주세요.",
"skill" => "같은 스킬을 다른 입력으로 이어서 실행할 수 있습니다.",
_ => "필요하면 다음 요청으로 이어서 작업할 수 있습니다.",
};
private static string BuildFailureFollowUp(string kind)
private static string BuildFailureFollowUp(string kind) => kind switch
{
return kind switch
{
"file_edit" or "file_write" => "대상 파일 경로와 권한, diff를 다시 확인세요.",
"build_test" => "실패 로그와 컴파일 오류 메시지를 먼저 확인하세요.",
"git" => "현재 브랜치, 잠금 상태, 충돌 여부를 확인하세요.",
"document" => "입력 데이터와 출력 형식, 저장 위치를 다시 확인세요.",
"skill" => "허용 도구와 런타임 요구사항을 다시 확인세요.",
"web" => "연결 상태와 요청 대상 URL을 다시 확인하세요.",
"mcp" => "MCP 서버 연결 상태와 도구 등록 상태를 다시 확인하세요.",
_ => "같은 요청을 재시도하기 전에 원인 메시지를 먼저 확인하세요.",
};
}
"file_edit" or "file_write" => "대상 파일 경로와 권한, diff 결과를 다시 확인해 주세요.",
"build_test" => "실패 로그와 컴파일/테스트 오류 메시지를 먼저 확인해 주세요.",
"git" => "브랜치 상태와 충돌 여부를 다시 확인해 주세요.",
"document" => "입력 데이터, 출력 형식, 대상 위치를 다시 확인해 주세요.",
"skill" => "사용 도구와 스킬 요구사항을 다시 확인해 주세요.",
"web" => "연결 상태와 요청 URL을 다시 확인해 주세요.",
"mcp" => "MCP 서버 연결 상태와 도구 등록 상태를 다시 확인해 주세요.",
_ => "같은 요청을 다시 시도하기 전에 원인 메시지를 먼저 확인해 주세요.",
};
}
@@ -31,12 +31,12 @@ public sealed class ToolSearchTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var query = args.TryGetProperty("query", out var queryEl) ? (queryEl.GetString() ?? "").Trim() : "";
var query = args.SafeTryGetProperty("query", out var queryEl) ? (queryEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(query))
return Task.FromResult(ToolResult.Fail("query is required."));
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
var includeDescription = args.TryGetProperty("include_description", out var descEl)
var limit = args.SafeTryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
var includeDescription = args.SafeTryGetProperty("include_description", out var descEl)
&& descEl.ValueKind == JsonValueKind.True;
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+4 -4
View File
@@ -38,15 +38,15 @@ public class UserAskTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var question = args.GetProperty("question").GetString() ?? "";
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : "";
var question = args.GetProperty("question").SafeGetString() ?? "";
var defaultVal = args.SafeTryGetProperty("default_value", out var dv) ? dv.SafeGetString() ?? "" : "";
var options = new List<string>();
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
{
foreach (var o in opts.EnumerateArray())
{
var s = o.GetString();
var s = o.SafeGetString();
if (!string.IsNullOrEmpty(s)) options.Add(s);
}
}
@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace AxCopilot.Services.Agent;
/// <summary>XLSX 파일을 HTML 테이블로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class XlsxToHtmlConverter
{
private const int MaxRows = 500;
private const int MaxCols = 50;
public static string Convert(string xlsxPath)
{
try
{
using var doc = SpreadsheetDocument.Open(xlsxPath, false);
var workbookPart = doc.WorkbookPart;
if (workbookPart == null)
return WrapHtml("<p style='color:#888'>스프레드시트 내용이 없습니다.</p>");
var sheets = workbookPart.Workbook?.Sheets?.Elements<Sheet>().ToList();
if (sheets == null || sheets.Count == 0)
return WrapHtml("<p style='color:#888'>시트가 없습니다.</p>");
var sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable;
var sb = new StringBuilder();
// 시트 탭 UI
if (sheets.Count > 1)
{
sb.AppendLine("<div class='sheet-tabs'>");
for (int i = 0; i < sheets.Count; i++)
{
var name = sheets[i].Name?.Value ?? $"Sheet{i + 1}";
var activeClass = i == 0 ? " active" : "";
sb.AppendLine($"<button class='sheet-tab{activeClass}' onclick='showSheet({i})'>{Escape(name)}</button>");
}
sb.AppendLine("</div>");
}
// 시트별 테이블
for (int si = 0; si < sheets.Count; si++)
{
var sheet = sheets[si];
var partId = sheet.Id?.Value;
if (string.IsNullOrEmpty(partId)) continue;
WorksheetPart sheetPart;
try { sheetPart = (WorksheetPart)workbookPart.GetPartById(partId); }
catch { continue; }
var display = si == 0 ? "block" : "none";
sb.AppendLine($"<div class='sheet-content' id='sheet-{si}' style='display:{display}'>");
sb.AppendLine(RenderSheet(sheetPart, sharedStrings));
sb.AppendLine("</div>");
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>XLSX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderSheet(WorksheetPart sheetPart, SharedStringTable? sharedStrings)
{
var sheetData = sheetPart.Worksheet?.GetFirstChild<SheetData>();
if (sheetData == null)
return "<p style='color:#888'>시트 데이터가 없습니다.</p>";
var rows = sheetData.Elements<Row>().Take(MaxRows + 1).ToList();
if (rows.Count == 0)
return "<p style='color:#888'>데이터가 없습니다.</p>";
// 병합 셀 정보
var mergeCells = sheetPart.Worksheet?.GetFirstChild<MergeCells>()
?.Elements<MergeCell>().ToList() ?? new List<MergeCell>();
var mergeMap = BuildMergeMap(mergeCells);
var sb = new StringBuilder();
sb.AppendLine("<div style='overflow-x:auto'><table>");
var rowIndex = 0;
foreach (var row in rows.Take(MaxRows))
{
rowIndex++;
sb.Append(rowIndex == 1 ? "<tr class='header'>" : "<tr>");
var cells = row.Elements<Cell>().Take(MaxCols).ToList();
foreach (var cell in cells)
{
var cellRef = cell.CellReference?.Value ?? "";
if (mergeMap.TryGetValue(cellRef, out var merge))
{
if (merge.IsHidden) continue; // 병합된 셀 건너뛰기
var spanAttrs = "";
if (merge.ColSpan > 1) spanAttrs += $" colspan='{merge.ColSpan}'";
if (merge.RowSpan > 1) spanAttrs += $" rowspan='{merge.RowSpan}'";
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}{spanAttrs}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
else
{
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table></div>");
if (rows.Count > MaxRows)
sb.AppendLine($"<p class='truncated'>... {MaxRows}행까지 표시 (전체 데이터는 Excel에서 확인)</p>");
return sb.ToString();
}
private static string GetCellValue(Cell cell, SharedStringTable? sharedStrings)
{
var value = cell.CellValue?.Text ?? cell.InnerText ?? "";
if (string.IsNullOrEmpty(value)) return "";
if (cell.DataType?.Value == CellValues.SharedString && sharedStrings != null)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count())
return sharedStrings.ElementAt(idx).InnerText;
}
return value;
}
private sealed record MergeCellInfo(int ColSpan, int RowSpan, bool IsHidden);
private static Dictionary<string, MergeCellInfo> BuildMergeMap(List<MergeCell> mergeCells)
{
var map = new Dictionary<string, MergeCellInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var mc in mergeCells)
{
var range = mc.Reference?.Value;
if (string.IsNullOrEmpty(range)) continue;
var parts = range.Split(':');
if (parts.Length != 2) continue;
var (startCol, startRow) = ParseCellRef(parts[0]);
var (endCol, endRow) = ParseCellRef(parts[1]);
var colSpan = endCol - startCol + 1;
var rowSpan = endRow - startRow + 1;
// 시작 셀: span 정보 포함
map[parts[0]] = new MergeCellInfo(colSpan, rowSpan, false);
// 나머지 셀: 숨김
for (int r = startRow; r <= endRow; r++)
for (int c = startCol; c <= endCol; c++)
{
var cellRef = ColumnIndexToLetter(c) + r;
if (!string.Equals(cellRef, parts[0], StringComparison.OrdinalIgnoreCase))
map[cellRef] = new MergeCellInfo(1, 1, true);
}
}
return map;
}
private static (int Col, int Row) ParseCellRef(string cellRef)
{
int col = 0, i = 0;
while (i < cellRef.Length && char.IsLetter(cellRef[i]))
{
col = col * 26 + (char.ToUpper(cellRef[i]) - 'A' + 1);
i++;
}
int.TryParse(cellRef[i..], out var row);
return (col, row);
}
private static string ColumnIndexToLetter(int col)
{
var sb = new StringBuilder();
while (col > 0)
{
col--;
sb.Insert(0, (char)('A' + col % 26));
col /= 26;
}
return sb.ToString();
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.5; padding: 16px; font-size: 12.5px; }}
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;
font-size: 11.5px; border-radius: 6px 6px 0 0; color: #6b7280; }}
.sheet-tab.active {{ background: #fff; color: #2563eb; font-weight: 600;
border-bottom: 2px solid #2563eb; margin-bottom: -2px; }}
.sheet-tab:hover {{ background: #e5e7eb; }}
table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
th, td {{ border: 1px solid #d1d5db; padding: 5px 8px; text-align: left; white-space: nowrap; }}
th, .header td {{ background: #f3f4f6; font-weight: 600; position: sticky; top: 0; }}
tr:nth-child(even) {{ background: #f9fafb; }}
.truncated {{ color: #9ca3af; font-size: 11px; margin-top: 8px; font-style: italic; }}
</style>
<script>
function showSheet(idx) {{
document.querySelectorAll('.sheet-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.sheet-tab').forEach(el => el.classList.remove('active'));
document.getElementById('sheet-' + idx).style.display = 'block';
document.querySelectorAll('.sheet-tab')[idx].classList.add('active');
}}
</script>
</head>
<body>
{body}
</body>
</html>";
}
}

Some files were not shown because too many files have changed in this diff Show More