Compare commits
13
Commits
6e99837a4c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227f5ab0d3 | ||
|
|
33c1db4dae | ||
|
|
7931566212 | ||
|
|
4f3c19c15c | ||
|
|
0ceca202e9 | ||
|
|
594d38e4a9 | ||
|
|
8643562319 | ||
|
|
74d43e701c | ||
|
|
8db1a1ffb0 | ||
|
|
117320af02 | ||
|
|
6bccc229b0 | ||
|
|
77b63e7a83 | ||
|
|
1b4a2bfb1c |
@@ -7,6 +7,16 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
|||||||
개발 참고: Claw Code 동등성 작업 추적 문서
|
개발 참고: Claw Code 동등성 작업 추적 문서
|
||||||
`docs/claw-code-parity-plan.md`
|
`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)
|
- 업데이트: 2026-04-07 09:19 (KST)
|
||||||
- AX Agent 하단 상태바의 전체 토큰 집계가 유휴 전환 후 사라지지 않도록 conversation aggregate 복원 경로를 추가했습니다. 실행 중 누적 토큰이 0이어도 현재 대화 전체의 prompt/completion 합계를 다시 계산해 상태바에 유지합니다.
|
- AX Agent 하단 상태바의 전체 토큰 집계가 유휴 전환 후 사라지지 않도록 conversation aggregate 복원 경로를 추가했습니다. 실행 중 누적 토큰이 0이어도 현재 대화 전체의 prompt/completion 합계를 다시 계산해 상태바에 유지합니다.
|
||||||
- 컨텍스트 사용량 팝업이 마지막 실제 압축 결과와 맞지 않던 문제를 수정했습니다. 이제 최근 압축의 실제 before/after 토큰과 자동/수동 여부, 누적 압축 횟수와 절감 토큰을 기준으로 표시합니다.
|
- 컨텍스트 사용량 팝업이 마지막 실제 압축 결과와 맞지 않던 문제를 수정했습니다. 이제 최근 압축의 실제 before/after 토큰과 자동/수동 여부, 누적 압축 횟수와 절감 토큰을 기준으로 표시합니다.
|
||||||
@@ -1492,3 +1502,60 @@ MIT License
|
|||||||
- `LlmService`에 tool-use 전용 스트리밍 이벤트 API를 추가했습니다. 이제 OpenAI/vLLM/IBM 경로는 텍스트 델타와 완성된 도구 호출을 각각 이벤트로 내보낼 수 있습니다.
|
- `LlmService`에 tool-use 전용 스트리밍 이벤트 API를 추가했습니다. 이제 OpenAI/vLLM/IBM 경로는 텍스트 델타와 완성된 도구 호출을 각각 이벤트로 내보낼 수 있습니다.
|
||||||
- `Cowork/Code` 루프도 이 스트리밍 이벤트를 직접 소비하도록 바꿔, 도구 호출이 완성되는 즉시 transcript에 `스트리밍 도구 감지` 진행 표시가 보이고 read-only 도구 조기 실행도 실제 실행 루프와 연결되도록 정리했습니다.
|
- `Cowork/Code` 루프도 이 스트리밍 이벤트를 직접 소비하도록 바꿔, 도구 호출이 완성되는 즉시 transcript에 `스트리밍 도구 감지` 진행 표시가 보이고 read-only 도구 조기 실행도 실제 실행 루프와 연결되도록 정리했습니다.
|
||||||
- 기존의 `응답 전체 수신 -> tool_calls 파싱 -> 도구 실행` 구조에서 한 단계 더 나아가, `스트리밍 수신 -> partial tool_call 조립 -> 조기 read-only 실행 -> 최종 루프 재사용` 흐름으로 리팩터링했습니다.
|
- 기존의 `응답 전체 수신 -> tool_calls 파싱 -> 도구 실행` 구조에서 한 단계 더 나아가, `스트리밍 수신 -> partial tool_call 조립 -> 조기 read-only 실행 -> 최종 루프 재사용` 흐름으로 리팩터링했습니다.
|
||||||
|
- 업데이트: 2026-04-08 12:02 (KST)
|
||||||
|
- AX Agent 진행 카드의 경과 시간 계산을 공용 검증 helper로 통일했습니다. `_streamStartTime`이 초기화되지 않았거나 6시간을 넘는 비정상 상태이면 `0초`로 정리해 `수천만 시간`처럼 표시되던 문제를 막았습니다.
|
||||||
|
- 스트리밍 종료/취소 시 `_streamStartTime`을 즉시 초기화하도록 정리해, 이전 실행의 시간이 다음 실행 카드나 assistant 메타에 새어 들어가지 않게 했습니다.
|
||||||
|
- 채팅 입력창 글로우는 런처와 같은 방식으로 `표시/숨김 + 얇은 외곽선 + 부드러운 투명도` 중심으로 다듬었습니다. 과한 블러와 두꺼운 외곽선 때문에 지저분하게 보이던 인상을 줄였습니다.
|
||||||
|
- 런처 글로우 토글은 일반 설정에 그대로 유지하고, AX Agent 내부 설정은 채팅 입력창 글로우만 담당하도록 역할을 분리했습니다.
|
||||||
|
- 업데이트: 2026-04-08 12:18 (KST)
|
||||||
|
- `claw-code`의 `Messages.tsx`, `VirtualMessageList.tsx`, `StatusLine.tsx`, `StreamingToolExecutor.ts` 흐름을 다시 대조해 AX의 코워크/코드가 실행 중 유독 무거운 원인을 점검했습니다.
|
||||||
|
- 구조 비교 결과, AX는 코워크/코드 스트리밍 중에도 process feed 이벤트가 transcript 전체 재렌더를 자주 유발하는 경로가 남아 있어 `claw-code`보다 UI 스레드 부담이 큰 상태였습니다.
|
||||||
|
- [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이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다.
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
-5349
File diff suppressed because it is too large
Load Diff
@@ -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와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |
|
||||||
@@ -79,6 +79,7 @@ public partial class App : System.Windows.Application
|
|||||||
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
|
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
|
||||||
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
|
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
|
||||||
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
|
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
|
||||||
|
WorkflowLogService.IsRawLogEnabled = settings.Settings.Llm.EnableRawLlmLog;
|
||||||
|
|
||||||
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
|
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1003,6 +1003,10 @@ public class LlmSettings
|
|||||||
[JsonPropertyName("detailedLogRetentionDays")]
|
[JsonPropertyName("detailedLogRetentionDays")]
|
||||||
public int DetailedLogRetentionDays { get; set; } = 3;
|
public int DetailedLogRetentionDays { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>LLM에 보낸 요청 JSON과 돌아온 응답 원문을 모두 기록합니다. 디버깅용이며 파일 크기가 클 수 있습니다.</summary>
|
||||||
|
[JsonPropertyName("enableRawLlmLog")]
|
||||||
|
public bool EnableRawLlmLog { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
|
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
|
||||||
[JsonPropertyName("enableAgentMemory")]
|
[JsonPropertyName("enableAgentMemory")]
|
||||||
public bool EnableAgentMemory { get; set; } = true;
|
public bool EnableAgentMemory { get; set; } = true;
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ public static class AgentHookRunner
|
|||||||
|
|
||||||
var structured = false;
|
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)
|
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
|
||||||
{
|
{
|
||||||
updatedInput = inputProp.Clone();
|
updatedInput = inputProp.Clone();
|
||||||
@@ -245,10 +245,10 @@ public static class AgentHookRunner
|
|||||||
structured = true;
|
structured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root.TryGetProperty("message", out var msgProp) &&
|
if (root.SafeTryGetProperty("message", out var msgProp) &&
|
||||||
msgProp.ValueKind == JsonValueKind.String)
|
msgProp.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
var msg = msgProp.GetString();
|
var msg = msgProp.SafeGetString();
|
||||||
if (!string.IsNullOrWhiteSpace(msg))
|
if (!string.IsNullOrWhiteSpace(msg))
|
||||||
message = msg.Trim();
|
message = msg.Trim();
|
||||||
}
|
}
|
||||||
@@ -281,8 +281,8 @@ public static class AgentHookRunner
|
|||||||
updatedPermissions = null;
|
updatedPermissions = null;
|
||||||
|
|
||||||
JsonElement permProp;
|
JsonElement permProp;
|
||||||
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|
if (!(root.SafeTryGetProperty("updatedPermissions", out permProp)
|
||||||
|| root.TryGetProperty("permissionUpdates", out permProp)))
|
|| root.SafeTryGetProperty("permissionUpdates", out permProp)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -301,14 +301,14 @@ public static class AgentHookRunner
|
|||||||
if (entry.ValueKind != JsonValueKind.Object)
|
if (entry.ValueKind != JsonValueKind.Object)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
|
if (!entry.SafeTryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var tool = toolProp.GetString()?.Trim();
|
var tool = toolProp.SafeGetString()?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(tool))
|
if (string.IsNullOrWhiteSpace(tool))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (entry.TryGetProperty("permission", out var permValue) &&
|
if (entry.SafeTryGetProperty("permission", out var permValue) &&
|
||||||
TryExtractPermissionValue(permValue, out var normalized))
|
TryExtractPermissionValue(permValue, out var normalized))
|
||||||
{
|
{
|
||||||
map[tool] = normalized;
|
map[tool] = normalized;
|
||||||
@@ -329,7 +329,7 @@ public static class AgentHookRunner
|
|||||||
|
|
||||||
if (permissionElement.ValueKind == JsonValueKind.String)
|
if (permissionElement.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
var text = permissionElement.GetString();
|
var text = permissionElement.SafeGetString();
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
normalized = text.Trim();
|
normalized = text.Trim();
|
||||||
@@ -337,10 +337,10 @@ public static class AgentHookRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (permissionElement.ValueKind == JsonValueKind.Object &&
|
else if (permissionElement.ValueKind == JsonValueKind.Object &&
|
||||||
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
|
permissionElement.SafeTryGetProperty("permission", out var nestedPermission) &&
|
||||||
nestedPermission.ValueKind == JsonValueKind.String)
|
nestedPermission.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
var text = nestedPermission.GetString();
|
var text = nestedPermission.SafeGetString();
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
normalized = text.Trim();
|
normalized = text.Trim();
|
||||||
@@ -354,12 +354,12 @@ public static class AgentHookRunner
|
|||||||
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
|
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
|
||||||
{
|
{
|
||||||
additionalContext = null;
|
additionalContext = null;
|
||||||
if (!root.TryGetProperty("additionalContext", out var ctxProp))
|
if (!root.SafeTryGetProperty("additionalContext", out var ctxProp))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (ctxProp.ValueKind == JsonValueKind.String)
|
if (ctxProp.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
var text = ctxProp.GetString()?.Trim();
|
var text = ctxProp.SafeGetString()?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
additionalContext = text;
|
additionalContext = text;
|
||||||
@@ -376,7 +376,7 @@ public static class AgentHookRunner
|
|||||||
if (part.ValueKind != JsonValueKind.String)
|
if (part.ValueKind != JsonValueKind.String)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var text = part.GetString()?.Trim();
|
var text = part.SafeGetString()?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
chunks.Add(text);
|
chunks.Add(text);
|
||||||
}
|
}
|
||||||
@@ -394,10 +394,10 @@ public static class AgentHookRunner
|
|||||||
// 우선순위: message > content > text
|
// 우선순위: message > content > text
|
||||||
foreach (var key in new[] { "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;
|
continue;
|
||||||
|
|
||||||
var text = value.GetString()?.Trim();
|
var text = value.SafeGetString()?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
additionalContext = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ public partial class AgentLoopService
|
|||||||
private readonly LlmService _llm;
|
private readonly LlmService _llm;
|
||||||
private readonly ToolRegistry _tools;
|
private readonly ToolRegistry _tools;
|
||||||
private readonly SettingsService _settings;
|
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);
|
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||||
@@ -79,6 +87,21 @@ public partial class AgentLoopService
|
|||||||
_llm = llm;
|
_llm = llm;
|
||||||
_tools = tools;
|
_tools = tools;
|
||||||
_settings = settings;
|
_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)
|
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
|
||||||
@@ -140,6 +163,7 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
||||||
|
|
||||||
|
var runStopwatch = Stopwatch.StartNew();
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
_currentRunId = Guid.NewGuid().ToString("N");
|
_currentRunId = Guid.NewGuid().ToString("N");
|
||||||
_docFallbackAttempted = false;
|
_docFallbackAttempted = false;
|
||||||
@@ -151,6 +175,11 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
|
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
|
||||||
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
||||||
|
var explorationState = new ExplorationTrackingState
|
||||||
|
{
|
||||||
|
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
|
||||||
|
SelectiveHit = true,
|
||||||
|
};
|
||||||
|
|
||||||
// 워크플로우 상세 로그: 에이전트 루프 시작
|
// 워크플로우 상세 로그: 에이전트 루프 시작
|
||||||
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
|
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
|
||||||
@@ -194,11 +223,11 @@ public partial class AgentLoopService
|
|||||||
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
||||||
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
||||||
var consecutiveNoToolResponses = 0;
|
var consecutiveNoToolResponses = 0;
|
||||||
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
||||||
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
||||||
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
var planExecutionRetryMax = AgentLoopRuntimeThresholds.GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
||||||
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
|
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 failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
var runState = new RunState();
|
var runState = new RunState();
|
||||||
var requireHighImpactCodeVerification = false;
|
var requireHighImpactCodeVerification = false;
|
||||||
@@ -212,6 +241,17 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
var context = BuildContext();
|
var context = BuildContext();
|
||||||
InjectTaskTypeGuidance(messages, taskPolicy);
|
InjectTaskTypeGuidance(messages, taskPolicy);
|
||||||
|
InjectExplorationScopeGuidance(messages, explorationState.Scope);
|
||||||
|
EmitEvent(
|
||||||
|
AgentEventType.Thinking,
|
||||||
|
"",
|
||||||
|
explorationState.Scope switch
|
||||||
|
{
|
||||||
|
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
|
||||||
|
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
|
||||||
|
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
|
||||||
|
_ => "점진 탐색 · 필요한 범위부터 확인하는 중",
|
||||||
|
});
|
||||||
if (!executionPolicy.ReduceEarlyMemoryPressure)
|
if (!executionPolicy.ReduceEarlyMemoryPressure)
|
||||||
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
|
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
|
||||||
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
|
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
|
||||||
@@ -513,12 +553,15 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
|
||||||
|
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
||||||
|
|
||||||
// LLM에 도구 정의와 함께 요청
|
// LLM에 도구 정의와 함께 요청
|
||||||
List<LlmService.ContentBlock> blocks;
|
List<LlmService.ContentBlock> blocks;
|
||||||
var llmCallSw = Stopwatch.StartNew();
|
var llmCallSw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
var activeTools = cachedActiveTools;
|
||||||
if (activeTools.Count == 0)
|
if (activeTools.Count == 0)
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
|
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
|
||||||
@@ -539,14 +582,15 @@ public partial class AgentLoopService
|
|||||||
sendMessages = [.. messages, new ChatMessage
|
sendMessages = [.. messages, new ChatMessage
|
||||||
{
|
{
|
||||||
Role = "user",
|
Role = "user",
|
||||||
Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
|
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
|
||||||
"Call at least one tool RIGHT NOW. Text-only response is rejected."
|
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 워크플로우 상세 로그: LLM 요청
|
// 워크플로우 상세 로그: LLM 요청
|
||||||
llmCallSw.Restart();
|
llmCallSw.Restart();
|
||||||
var (_, currentModel) = _llm.GetCurrentModelInfo();
|
var (_, currentModel) = _llm.GetCurrentModelInfo();
|
||||||
|
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
|
||||||
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
|
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
|
||||||
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
|
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
|
||||||
var streamedTextPreview = new StringBuilder();
|
var streamedTextPreview = new StringBuilder();
|
||||||
@@ -796,7 +840,7 @@ public partial class AgentLoopService
|
|||||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||||
|
|
||||||
var activeToolPreview = string.Join(", ",
|
var activeToolPreview = string.Join(", ",
|
||||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
cachedActiveTools
|
||||||
.Select(t => t.Name)
|
.Select(t => t.Name)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.Take(10));
|
.Take(10));
|
||||||
@@ -807,17 +851,14 @@ public partial class AgentLoopService
|
|||||||
"[System:ToolCallRequired] " +
|
"[System:ToolCallRequired] " +
|
||||||
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
|
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
|
||||||
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
|
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
|
||||||
"지금 즉시 도구를 1개 이상 호출하세요. " +
|
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
|
||||||
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
|
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||||
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
|
$"사용 가능한 도구: {activeToolPreview}",
|
||||||
$"지금 사용 가능한 도구: {activeToolPreview}",
|
|
||||||
_ =>
|
_ =>
|
||||||
"[System:ToolCallRequired] " +
|
"[System:ToolCallRequired] " +
|
||||||
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
|
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
|
||||||
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
|
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" +
|
||||||
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
|
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||||
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
|
|
||||||
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
|
|
||||||
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
|
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
|
||||||
};
|
};
|
||||||
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
|
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
|
||||||
@@ -842,7 +883,7 @@ public partial class AgentLoopService
|
|||||||
if (!string.IsNullOrEmpty(textResponse))
|
if (!string.IsNullOrEmpty(textResponse))
|
||||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||||
var planToolList = string.Join(", ",
|
var planToolList = string.Join(", ",
|
||||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
cachedActiveTools
|
||||||
.Select(t => t.Name)
|
.Select(t => t.Name)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.Take(10));
|
.Take(10));
|
||||||
@@ -850,15 +891,13 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
1 =>
|
1 =>
|
||||||
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
|
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
|
||||||
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
|
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
|
||||||
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
|
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||||
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
|
|
||||||
$"사용 가능한 도구: {planToolList}",
|
$"사용 가능한 도구: {planToolList}",
|
||||||
_ =>
|
_ =>
|
||||||
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
|
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
|
||||||
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
|
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" +
|
||||||
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
|
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||||
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
|
|
||||||
$"사용 가능한 도구: {planToolList}"
|
$"사용 가능한 도구: {planToolList}"
|
||||||
};
|
};
|
||||||
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
|
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
|
||||||
@@ -884,7 +923,8 @@ public partial class AgentLoopService
|
|||||||
messages.Add(new ChatMessage { Role = "user",
|
messages.Add(new ChatMessage { Role = "user",
|
||||||
Content = "html_create 도구를 호출하지 않았습니다. " +
|
Content = "html_create 도구를 호출하지 않았습니다. " +
|
||||||
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
|
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
|
||||||
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." });
|
"지금 즉시 아래 형식으로 호출하세요:\n" +
|
||||||
|
"<tool_call>\n{\"name\": \"html_create\", \"arguments\": {\"file_name\": \"...\", \"html_body\": \"...\"}}\n</tool_call>" });
|
||||||
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
|
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
|
||||||
continue; // 루프 재시작
|
continue; // 루프 재시작
|
||||||
}
|
}
|
||||||
@@ -1044,7 +1084,7 @@ public partial class AgentLoopService
|
|||||||
contentBlocks.Add(new { type = "text", text = textResponse });
|
contentBlocks.Add(new { type = "text", text = textResponse });
|
||||||
foreach (var tc in toolCalls)
|
foreach (var tc in toolCalls)
|
||||||
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
|
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 });
|
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
|
||||||
|
|
||||||
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
|
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
|
||||||
@@ -1122,15 +1162,16 @@ public partial class AgentLoopService
|
|||||||
if (toolCalls.Count == 0) continue;
|
if (toolCalls.Count == 0) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var call in toolCalls)
|
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
|
||||||
{
|
var activeToolNames = cachedActiveTools
|
||||||
if (ct.IsCancellationRequested) break;
|
|
||||||
|
|
||||||
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
|
||||||
.Select(t => t.Name)
|
.Select(t => t.Name)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var call in toolCalls)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
|
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
|
||||||
var globallyRegisteredTool = _tools.Get(resolvedToolName);
|
var globallyRegisteredTool = _tools.Get(resolvedToolName);
|
||||||
if (globallyRegisteredTool != null &&
|
if (globallyRegisteredTool != null &&
|
||||||
@@ -1291,6 +1332,28 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
|
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
|
||||||
FormatToolCallSummary(effectiveCall));
|
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);
|
var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages);
|
||||||
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
|
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
|
||||||
return decisionTransition.TerminalResponse;
|
return decisionTransition.TerminalResponse;
|
||||||
@@ -1398,7 +1461,7 @@ public partial class AgentLoopService
|
|||||||
await RunRuntimeHooksAsync(
|
await RunRuntimeHooksAsync(
|
||||||
"__stop_requested__",
|
"__stop_requested__",
|
||||||
"post",
|
"post",
|
||||||
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
|
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }, s_jsonOpts),
|
||||||
"cancelled",
|
"cancelled",
|
||||||
success: false);
|
success: false);
|
||||||
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
|
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
|
||||||
@@ -1475,6 +1538,26 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
|
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
|
||||||
failedToolHistogram[effectiveCall.ToolName] = failedCount + 1;
|
failedToolHistogram[effectiveCall.ToolName] = failedCount + 1;
|
||||||
|
|
||||||
|
// 같은 도구가 5회 이상 실패하면 해당 도구를 포기하고 LLM에 알림
|
||||||
|
if (failedCount + 1 >= 5)
|
||||||
|
{
|
||||||
|
var abortMsg = $"도구 '{effectiveCall.ToolName}'이(가) {failedCount + 1}회 실패했습니다. 이 도구를 더 이상 호출하지 마세요. 다른 방법을 시도하거나 사용자에게 결과를 보고하세요.";
|
||||||
|
EmitEvent(AgentEventType.Error, effectiveCall.ToolName, abortMsg);
|
||||||
|
messages.Add(LlmService.CreateToolResultMessage(
|
||||||
|
effectiveCall.ToolId, effectiveCall.ToolName, abortMsg));
|
||||||
|
messages.Add(new ChatMessage { Role = "user", Content = abortMsg });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 실패 횟수가 총 도구 호출의 60% 이상이면 조기 중단
|
||||||
|
var totalFails = failedToolHistogram.Values.Sum();
|
||||||
|
if (totalToolCalls > 6 && totalFails > totalToolCalls * 0.6)
|
||||||
|
{
|
||||||
|
EmitEvent(AgentEventType.Error, "",
|
||||||
|
$"전체 도구 호출 중 실패율이 높아 작업을 중단합니다 (실패 {totalFails}/{totalToolCalls})");
|
||||||
|
return $"도구 실행 실패율이 높아 작업을 중단했습니다. {totalFails}개 실패 / {totalToolCalls}개 호출. 요청을 다시 시도하거나 작업 방식을 변경해 주세요.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 스레드가 이벤트를 렌더링할 시간 확보
|
// UI 스레드가 이벤트를 렌더링할 시간 확보
|
||||||
@@ -1579,7 +1662,8 @@ public partial class AgentLoopService
|
|||||||
llm,
|
llm,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
context,
|
context,
|
||||||
ct);
|
ct,
|
||||||
|
documentPlanCalled);
|
||||||
if (terminalCompleted)
|
if (terminalCompleted)
|
||||||
{
|
{
|
||||||
if (consumedExtraIteration)
|
if (consumedExtraIteration)
|
||||||
@@ -1630,7 +1714,7 @@ public partial class AgentLoopService
|
|||||||
await RunRuntimeHooksAsync(
|
await RunRuntimeHooksAsync(
|
||||||
"__stop_requested__",
|
"__stop_requested__",
|
||||||
"post",
|
"post",
|
||||||
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
|
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }, s_jsonOpts),
|
||||||
"cancelled",
|
"cancelled",
|
||||||
success: false);
|
success: false);
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1749,20 @@ public partial class AgentLoopService
|
|||||||
// 통계 기록 (도구 호출이 1회 이상인 세션만)
|
// 통계 기록 (도구 호출이 1회 이상인 세션만)
|
||||||
if (totalToolCalls > 0)
|
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;
|
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
|
||||||
|
|
||||||
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
|
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
|
||||||
@@ -1708,6 +1806,30 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
|
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 ?? "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2927,11 +3049,11 @@ public partial class AgentLoopService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(message.Content);
|
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() ?? "";
|
toolName = toolNameProp.SafeGetString() ?? "";
|
||||||
if (doc.RootElement.TryGetProperty("content", out var contentProp))
|
if (doc.RootElement.SafeTryGetProperty("content", out var contentProp))
|
||||||
content = contentProp.GetString() ?? "";
|
content = contentProp.SafeGetString() ?? "";
|
||||||
return !string.IsNullOrWhiteSpace(toolName);
|
return !string.IsNullOrWhiteSpace(toolName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3658,58 +3780,6 @@ public partial class AgentLoopService
|
|||||||
.Select(x => $"{x.Key}({x.Value})"));
|
.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(
|
private static string BuildUnknownToolRecoveryPrompt(
|
||||||
string unknownToolName,
|
string unknownToolName,
|
||||||
IReadOnlyCollection<string> activeToolNames)
|
IReadOnlyCollection<string> activeToolNames)
|
||||||
@@ -4086,7 +4156,7 @@ public partial class AgentLoopService
|
|||||||
contentBlocks.Add(new { type = "text", text = verifyResponse });
|
contentBlocks.Add(new { type = "text", text = verifyResponse });
|
||||||
foreach (var tc in verifyToolCalls)
|
foreach (var tc in verifyToolCalls)
|
||||||
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
|
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 };
|
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
|
||||||
messages.Add(assistantMsg);
|
messages.Add(assistantMsg);
|
||||||
addedMessages.Add(assistantMsg);
|
addedMessages.Add(assistantMsg);
|
||||||
@@ -4247,9 +4317,9 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
foreach (var name in names)
|
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))
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -4291,8 +4361,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (normalizedTool.Contains("file_write"))
|
if (normalizedTool.Contains("file_write"))
|
||||||
{
|
{
|
||||||
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
|
var content = input.SafeTryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
|
||||||
? c.GetString() ?? ""
|
? c.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
|
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
|
||||||
string? previous = null;
|
string? previous = null;
|
||||||
@@ -4318,17 +4388,17 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (normalizedTool.Contains("file_edit"))
|
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()
|
var lines = edits.EnumerateArray()
|
||||||
.Take(6)
|
.Take(6)
|
||||||
.Select((edit, index) =>
|
.Select((edit, index) =>
|
||||||
{
|
{
|
||||||
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
|
var oldText = edit.SafeTryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
|
||||||
? oldElem.GetString() ?? ""
|
? oldElem.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
|
var newText = edit.SafeTryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
|
||||||
? newElem.GetString() ?? ""
|
? newElem.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
|
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
|
||||||
@@ -4346,8 +4416,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
|
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
|
||||||
{
|
{
|
||||||
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
|
var command = input.SafeTryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
|
||||||
? cmd.GetString() ?? target
|
? cmd.SafeGetString() ?? target
|
||||||
: target;
|
: target;
|
||||||
return new PermissionPromptPreview(
|
return new PermissionPromptPreview(
|
||||||
Kind: "command",
|
Kind: "command",
|
||||||
@@ -4358,8 +4428,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
|
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
|
||||||
{
|
{
|
||||||
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
|
var url = input.SafeTryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
|
||||||
? u.GetString() ?? target
|
? u.SafeGetString() ?? target
|
||||||
: target;
|
: target;
|
||||||
return new PermissionPromptPreview(
|
return new PermissionPromptPreview(
|
||||||
Kind: "web",
|
Kind: "web",
|
||||||
@@ -4630,124 +4700,27 @@ public partial class AgentLoopService
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Dispatcher != null)
|
if (Dispatcher != null)
|
||||||
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
Dispatcher(() =>
|
||||||
|
{
|
||||||
|
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
|
||||||
|
while (Events.Count > 500)
|
||||||
|
Events.RemoveAt(0);
|
||||||
Events.Add(evt);
|
Events.Add(evt);
|
||||||
EventOccurred?.Invoke(evt);
|
EventOccurred?.Invoke(evt);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
|
|
||||||
private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState)
|
|
||||||
{
|
{
|
||||||
runState.PendingPostCompactionTurn = true;
|
// Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
|
||||||
runState.PostCompactionTurnCounter = 0;
|
lock (Events)
|
||||||
runState.LastCompactionStageSummary = result.StageSummary;
|
|
||||||
runState.LastCompactionSavedTokens = result.SavedTokens;
|
|
||||||
SyncRunPostCompactionState(runState);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NotifyPostCompactionTurnIfNeeded(RunState runState)
|
|
||||||
{
|
{
|
||||||
if (!runState.PendingPostCompactionTurn)
|
while (Events.Count > 500)
|
||||||
return;
|
Events.RemoveAt(0);
|
||||||
|
Events.Add(evt);
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
|
EventOccurred?.Invoke(evt);
|
||||||
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>
|
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
|
||||||
@@ -4761,10 +4734,10 @@ public partial class AgentLoopService
|
|||||||
// Git 커밋 — 수준에 관계없이 무조건 확인
|
// Git 커밋 — 수준에 관계없이 무조건 확인
|
||||||
if (toolName == "git_tool")
|
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")
|
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}";
|
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4775,7 +4748,7 @@ public partial class AgentLoopService
|
|||||||
// process 도구 (외부 명령 실행)
|
// process 도구 (외부 명령 실행)
|
||||||
if (toolName == "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 $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -4787,14 +4760,14 @@ public partial class AgentLoopService
|
|||||||
// 외부 명령 실행
|
// 외부 명령 실행
|
||||||
if (toolName == "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 $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 파일 생성
|
// 새 파일 생성
|
||||||
if (toolName == "file_write")
|
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))
|
if (!string.IsNullOrEmpty(path))
|
||||||
{
|
{
|
||||||
var fullPath = System.IO.Path.IsPathRooted(path) ? path
|
var fullPath = System.IO.Path.IsPathRooted(path) ? path
|
||||||
@@ -4812,15 +4785,15 @@ public partial class AgentLoopService
|
|||||||
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||||||
return null;
|
return null;
|
||||||
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
|
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
|
||||||
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
|
var path = (input?.SafeTryGetProperty("path", out var p1) == true ? p1.SafeGetString() : null)
|
||||||
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
|
?? (input?.SafeTryGetProperty("file_path", out var p2) == true ? p2.SafeGetString() : "");
|
||||||
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
|
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 빌드/테스트 실행
|
// 빌드/테스트 실행
|
||||||
if (toolName is "build_run" or "test_loop")
|
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}";
|
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4830,7 +4803,7 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
if (toolName is "file_write" or "file_edit")
|
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}";
|
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4845,12 +4818,12 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
// 주요 파라미터만 표시
|
// 주요 파라미터만 표시
|
||||||
var input = call.ToolInput.Value;
|
var input = call.ToolInput.Value;
|
||||||
if (input.TryGetProperty("path", out var path))
|
if (input.SafeTryGetProperty("path", out var path))
|
||||||
return $"{call.ToolName}: {path.GetString()}";
|
return $"{call.ToolName}: {path.SafeGetString()}";
|
||||||
if (input.TryGetProperty("command", out var cmd))
|
if (input.SafeTryGetProperty("command", out var cmd))
|
||||||
return $"{call.ToolName}: {cmd.GetString()}";
|
return $"{call.ToolName}: {cmd.SafeGetString()}";
|
||||||
if (input.TryGetProperty("pattern", out var pat))
|
if (input.SafeTryGetProperty("pattern", out var pat))
|
||||||
return $"{call.ToolName}: {pat.GetString()}";
|
return $"{call.ToolName}: {pat.SafeGetString()}";
|
||||||
return call.ToolName;
|
return call.ToolName;
|
||||||
}
|
}
|
||||||
catch { 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;
|
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)
|
private static bool ShouldSkipTerminalEvidenceGateForAnalysisQuery(string userQuery, TaskTypePolicy taskPolicy)
|
||||||
{
|
{
|
||||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
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(
|
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||||
LlmService.ContentBlock block,
|
LlmService.ContentBlock block,
|
||||||
IReadOnlyCollection<IAgentTool> tools,
|
IReadOnlyCollection<IAgentTool> tools,
|
||||||
AgentContext context,
|
AgentContext context,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
@@ -1167,75 +927,15 @@ public partial class AgentLoopService
|
|||||||
bool forceToolCall = false,
|
bool forceToolCall = false,
|
||||||
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||||
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||||
{
|
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
|
||||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
messages,
|
||||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
tools,
|
||||||
while (true)
|
ct,
|
||||||
{
|
phaseLabel,
|
||||||
try
|
runState,
|
||||||
{
|
forceToolCall,
|
||||||
if (onStreamEventAsync == null)
|
prefetchToolCallAsync,
|
||||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
onStreamEventAsync);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyToolPostExecutionBookkeeping(
|
private void ApplyToolPostExecutionBookkeeping(
|
||||||
LlmService.ContentBlock call,
|
LlmService.ContentBlock call,
|
||||||
@@ -1419,189 +1119,6 @@ public partial class AgentLoopService
|
|||||||
|| lower.Contains("json parse");
|
|| 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(
|
private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync(
|
||||||
LlmService.ContentBlock call,
|
LlmService.ContentBlock call,
|
||||||
AgentContext context,
|
AgentContext context,
|
||||||
@@ -1651,7 +1168,7 @@ public partial class AgentLoopService
|
|||||||
return (false, null);
|
return (false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RunState
|
internal sealed class RunState
|
||||||
{
|
{
|
||||||
public int ContextRecoveryAttempts;
|
public int ContextRecoveryAttempts;
|
||||||
public int WithheldRecoveryAttempts;
|
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;
|
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
|
internal static class AgentTranscriptDisplayCatalog
|
||||||
{
|
{
|
||||||
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
||||||
@@ -15,20 +37,27 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
{
|
{
|
||||||
"file_read" => "파일 읽기",
|
"file_read" => "파일 읽기",
|
||||||
"file_write" => "파일 쓰기",
|
"file_write" => "파일 쓰기",
|
||||||
"file_edit" => "파일 편집",
|
"file_edit" => "파일 수정",
|
||||||
"file_watch" => "파일 변경 감시",
|
"file_watch" => "파일 감시",
|
||||||
"file_info" => "파일 정보",
|
"file_info" => "파일 정보",
|
||||||
"file_manage" => "파일 관리",
|
"file_manage" => "파일 관리",
|
||||||
"glob" => "파일 찾기",
|
"glob" => "파일 찾기",
|
||||||
"grep" => "내용 검색",
|
"grep" => "내용 검색",
|
||||||
"folder_map" => "폴더 구조",
|
"folder_map" => "폴더 구조",
|
||||||
|
"multi_read" => "다중 파일 읽기",
|
||||||
|
|
||||||
"document_reader" => "문서 읽기",
|
"document_read" or "document_reader" => "문서 읽기",
|
||||||
"document_planner" => "문서 계획",
|
"document_plan" or "document_planner" => "문서 계획",
|
||||||
"document_assembler" => "문서 조합",
|
"document_assemble" or "document_assembler" => "문서 조합",
|
||||||
"document_review" => "문서 검토",
|
"document_review" => "문서 검토",
|
||||||
"format_convert" => "형식 변환",
|
"format_convert" => "형식 변환",
|
||||||
"template_render" => "템플릿 렌더",
|
"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" => "빌드/실행",
|
"build_run" => "빌드/실행",
|
||||||
"test_loop" => "테스트 루프",
|
"test_loop" => "테스트 루프",
|
||||||
@@ -42,11 +71,11 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
"powershell" => "PowerShell",
|
"powershell" => "PowerShell",
|
||||||
"web_fetch" => "웹 요청",
|
"web_fetch" => "웹 요청",
|
||||||
"http" => "HTTP 요청",
|
"http" => "HTTP 요청",
|
||||||
"user_ask" => "의견 요청",
|
"user_ask" => "질문 요청",
|
||||||
"suggest_actions" => "다음 작업 제안",
|
"suggest_actions" => "다음 작업 제안",
|
||||||
|
|
||||||
"task_create" => "작업 생성",
|
"task_create" => "작업 생성",
|
||||||
"task_update" => "작업 업데이트",
|
"task_update" => "작업 갱신",
|
||||||
"task_list" => "작업 목록",
|
"task_list" => "작업 목록",
|
||||||
"task_get" => "작업 조회",
|
"task_get" => "작업 조회",
|
||||||
"task_stop" => "작업 중지",
|
"task_stop" => "작업 중지",
|
||||||
@@ -104,7 +133,7 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
||||||
AgentEventType.SkillCall => $"{displayName} 실행",
|
AgentEventType.SkillCall => $"{displayName} 실행",
|
||||||
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
|
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
|
||||||
AgentEventType.PermissionGranted => $"{displayName} 실행 권한이 승인되었습니다.",
|
AgentEventType.PermissionGranted => $"{displayName} 실행 권한을 확인했습니다.",
|
||||||
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
|
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
|
||||||
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
|
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
|
||||||
AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.",
|
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)
|
private static string GetToolCategoryLabel(string? rawName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rawName))
|
if (string.IsNullOrWhiteSpace(rawName))
|
||||||
@@ -119,13 +399,13 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
|
|
||||||
return rawName.Trim().ToLowerInvariant() switch
|
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"
|
"build_run" or "test_loop" or "dev_env_detect"
|
||||||
=> "빌드",
|
=> "빌드",
|
||||||
"git_tool" or "diff_tool" or "diff_preview"
|
"git_tool" or "diff_tool" or "diff_preview"
|
||||||
=> "Git",
|
=> "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"
|
"user_ask"
|
||||||
=> "질문",
|
=> "질문",
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ public class Base64Tool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
var text = args.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -69,9 +69,9 @@ public class Base64Tool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
|
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");
|
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 (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
|
||||||
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
|
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ public class BatchSkill : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = args.GetProperty("path").GetString() ?? "";
|
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var content = args.GetProperty("content").GetString() ?? "";
|
var content = args.GetProperty("content").SafeGetString() ?? "";
|
||||||
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
var desc = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ public class BuildRunTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "detect";
|
var action = args.GetProperty("action").SafeGetString() ?? "detect";
|
||||||
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
|
var customCmd = args.SafeTryGetProperty("command", out var cmd) ? cmd.SafeGetString() ?? "" : "";
|
||||||
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
|
var subPath = args.SafeTryGetProperty("project_path", out var pp) ? pp.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var workDir = context.WorkFolder;
|
var workDir = context.WorkFolder;
|
||||||
if (!string.IsNullOrEmpty(subPath))
|
if (!string.IsNullOrEmpty(subPath))
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ public class ChartSkill : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -69,12 +69,12 @@ public class ChartSkill : IAgentTool
|
|||||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||||
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
|
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
|
||||||
}
|
}
|
||||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
|
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "dashboard" : "dashboard";
|
||||||
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
|
var layout = args.SafeTryGetProperty("layout", out var l) ? l.SafeGetString() ?? "single" : "single";
|
||||||
var globalAccent = args.TryGetProperty("accent_color", out var ga) ? ga.GetString() : null;
|
var globalAccent = args.SafeTryGetProperty("accent_color", out var ga) ? ga.SafeGetString() : null;
|
||||||
var globalShowValues = true;
|
var globalShowValues = true;
|
||||||
if (args.TryGetProperty("show_values", out var sv))
|
if (args.SafeTryGetProperty("show_values", out var sv))
|
||||||
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||||
@@ -89,7 +89,7 @@ public class ChartSkill : IAgentTool
|
|||||||
var dir = Path.GetDirectoryName(fullPath);
|
var dir = Path.GetDirectoryName(fullPath);
|
||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
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 파라미터가 필요합니다 (배열 형식).");
|
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
|
||||||
|
|
||||||
var chartCount = chartsEl.GetArrayLength();
|
var chartCount = chartsEl.GetArrayLength();
|
||||||
@@ -138,17 +138,17 @@ public class ChartSkill : IAgentTool
|
|||||||
|
|
||||||
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
|
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
|
||||||
{
|
{
|
||||||
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
|
var type = chart.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "bar" : "bar";
|
||||||
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
|
var chartTitle = chart.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? "" : "";
|
||||||
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
|
var unit = chart.SafeTryGetProperty("unit", out var u) ? u.SafeGetString() ?? "" : "";
|
||||||
var labels = ParseStringArray(chart, "labels");
|
var labels = ParseStringArray(chart, "labels");
|
||||||
var datasets = ParseDatasets(chart);
|
var datasets = ParseDatasets(chart);
|
||||||
|
|
||||||
// Per-chart overrides
|
// 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;
|
var showValues = globalShowValues;
|
||||||
if (chart.TryGetProperty("show_values", out var sv))
|
if (chart.SafeTryGetProperty("show_values", out var sv))
|
||||||
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Apply accent color to first dataset if single-color context
|
// Apply accent color to first dataset if single-color context
|
||||||
if (!string.IsNullOrEmpty(accentColor) && datasets.Count > 0 && datasets[0].Color == Palette[0])
|
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)
|
foreach (var ds in datasets)
|
||||||
{
|
{
|
||||||
var pts = new List<(double x, double y)>();
|
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())
|
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 (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())
|
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))
|
&& px.TryGetDouble(out var xd) && py.TryGetDouble(out var yd))
|
||||||
pts.Add((xd, yd));
|
pts.Add((xd, yd));
|
||||||
}
|
}
|
||||||
@@ -609,12 +609,12 @@ public class ChartSkill : IAgentTool
|
|||||||
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
|
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
|
||||||
{
|
{
|
||||||
var yLabels = ParseStringArray(chart, "y_labels");
|
var yLabels = ParseStringArray(chart, "y_labels");
|
||||||
var colorFrom = chart.TryGetProperty("color_from", out var cf) ? cf.GetString() ?? "#EFF6FF" : "#EFF6FF";
|
var colorFrom = chart.SafeTryGetProperty("color_from", out var cf) ? cf.SafeGetString() ?? "#EFF6FF" : "#EFF6FF";
|
||||||
var colorTo = chart.TryGetProperty("color_to", out var ct2) ? ct2.GetString() ?? "#1D4ED8" : "#1D4ED8";
|
var colorTo = chart.SafeTryGetProperty("color_to", out var ct2) ? ct2.SafeGetString() ?? "#1D4ED8" : "#1D4ED8";
|
||||||
|
|
||||||
// Parse 2D values array
|
// Parse 2D values array
|
||||||
var grid = new List<List<double>>();
|
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())
|
foreach (var row in valsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
@@ -678,19 +678,19 @@ public class ChartSkill : IAgentTool
|
|||||||
|
|
||||||
private static string RenderGauge(JsonElement chart, string unit)
|
private static string RenderGauge(JsonElement chart, string unit)
|
||||||
{
|
{
|
||||||
var value = chart.TryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
|
var value = chart.SafeTryGetProperty("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 min = chart.SafeTryGetProperty("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 max = chart.SafeTryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
|
||||||
if (max <= min) max = min + 1;
|
if (max <= min) max = min + 1;
|
||||||
|
|
||||||
// Determine arc color from thresholds (highest threshold below value wins)
|
// Determine arc color from thresholds (highest threshold below value wins)
|
||||||
var arcColor = Palette[0];
|
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()
|
var thresholds = thEl.EnumerateArray()
|
||||||
.Select(t => (
|
.Select(t => (
|
||||||
At: t.TryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
|
At: t.SafeTryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
|
||||||
Color: t.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[0] : Palette[0]))
|
Color: t.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[0] : Palette[0]))
|
||||||
.OrderBy(t => t.At)
|
.OrderBy(t => t.At)
|
||||||
.ToList();
|
.ToList();
|
||||||
foreach (var (at, color) in thresholds)
|
foreach (var (at, color) in thresholds)
|
||||||
@@ -754,16 +754,16 @@ public class ChartSkill : IAgentTool
|
|||||||
|
|
||||||
private static List<string> ParseStringArray(JsonElement parent, string prop)
|
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 new();
|
||||||
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
|
return arr.EnumerateArray().Select(e => e.SafeGetString() ?? "").ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Dataset> ParseDatasets(JsonElement chart)
|
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()
|
return new()
|
||||||
{
|
{
|
||||||
@@ -782,10 +782,10 @@ public class ChartSkill : IAgentTool
|
|||||||
int colorIdx = 0;
|
int colorIdx = 0;
|
||||||
foreach (var ds in dsArr.EnumerateArray())
|
foreach (var ds in dsArr.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
|
var name = ds.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? $"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 color = ds.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
|
||||||
var values = new List<double>();
|
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();
|
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
|
||||||
list.Add(new Dataset { Name = name, Values = values, Color = color });
|
list.Add(new Dataset { Name = name, Values = values, Color = color });
|
||||||
colorIdx++;
|
colorIdx++;
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ public class CheckpointTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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이 필요합니다.");
|
return ToolResult.Fail("action이 필요합니다.");
|
||||||
var action = actionEl.GetString() ?? "";
|
var action = actionEl.SafeGetString() ?? "";
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||||
@@ -87,7 +87,7 @@ public class CheckpointTool : IAgentTool
|
|||||||
|
|
||||||
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
|
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()));
|
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
|
||||||
@@ -206,16 +206,16 @@ public class CheckpointTool : IAgentTool
|
|||||||
// ID 또는 이름으로 체크포인트 찾기
|
// ID 또는 이름으로 체크포인트 찾기
|
||||||
string? targetDir = null;
|
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)
|
if (id >= 0 && id < dirs.Count)
|
||||||
targetDir = dirs[id];
|
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));
|
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +276,10 @@ public class CheckpointTool : IAgentTool
|
|||||||
.OrderByDescending(d => d)
|
.OrderByDescending(d => d)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (!args.TryGetProperty("id", out var idEl))
|
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||||
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
|
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)
|
if (id < 0 || id >= dirs.Count)
|
||||||
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
|
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
|
||||||
|
|
||||||
|
|||||||
@@ -36,17 +36,16 @@ public class ClipboardTool : IAgentTool
|
|||||||
Required = ["action"],
|
Required = ["action"],
|
||||||
};
|
};
|
||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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
|
try
|
||||||
{
|
{
|
||||||
// 클립보드는 STA 스레드에서만 접근 가능
|
// 클립보드는 STA 스레드에서만 접근 가능 — InvokeAsync로 UI 스레드 블로킹 방지
|
||||||
ToolResult? result = null;
|
var result = await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
|
||||||
{
|
{
|
||||||
result = action switch
|
return action switch
|
||||||
{
|
{
|
||||||
"read" => ReadClipboard(),
|
"read" => ReadClipboard(),
|
||||||
"write" => WriteClipboard(args),
|
"write" => WriteClipboard(args),
|
||||||
@@ -55,11 +54,11 @@ public class ClipboardTool : IAgentTool
|
|||||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
|
return result ?? ToolResult.Fail("클립보드 접근 실패");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
|
return ToolResult.Fail($"클립보드 오류: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +79,10 @@ public class ClipboardTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult WriteClipboard(JsonElement args)
|
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");
|
return ToolResult.Fail("'text' parameter is required for write action");
|
||||||
|
|
||||||
var text = textProp.GetString() ?? "";
|
var text = textProp.SafeGetString() ?? "";
|
||||||
Clipboard.SetText(text);
|
Clipboard.SetText(text);
|
||||||
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
|
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ public class CodeReviewTool : IAgentTool
|
|||||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
|
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
|
||||||
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||||
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||||
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
|
var target = args.SafeTryGetProperty("target", out var t) ? t.SafeGetString() ?? "" : "";
|
||||||
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
|
var focus = args.SafeTryGetProperty("focus", out var f) ? f.SafeGetString() ?? "all" : "all";
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ public class CodeSearchTool : IAgentTool
|
|||||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
|
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
|
||||||
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||||
|
|
||||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
|
||||||
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
|
var maxResults = args.SafeTryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
|
||||||
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
|
var reindex = args.SafeTryGetProperty("reindex", out var ri) && ri.GetBoolean();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
return ToolResult.Fail("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)
|
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() : "";
|
||||||
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
|
var schedule = args.SafeTryGetProperty("schedule", out var s) ? (s.SafeGetString() ?? "").Trim() : "";
|
||||||
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
|
var command = args.SafeTryGetProperty("command", out var c) ? (c.SafeGetString() ?? "").Trim() : "";
|
||||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
|
||||||
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
|
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
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))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|
||||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
|
||||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
|
||||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||||
|
|
||||||
|
|||||||
@@ -42,31 +42,31 @@ public class CsvSkill : IAgentTool
|
|||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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' (문자열 배열)가 필요합니다.");
|
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' (배열의 배열)가 필요합니다.");
|
return ToolResult.Fail("필수 파라미터 누락: 'rows' (배열의 배열)가 필요합니다.");
|
||||||
|
|
||||||
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
|
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
|
||||||
string path;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var hint = headersEl.GetArrayLength() > 0
|
var hint = headersEl.GetArrayLength() > 0 && headersEl[0].ValueKind == JsonValueKind.String
|
||||||
? headersEl[0].GetString() ?? "data"
|
? headersEl[0].SafeGetString() ?? "data"
|
||||||
: "data";
|
: "data";
|
||||||
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||||
if (safe.Length > 40) safe = safe[..40].TrimEnd();
|
if (safe.Length > 40) safe = safe[..40].TrimEnd();
|
||||||
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
|
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
|
||||||
}
|
}
|
||||||
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||||
var delimiterKey = args.TryGetProperty("delimiter", out var delimEl) ? delimEl.GetString() ?? "comma" : "comma";
|
var delimiterKey = args.SafeTryGetProperty("delimiter", out var delimEl) ? delimEl.SafeGetString() ?? "comma" : "comma";
|
||||||
var useBom = !args.TryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
|
var useBom = !args.SafeTryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
|
||||||
var useSummary = args.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
|
var useSummary = args.SafeTryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
|
||||||
|
|
||||||
// ── 구분자 해석 ────────────────────────────────────────────────────
|
// ── 구분자 해석 ────────────────────────────────────────────────────
|
||||||
var delimiter = delimiterKey.ToLowerInvariant() switch
|
var delimiter = delimiterKey.ToLowerInvariant() switch
|
||||||
@@ -78,9 +78,9 @@ public class CsvSkill : IAgentTool
|
|||||||
|
|
||||||
// ── 열 타입 힌트 ───────────────────────────────────────────────────
|
// ── 열 타입 힌트 ───────────────────────────────────────────────────
|
||||||
var colTypeHints = new List<string>();
|
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())
|
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);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
@@ -118,7 +118,7 @@ public class CsvSkill : IAgentTool
|
|||||||
// ── 헤더 수집 ──────────────────────────────────────────────────
|
// ── 헤더 수집 ──────────────────────────────────────────────────
|
||||||
var headerList = new List<string>();
|
var headerList = new List<string>();
|
||||||
foreach (var h in headersEl.EnumerateArray())
|
foreach (var h in headersEl.EnumerateArray())
|
||||||
headerList.Add(h.GetString() ?? "");
|
headerList.Add(h.SafeGetString() ?? "");
|
||||||
|
|
||||||
int colCount = headerList.Count;
|
int colCount = headerList.Count;
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public class DataPivotTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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);
|
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
|
||||||
|
|
||||||
if (!context.IsPathAllowed(fullPath))
|
if (!context.IsPathAllowed(fullPath))
|
||||||
@@ -81,28 +81,28 @@ public class DataPivotTool : IAgentTool
|
|||||||
var originalCount = data.Count;
|
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))
|
if (!string.IsNullOrWhiteSpace(filterStr))
|
||||||
data = ApplyFilter(data, filterStr);
|
data = ApplyFilter(data, filterStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그룹화 & 집계
|
// 그룹화 & 집계
|
||||||
List<Dictionary<string, string>> result;
|
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>();
|
var groupCols = new List<string>();
|
||||||
foreach (var g in groupEl.EnumerateArray())
|
foreach (var g in groupEl.EnumerateArray())
|
||||||
groupCols.Add(g.GetString() ?? "");
|
groupCols.Add(g.SafeGetString() ?? "");
|
||||||
|
|
||||||
var aggregates = new List<(string Column, string Function)>();
|
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())
|
foreach (var agg in aggEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
|
var col = agg.SafeTryGetProperty("column", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
|
var func = agg.SafeTryGetProperty("function", out var f) ? f.SafeGetString() ?? "count" : "count";
|
||||||
if (!string.IsNullOrEmpty(col))
|
if (!string.IsNullOrEmpty(col))
|
||||||
aggregates.Add((col, func));
|
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))
|
if (!string.IsNullOrWhiteSpace(sortBy))
|
||||||
result = ApplySort(result, sortBy);
|
result = ApplySort(result, sortBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top N
|
// 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();
|
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);
|
var output = FormatOutput(result, outputFormat);
|
||||||
|
|
||||||
return Task.FromResult(ToolResult.Ok(
|
return Task.FromResult(ToolResult.Ok(
|
||||||
@@ -192,7 +192,7 @@ public class DataPivotTool : IAgentTool
|
|||||||
|
|
||||||
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
|
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||||
? doc.RootElement
|
? 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;
|
if (arr.ValueKind != JsonValueKind.Array) return data;
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class DateTimeTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -94,7 +94,7 @@ public class DateTimeTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult Parse(JsonElement args)
|
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 (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||||
|
|
||||||
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
|
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)
|
private static ToolResult Diff(JsonElement args)
|
||||||
{
|
{
|
||||||
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
|
var d1 = args.SafeTryGetProperty("date", out var v1) ? v1.SafeGetString() ?? "" : "";
|
||||||
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
|
var d2 = args.SafeTryGetProperty("date2", out var v2) ? v2.SafeGetString() ?? "" : "";
|
||||||
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
|
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
|
||||||
return ToolResult.Fail("'date' and 'date2' parameters are required");
|
return ToolResult.Fail("'date' and 'date2' parameters are required");
|
||||||
|
|
||||||
@@ -132,9 +132,9 @@ public class DateTimeTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult Add(JsonElement args)
|
private static ToolResult Add(JsonElement args)
|
||||||
{
|
{
|
||||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||||
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
|
var amountStr = args.SafeTryGetProperty("amount", out var av) ? av.SafeGetString() ?? "0" : "0";
|
||||||
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
|
var unit = args.SafeTryGetProperty("unit", out var uv) ? uv.SafeGetString() ?? "days" : "days";
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
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}'");
|
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)
|
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");
|
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
|
||||||
|
|
||||||
// 숫자면 epoch → datetime
|
// 숫자면 epoch → datetime
|
||||||
@@ -186,8 +186,8 @@ public class DateTimeTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult FormatDate(JsonElement args)
|
private static ToolResult FormatDate(JsonElement args)
|
||||||
{
|
{
|
||||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||||
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
|
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 (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||||
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
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)
|
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)
|
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)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var newContent = args.GetProperty("new_content").GetString() ?? "";
|
var newContent = args.GetProperty("new_content").SafeGetString() ?? "";
|
||||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ public class DiffTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
|
||||||
var left = args.GetProperty("left").GetString() ?? "";
|
var left = args.GetProperty("left").SafeGetString() ?? "";
|
||||||
var right = args.GetProperty("right").GetString() ?? "";
|
var right = args.GetProperty("right").SafeGetString() ?? "";
|
||||||
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
|
var leftLabel = args.SafeTryGetProperty("left_label", out var ll) ? ll.SafeGetString() ?? "left" : "left";
|
||||||
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
|
var rightLabel = args.SafeTryGetProperty("right_label", out var rl) ? rl.SafeGetString() ?? "right" : "right";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,24 +64,24 @@ public class DocumentAssemblerTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = args.GetProperty("path").GetString() ?? "";
|
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var title = args.GetProperty("title").GetString() ?? "Document";
|
var title = args.GetProperty("title").SafeGetString() ?? "Document";
|
||||||
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
|
||||||
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
|
var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood();
|
||||||
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
||||||
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
|
var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null;
|
||||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
|
||||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : 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 배열이 필요합니다.");
|
return ToolResult.Fail("sections 배열이 필요합니다.");
|
||||||
|
|
||||||
var sections = new List<(string Heading, string Content, int Level)>();
|
var sections = new List<(string Heading, string Content, int Level)>();
|
||||||
foreach (var sec in sectionsEl.EnumerateArray())
|
foreach (var sec in sectionsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
var heading = sec.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
|
||||||
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
var level = sec.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||||
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
|
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
|
||||||
sections.Add((heading, content, level));
|
sections.Add((heading, content, level));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var topic = args.GetProperty("topic").GetString() ?? "";
|
var topic = args.GetProperty("topic").SafeGetString() ?? "";
|
||||||
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
|
var docType = args.SafeTryGetProperty("document_type", out var dt) ? dt.SafeGetString() ?? "report" : "report";
|
||||||
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
var targetPages = args.SafeTryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
||||||
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
|
||||||
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
|
var sectionsHint = args.SafeTryGetProperty("sections_hint", out var sh) ? sh.SafeGetString() ?? "" : "";
|
||||||
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
|
var refSummary = args.SafeTryGetProperty("reference_summary", out var rs) ? rs.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(topic))
|
if (string.IsNullOrWhiteSpace(topic))
|
||||||
return Task.FromResult(ToolResult.Fail("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)
|
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가 필요합니다.");
|
return ToolResult.Fail("path가 필요합니다.");
|
||||||
var path = pathEl.GetString() ?? "";
|
var path = pathEl.SafeGetString() ?? "";
|
||||||
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
|
var maxChars = args.SafeTryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
|
||||||
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
|
var offset = args.SafeTryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
|
||||||
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
|
var sheetParam = args.SafeTryGetProperty("sheet", out var sh) ? sh.SafeGetString() ?? "" : "";
|
||||||
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
|
var pagesParam = args.SafeTryGetProperty("pages", out var pg) ? pg.SafeGetString() ?? "" : "";
|
||||||
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
|
var sectionParam = args.SafeTryGetProperty("section", out var sec) ? sec.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (maxChars < 100) maxChars = DefaultMaxChars;
|
if (maxChars < 100) maxChars = DefaultMaxChars;
|
||||||
if (offset < 0) offset = 0;
|
if (offset < 0) offset = 0;
|
||||||
@@ -565,7 +565,7 @@ public class DocumentReaderTool : IAgentTool
|
|||||||
private static int GetIntValue(JsonElement el, int defaultValue)
|
private static int GetIntValue(JsonElement el, int defaultValue)
|
||||||
{
|
{
|
||||||
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
|
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;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ public class DocumentReviewTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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가 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||||
var path = pathEl.GetString() ?? "";
|
var path = pathEl.SafeGetString() ?? "";
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
|
|
||||||
if (!context.IsPathAllowed(fullPath))
|
if (!context.IsPathAllowed(fullPath))
|
||||||
@@ -108,11 +108,11 @@ public class DocumentReviewTool : IAgentTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 기대 섹션 검사
|
// 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())
|
foreach (var sec in sections.EnumerateArray())
|
||||||
{
|
{
|
||||||
var title = sec.GetString() ?? "";
|
var title = sec.SafeGetString() ?? "";
|
||||||
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||||
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
|
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,12 +94,12 @@ public class DocxSkill : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -108,12 +108,12 @@ public class DocxSkill : IAgentTool
|
|||||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||||
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
|
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
|
||||||
}
|
}
|
||||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
|
||||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
|
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
|
||||||
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
|
var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
|
||||||
(headerText != null || footerText != null);
|
(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 theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"];
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
@@ -161,7 +161,7 @@ public class DocxSkill : IAgentTool
|
|||||||
int tableCount = 0;
|
int tableCount = 0;
|
||||||
foreach (var section in sections.EnumerateArray())
|
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")
|
if (blockType == "pagebreak")
|
||||||
{
|
{
|
||||||
@@ -195,9 +195,9 @@ public class DocxSkill : IAgentTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 일반 섹션 (heading + body)
|
// 일반 섹션 (heading + body)
|
||||||
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
|
||||||
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
|
||||||
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
var level = section.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(heading))
|
if (!string.IsNullOrEmpty(heading))
|
||||||
body.Append(CreateHeadingParagraph(heading, level, theme));
|
body.Append(CreateHeadingParagraph(heading, level, theme));
|
||||||
@@ -371,9 +371,9 @@ public class DocxSkill : IAgentTool
|
|||||||
|
|
||||||
private static Table CreateTable(JsonElement section, ThemeColors theme)
|
private static Table CreateTable(JsonElement section, ThemeColors theme)
|
||||||
{
|
{
|
||||||
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
|
var headers = section.SafeTryGetProperty("headers", out var hArr) ? hArr : default;
|
||||||
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
|
var rows = section.SafeTryGetProperty("rows", out var rArr) ? rArr : default;
|
||||||
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
|
var tableStyle = section.SafeTryGetProperty("style", out var ts) ? ts.SafeGetString() ?? "striped" : "striped";
|
||||||
|
|
||||||
var table = new Table();
|
var table = new Table();
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@ public class DocxSkill : IAgentTool
|
|||||||
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
|
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
|
||||||
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
|
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
|
RunProperties = new RunProperties
|
||||||
{
|
{
|
||||||
@@ -472,15 +472,15 @@ public class DocxSkill : IAgentTool
|
|||||||
|
|
||||||
private static void AppendList(Body body, JsonElement section)
|
private static void AppendList(Body body, JsonElement section)
|
||||||
{
|
{
|
||||||
var items = section.TryGetProperty("items", out var arr) ? arr : default;
|
var items = section.SafeTryGetProperty("items", out var arr) ? arr : default;
|
||||||
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
|
var listStyle = section.SafeTryGetProperty("style", out var ls) ? ls.SafeGetString() ?? "bullet" : "bullet";
|
||||||
|
|
||||||
if (items.ValueKind != JsonValueKind.Array) return;
|
if (items.ValueKind != JsonValueKind.Array) return;
|
||||||
|
|
||||||
int idx = 1;
|
int idx = 1;
|
||||||
foreach (var item in items.EnumerateArray())
|
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("- ");
|
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)
|
private static void AppendCallout(Body body, JsonElement section)
|
||||||
{
|
{
|
||||||
var style = section.TryGetProperty("style", out var sv) ? sv.GetString() ?? "info" : "info";
|
var style = section.SafeTryGetProperty("style", out var sv) ? sv.SafeGetString() ?? "info" : "info";
|
||||||
var calloutTitle = section.TryGetProperty("title", out var tv) ? tv.GetString() ?? "" : "";
|
var calloutTitle = section.SafeTryGetProperty("title", out var tv) ? tv.SafeGetString() ?? "" : "";
|
||||||
var calloutBody = section.TryGetProperty("body", out var bv) ? bv.GetString() ?? "" : "";
|
var calloutBody = section.SafeTryGetProperty("body", out var bv) ? bv.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
|
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
|
||||||
? cc
|
? cc
|
||||||
@@ -588,8 +588,8 @@ public class DocxSkill : IAgentTool
|
|||||||
|
|
||||||
private static Paragraph CreateHighlightBox(JsonElement section)
|
private static Paragraph CreateHighlightBox(JsonElement section)
|
||||||
{
|
{
|
||||||
var text = section.TryGetProperty("text", out var tv) ? tv.GetString() ?? "" : "";
|
var text = section.SafeTryGetProperty("text", out var tv) ? tv.SafeGetString() ?? "" : "";
|
||||||
var color = section.TryGetProperty("color", out var cv) ? cv.GetString() ?? "blue" : "blue";
|
var color = section.SafeTryGetProperty("color", out var cv) ? cv.SafeGetString() ?? "blue" : "blue";
|
||||||
|
|
||||||
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
|
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
|
||||||
? 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> </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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,12 @@ public class EncodingTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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")
|
if (action == "list")
|
||||||
return ListEncodings();
|
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))
|
if (string.IsNullOrEmpty(rawPath))
|
||||||
return ToolResult.Fail("'path'가 필요합니다.");
|
return ToolResult.Fail("'path'가 필요합니다.");
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ public class EncodingTool : IAgentTool
|
|||||||
|
|
||||||
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
|
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);
|
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
|
||||||
@@ -98,10 +98,10 @@ public class EncodingTool : IAgentTool
|
|||||||
|
|
||||||
// 소스 인코딩 결정
|
// 소스 인코딩 결정
|
||||||
Encoding fromEnc;
|
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);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
fromEnc = Encoding.GetEncoding(fe.GetString()!);
|
fromEnc = Encoding.GetEncoding(fe.SafeGetString()!);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ public sealed class EnterWorktreeTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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))
|
if (string.IsNullOrWhiteSpace(relative))
|
||||||
return Task.FromResult(ToolResult.Fail("path is required."));
|
return Task.FromResult(ToolResult.Fail("path is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
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 root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
|
||||||
var full = Path.GetFullPath(Path.Combine(root, relative));
|
var full = Path.GetFullPath(Path.Combine(root, relative));
|
||||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class EnvTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -68,9 +68,9 @@ public class EnvTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult Get(JsonElement args)
|
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");
|
return ToolResult.Fail("'name' parameter is required for get action");
|
||||||
var name = n.GetString() ?? "";
|
var name = n.SafeGetString() ?? "";
|
||||||
var value = Environment.GetEnvironmentVariable(name);
|
var value = Environment.GetEnvironmentVariable(name);
|
||||||
return value != null
|
return value != null
|
||||||
? ToolResult.Ok($"{name}={value}")
|
? ToolResult.Ok($"{name}={value}")
|
||||||
@@ -79,20 +79,20 @@ public class EnvTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult Set(JsonElement args)
|
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");
|
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");
|
return ToolResult.Fail("'value' parameter is required for set action");
|
||||||
|
|
||||||
var name = n.GetString() ?? "";
|
var name = n.SafeGetString() ?? "";
|
||||||
var value = v.GetString() ?? "";
|
var value = v.SafeGetString() ?? "";
|
||||||
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
|
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
|
||||||
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
|
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ToolResult ListVars(JsonElement args)
|
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 vars = Environment.GetEnvironmentVariables();
|
||||||
var entries = new List<string>();
|
var entries = new List<string>();
|
||||||
|
|
||||||
@@ -118,9 +118,9 @@ public class EnvTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult Expand(JsonElement args)
|
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");
|
return ToolResult.Fail("'value' parameter is required for expand action");
|
||||||
var input = v.GetString() ?? "";
|
var input = v.SafeGetString() ?? "";
|
||||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||||
return ToolResult.Ok(expanded);
|
return ToolResult.Ok(expanded);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,15 +84,15 @@ public class ExcelSkill : IAgentTool
|
|||||||
{
|
{
|
||||||
// path 미제공 시 title 또는 sheet_name에서 자동 생성
|
// path 미제공 시 title 또는 sheet_name에서 자동 생성
|
||||||
string path;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var hint = (args.TryGetProperty("title", out var tEl) ? tEl.GetString() : null)
|
var hint = (args.SafeTryGetProperty("title", out var tEl) ? tEl.SafeGetString() : null)
|
||||||
?? (args.TryGetProperty("sheet_name", out var snEl) ? snEl.GetString() : null)
|
?? (args.SafeTryGetProperty("sheet_name", out var snEl) ? snEl.SafeGetString() : null)
|
||||||
?? "workbook";
|
?? "workbook";
|
||||||
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||||
@@ -116,7 +116,7 @@ public class ExcelSkill : IAgentTool
|
|||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
// Determine if we are in multi-sheet mode
|
// 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.ValueKind == JsonValueKind.Array
|
||||||
&& sheetsArr.GetArrayLength() > 0;
|
&& sheetsArr.GetArrayLength() > 0;
|
||||||
|
|
||||||
@@ -137,16 +137,16 @@ public class ExcelSkill : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
|
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'");
|
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'");
|
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
|
||||||
|
|
||||||
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
|
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
|
||||||
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
|
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
|
||||||
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() : null;
|
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
|
||||||
var isStyled = tableStyle != "plain";
|
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 theme = GetTheme(themeName);
|
||||||
var numFmts = ParseNumberFormats(args, "number_formats");
|
var numFmts = ParseNumberFormats(args, "number_formats");
|
||||||
@@ -168,8 +168,8 @@ public class ExcelSkill : IAgentTool
|
|||||||
|
|
||||||
var colCount = headers.GetArrayLength();
|
var colCount = headers.GetArrayLength();
|
||||||
|
|
||||||
var summaryArg = args.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||||
var mergesArg = args.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||||
|
|
||||||
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||||
@@ -218,7 +218,7 @@ public class ExcelSkill : IAgentTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use first sheet's theme for the shared stylesheet
|
// 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 firstTheme = GetTheme(firstThemeName);
|
||||||
|
|
||||||
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
|
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
|
||||||
@@ -235,21 +235,21 @@ public class ExcelSkill : IAgentTool
|
|||||||
|
|
||||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||||
{
|
{
|
||||||
var sheetName = sheetDef.TryGetProperty("name", out var snEl) ? snEl.GetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
|
var sheetName = sheetDef.SafeTryGetProperty("name", out var snEl) ? snEl.SafeGetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
|
||||||
var tableStyle = sheetDef.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "styled" : "styled";
|
var tableStyle = sheetDef.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "styled" : "styled";
|
||||||
var themeName = sheetDef.TryGetProperty("theme", out var thEl) ? thEl.GetString() : firstThemeName;
|
var themeName = sheetDef.SafeTryGetProperty("theme", out var thEl) ? thEl.SafeGetString() : firstThemeName;
|
||||||
var isStyled = tableStyle != "plain";
|
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 theme = GetTheme(themeName);
|
||||||
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
|
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
|
||||||
var alignments = ParseAlignments(sheetDef, "col_alignments");
|
var alignments = ParseAlignments(sheetDef, "col_alignments");
|
||||||
|
|
||||||
if (!sheetDef.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
|
if (!sheetDef.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
|
||||||
if (!sheetDef.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
|
if (!sheetDef.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
|
||||||
|
|
||||||
var colCount = headers.GetArrayLength();
|
var colCount = headers.GetArrayLength();
|
||||||
var summaryArg = sheetDef.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||||
var mergesArg = sheetDef.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||||
|
|
||||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||||
worksheetPart.Worksheet = new Worksheet();
|
worksheetPart.Worksheet = new Worksheet();
|
||||||
@@ -315,7 +315,7 @@ public class ExcelSkill : IAgentTool
|
|||||||
{
|
{
|
||||||
CellReference = cellRef,
|
CellReference = cellRef,
|
||||||
DataType = CellValues.String,
|
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,
|
StyleIndex = isStyled ? (uint)1 : 0,
|
||||||
};
|
};
|
||||||
headerRow.Append(cell);
|
headerRow.Append(cell);
|
||||||
@@ -374,7 +374,7 @@ public class ExcelSkill : IAgentTool
|
|||||||
var mergeCells = new MergeCells();
|
var mergeCells = new MergeCells();
|
||||||
foreach (var merge in mergesArg.EnumerateArray())
|
foreach (var merge in mergesArg.EnumerateArray())
|
||||||
{
|
{
|
||||||
var range = merge.GetString();
|
var range = merge.SafeGetString();
|
||||||
if (!string.IsNullOrEmpty(range))
|
if (!string.IsNullOrEmpty(range))
|
||||||
mergeCells.Append(new MergeCell { Reference = range });
|
mergeCells.Append(new MergeCell { Reference = range });
|
||||||
}
|
}
|
||||||
@@ -678,7 +678,7 @@ public class ExcelSkill : IAgentTool
|
|||||||
|
|
||||||
private static Columns? CreateColumns(JsonElement args, int colCount)
|
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();
|
var columns = new Columns();
|
||||||
for (int i = 0; i < colCount; i++)
|
for (int i = 0; i < colCount; i++)
|
||||||
@@ -706,8 +706,8 @@ public class ExcelSkill : IAgentTool
|
|||||||
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
|
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
|
||||||
uint rowNum, int colCount, int dataRowCount, bool isStyled)
|
uint rowNum, int colCount, int dataRowCount, bool isStyled)
|
||||||
{
|
{
|
||||||
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
|
var label = summary.SafeTryGetProperty("label", out var lbl) ? lbl.SafeGetString() ?? "합계" : "합계";
|
||||||
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
|
var colFormulas = summary.SafeTryGetProperty("columns", out var cols) ? cols : default;
|
||||||
|
|
||||||
var summaryRow = new Row { RowIndex = rowNum };
|
var summaryRow = new Row { RowIndex = rowNum };
|
||||||
|
|
||||||
@@ -730,9 +730,9 @@ public class ExcelSkill : IAgentTool
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (colFormulas.ValueKind == JsonValueKind.Object &&
|
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 startRow = 2;
|
||||||
var endRow = startRow + dataRowCount - 1;
|
var endRow = startRow + dataRowCount - 1;
|
||||||
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
|
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)
|
private static List<string?> ParseNumberFormats(JsonElement args, string key)
|
||||||
{
|
{
|
||||||
var result = new List<string?>();
|
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;
|
return result;
|
||||||
foreach (var el in arr.EnumerateArray())
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string?> ParseAlignments(JsonElement args, string key)
|
private static List<string?> ParseAlignments(JsonElement args, string key)
|
||||||
{
|
{
|
||||||
var result = new List<string?>();
|
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;
|
return result;
|
||||||
foreach (var el in arr.EnumerateArray())
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ public class FileEditTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = args.GetProperty("path").GetString() ?? "";
|
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var oldStr = args.GetProperty("old_string").GetString() ?? "";
|
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
|
||||||
var newStr = args.GetProperty("new_string").GetString() ?? "";
|
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
|
||||||
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
|
|
||||||
@@ -47,7 +47,11 @@ public class FileEditTool : IAgentTool
|
|||||||
|
|
||||||
var count = CountOccurrences(content, oldStr);
|
var count = CountOccurrences(content, oldStr);
|
||||||
if (count == 0)
|
if (count == 0)
|
||||||
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
|
{
|
||||||
|
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
|
||||||
|
var hint = BuildNotFoundHint(content, oldStr);
|
||||||
|
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
|
||||||
|
}
|
||||||
if (!replaceAll && count > 1)
|
if (!replaceAll && count > 1)
|
||||||
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
|
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
|
||||||
|
|
||||||
@@ -113,6 +117,43 @@ public class FileEditTool : IAgentTool
|
|||||||
return sb.ToString().TrimEnd();
|
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)
|
private static int CountOccurrences(string text, string search)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(search)) return 0;
|
if (string.IsNullOrEmpty(search)) return 0;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class FileInfoTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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);
|
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||||
|
|
||||||
if (!context.IsPathAllowed(path))
|
if (!context.IsPathAllowed(path))
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ public class FileManageTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
|
var dest = args.SafeTryGetProperty("destination", out var d) ? d.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ public class FileReadTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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가 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||||
var path = pathEl.GetString() ?? "";
|
var path = pathEl.SafeGetString() ?? "";
|
||||||
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
|
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
|
||||||
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
|
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
|
||||||
|
|
||||||
var fullPath = ResolvePath(path, context.WorkFolder);
|
var fullPath = ResolvePath(path, context.WorkFolder);
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ public class FileWatchTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = args.GetProperty("path").GetString() ?? "";
|
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
|
var pattern = args.SafeTryGetProperty("pattern", out var patEl) ? patEl.SafeGetString() ?? "*" : "*";
|
||||||
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
|
var sinceStr = args.SafeTryGetProperty("since", out var sinceEl) ? sinceEl.SafeGetString() ?? "24h" : "24h";
|
||||||
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
|
var recursive = !args.SafeTryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
|
||||||
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
|
var includeSize = !args.SafeTryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
|
||||||
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
|
var topN = args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
if (!context.IsPathAllowed(fullPath))
|
if (!context.IsPathAllowed(fullPath))
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ public class FileWriteTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||||
var content = args.GetProperty("content").GetString() ?? "";
|
var content = args.GetProperty("content").SafeGetString() ?? "";
|
||||||
var encName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
var encName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||||
var append = args.TryGetProperty("append", out var appEl) && appEl.GetBoolean();
|
var append = args.SafeTryGetProperty("append", out var appEl) && appEl.GetBoolean();
|
||||||
var mkDirs = !args.TryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
|
var mkDirs = !args.SafeTryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ public class FolderMapTool : IAgentTool
|
|||||||
Properties = new()
|
Properties = new()
|
||||||
{
|
{
|
||||||
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
|
["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." },
|
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
|
||||||
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
|
["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." },
|
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
|
||||||
["extensions"] = new()
|
["extensions"] = new()
|
||||||
{
|
{
|
||||||
@@ -52,34 +52,34 @@ public class FolderMapTool : IAgentTool
|
|||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// ── path ──────────────────────────────────────────────────────────
|
// ── path ──────────────────────────────────────────────────────────
|
||||||
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
// ── depth ─────────────────────────────────────────────────────────
|
// ── depth ─────────────────────────────────────────────────────────
|
||||||
var depth = 3;
|
var depth = 2;
|
||||||
if (args.TryGetProperty("depth", out var d))
|
if (args.SafeTryGetProperty("depth", out var d))
|
||||||
{
|
{
|
||||||
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
|
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;
|
if (depth < 1) depth = 1;
|
||||||
var maxDepth = Math.Min(depth, 10);
|
var maxDepth = Math.Min(depth, 10);
|
||||||
|
|
||||||
// ── include_files ─────────────────────────────────────────────────
|
// ── include_files ─────────────────────────────────────────────────
|
||||||
var includeFiles = true;
|
var includeFiles = false;
|
||||||
if (args.TryGetProperty("include_files", out var inc))
|
if (args.SafeTryGetProperty("include_files", out var inc))
|
||||||
{
|
{
|
||||||
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
||||||
includeFiles = inc.GetBoolean();
|
includeFiles = inc.GetBoolean();
|
||||||
else
|
else
|
||||||
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── extensions / pattern ──────────────────────────────────────────
|
// ── extensions / pattern ──────────────────────────────────────────
|
||||||
HashSet<string>? extSet = null;
|
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()
|
var list = extsEl.EnumerateArray()
|
||||||
.Select(e => e.GetString() ?? "")
|
.Select(e => e.SafeGetString() ?? "")
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
.Select(s => s.StartsWith('.') ? s : "." + s)
|
.Select(s => s.StartsWith('.') ? s : "." + s)
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -88,34 +88,34 @@ public class FolderMapTool : IAgentTool
|
|||||||
// Fall back to single pattern if extensions not provided
|
// Fall back to single pattern if extensions not provided
|
||||||
string extFilter = "";
|
string extFilter = "";
|
||||||
if (extSet == null)
|
if (extSet == null)
|
||||||
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
|
extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
// ── sort_by ───────────────────────────────────────────────────────
|
// ── 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";
|
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
|
||||||
|
|
||||||
// ── show_dir_sizes ────────────────────────────────────────────────
|
// ── show_dir_sizes ────────────────────────────────────────────────
|
||||||
var showDirSizes = false;
|
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)
|
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
|
||||||
showDirSizes = sds.GetBoolean();
|
showDirSizes = sds.GetBoolean();
|
||||||
else
|
else
|
||||||
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
|
showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── modified_after ────────────────────────────────────────────────
|
// ── modified_after ────────────────────────────────────────────────
|
||||||
DateTime? modifiedAfter = null;
|
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;
|
modifiedAfter = mdt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── max_file_size ─────────────────────────────────────────────────
|
// ── max_file_size ─────────────────────────────────────────────────
|
||||||
long? maxFileSizeBytes = null;
|
long? maxFileSizeBytes = null;
|
||||||
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
|
if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
|
||||||
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
|
maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? "");
|
||||||
|
|
||||||
// ── resolve base directory ────────────────────────────────────────
|
// ── resolve base directory ────────────────────────────────────────
|
||||||
var baseDir = string.IsNullOrEmpty(subPath)
|
var baseDir = string.IsNullOrEmpty(subPath)
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ public class FormatConvertTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var source = args.GetProperty("source").GetString() ?? "";
|
var source = args.GetProperty("source").SafeGetString() ?? "";
|
||||||
var target = args.GetProperty("target").GetString() ?? "";
|
var target = args.GetProperty("target").SafeGetString() ?? "";
|
||||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
|
||||||
|
|
||||||
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
|
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
|
||||||
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
|
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ public class GitTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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이 필요합니다.");
|
return ToolResult.Fail("action이 필요합니다.");
|
||||||
var action = actionEl.GetString() ?? "status";
|
var action = actionEl.SafeGetString() ?? "status";
|
||||||
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
|
var extraArgs = args.SafeTryGetProperty("args", out var a) ? a.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var workDir = context.WorkFolder;
|
var workDir = context.WorkFolder;
|
||||||
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ public class GlobTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||||
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
|
var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name";
|
||||||
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
|
var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
|
||||||
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
|
var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
|
||||||
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
|
var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||||
? context.WorkFolder
|
? context.WorkFolder
|
||||||
|
|||||||
@@ -33,20 +33,20 @@ public class GrepTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||||
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
|
var globFilter = args.SafeTryGetProperty("glob", out var g) ? g.SafeGetString() ?? "" : "";
|
||||||
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
var caseSensitive = args.SafeTryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
||||||
var maxMatches = args.TryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
|
var maxMatches = args.SafeTryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
|
||||||
var filesOnly = args.TryGetProperty("files_only", out var fo) && fo.GetBoolean();
|
var filesOnly = args.SafeTryGetProperty("files_only", out var fo) && fo.GetBoolean();
|
||||||
var wholeWord = args.TryGetProperty("whole_word", out var ww) && ww.GetBoolean();
|
var wholeWord = args.SafeTryGetProperty("whole_word", out var ww) && ww.GetBoolean();
|
||||||
var invert = args.TryGetProperty("invert", out var inv) && inv.GetBoolean();
|
var invert = args.SafeTryGetProperty("invert", out var inv) && inv.GetBoolean();
|
||||||
var maxFileSizeKb = args.TryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
|
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
|
// 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 contextLines = args.SafeTryGetProperty("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 beforeLines = args.SafeTryGetProperty("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 afterLines = args.SafeTryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
|
||||||
|
|
||||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||||
? context.WorkFolder
|
? context.WorkFolder
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ public class HashTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
|
||||||
var input = args.GetProperty("input").GetString() ?? "";
|
var input = args.GetProperty("input").SafeGetString() ?? "";
|
||||||
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
|
var algo = args.SafeTryGetProperty("algorithm", out var a) ? a.SafeGetString() ?? "sha256" : "sha256";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,26 +67,26 @@ public class HtmlSkill : IAgentTool
|
|||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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'");
|
return ToolResult.Fail("필수 파라미터 누락: 'title'");
|
||||||
|
|
||||||
// path 미제공 시 title로 자동 생성
|
// path 미제공 시 title로 자동 생성
|
||||||
args.TryGetProperty("path", out var pathEl);
|
args.SafeTryGetProperty("path", out var pathEl);
|
||||||
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.GetString()))
|
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
pathEl = default; // 아래에서 title 기반으로 생성
|
pathEl = default; // 아래에서 title 기반으로 생성
|
||||||
|
|
||||||
// body와 sections 둘 다 없으면 오류
|
// body와 sections 둘 다 없으면 오류
|
||||||
bool hasBody = args.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
|
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
|
||||||
bool hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||||
if (!hasBody && !hasSections)
|
if (!hasBody && !hasSections)
|
||||||
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
|
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
|
||||||
|
|
||||||
var title = titleEl.GetString() ?? "Report";
|
var title = titleEl.SafeGetString() ?? "Report";
|
||||||
// path가 없으면 title에서 안전한 파일명 생성
|
// path가 없으면 title에서 안전한 파일명 생성
|
||||||
string path;
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -95,14 +95,14 @@ public class HtmlSkill : IAgentTool
|
|||||||
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
|
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
|
||||||
path = safeName + ".html";
|
path = safeName + ".html";
|
||||||
}
|
}
|
||||||
var body = hasBody ? (bodyEl.GetString() ?? "") : "";
|
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
|
||||||
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
|
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
|
||||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
|
||||||
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
|
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
|
||||||
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
|
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
|
||||||
var usePrint = args.TryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
|
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
|
||||||
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
||||||
var accentColor = args.TryGetProperty("accent_color", out var accentEl) ? accentEl.GetString() : null;
|
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||||
@@ -187,8 +187,16 @@ public class HtmlSkill : IAgentTool
|
|||||||
if (!string.IsNullOrEmpty(tocHtml))
|
if (!string.IsNullOrEmpty(tocHtml))
|
||||||
sb.AppendLine(tocHtml);
|
sb.AppendLine(tocHtml);
|
||||||
|
|
||||||
// 본문
|
// 본문 — table 태그에 반응형 래퍼 자동 추가
|
||||||
sb.AppendLine(body);
|
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("</div>");
|
||||||
sb.AppendLine("</body>");
|
sb.AppendLine("</body>");
|
||||||
@@ -225,8 +233,8 @@ public class HtmlSkill : IAgentTool
|
|||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var section in sections.EnumerateArray())
|
foreach (var section in sections.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (!section.TryGetProperty("type", out var typeEl)) continue;
|
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
|
||||||
var type = typeEl.GetString() ?? "";
|
var type = typeEl.SafeGetString() ?? "";
|
||||||
|
|
||||||
switch (type.ToLowerInvariant())
|
switch (type.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
@@ -267,22 +275,22 @@ public class HtmlSkill : IAgentTool
|
|||||||
|
|
||||||
private static string RenderHeading(JsonElement s)
|
private static string RenderHeading(JsonElement s)
|
||||||
{
|
{
|
||||||
var level = s.TryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
|
var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
|
||||||
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||||
return $"<h{level}>{Escape(text)}</h{level}>";
|
return $"<h{level}>{Escape(text)}</h{level}>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderParagraph(JsonElement s)
|
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>";
|
return $"<p>{MarkdownToHtml(text)}</p>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderCallout(JsonElement s)
|
private static string RenderCallout(JsonElement s)
|
||||||
{
|
{
|
||||||
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "info" : "info";
|
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "info" : "info";
|
||||||
var title = s.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
|
var title = s.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
|
||||||
var text = s.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "";
|
||||||
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "ℹ️" };
|
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "ℹ️" };
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine($"<div class=\"callout-{style}\">");
|
sb.AppendLine($"<div class=\"callout-{style}\">");
|
||||||
@@ -299,15 +307,15 @@ public class HtmlSkill : IAgentTool
|
|||||||
sb.AppendLine("<div style=\"overflow-x:auto\">");
|
sb.AppendLine("<div style=\"overflow-x:auto\">");
|
||||||
sb.AppendLine("<table>");
|
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>");
|
sb.AppendLine("<thead><tr>");
|
||||||
foreach (var h in headers.EnumerateArray())
|
foreach (var h in headers.EnumerateArray())
|
||||||
sb.Append($"<th>{Escape(h.GetString() ?? "")}</th>");
|
sb.Append($"<th>{Escape(h.SafeGetString() ?? "")}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
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>");
|
sb.AppendLine("<tbody>");
|
||||||
foreach (var row in rows.EnumerateArray())
|
foreach (var row in rows.EnumerateArray())
|
||||||
@@ -315,7 +323,7 @@ public class HtmlSkill : IAgentTool
|
|||||||
sb.Append("<tr>");
|
sb.Append("<tr>");
|
||||||
if (row.ValueKind == JsonValueKind.Array)
|
if (row.ValueKind == JsonValueKind.Array)
|
||||||
foreach (var cell in row.EnumerateArray())
|
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("</tr>");
|
||||||
}
|
}
|
||||||
sb.AppendLine("</tbody>");
|
sb.AppendLine("</tbody>");
|
||||||
@@ -328,19 +336,19 @@ public class HtmlSkill : IAgentTool
|
|||||||
|
|
||||||
private static string RenderChart(JsonElement s, string? accentColor)
|
private static string RenderChart(JsonElement s, string? accentColor)
|
||||||
{
|
{
|
||||||
var kind = s.TryGetProperty("kind", out var k) ? k.GetString() ?? "bar" : "bar";
|
var kind = s.SafeTryGetProperty("kind", out var k) ? k.SafeGetString() ?? "bar" : "bar";
|
||||||
var chartTitle = s.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
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 "";
|
return "";
|
||||||
|
|
||||||
var items = new List<(string label, double value, string color)>();
|
var items = new List<(string label, double value, string color)>();
|
||||||
double maxVal = 0;
|
double maxVal = 0;
|
||||||
foreach (var item in data.EnumerateArray())
|
foreach (var item in data.EnumerateArray())
|
||||||
{
|
{
|
||||||
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
|
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
|
||||||
var value = item.TryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
|
var value = item.SafeTryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
|
||||||
var color = item.TryGetProperty("color", out var cl) ? cl.GetString() ?? "#2E75B6" : "#2E75B6";
|
var color = item.SafeTryGetProperty("color", out var cl) ? cl.SafeGetString() ?? "#2E75B6" : "#2E75B6";
|
||||||
items.Add((label, value, color));
|
items.Add((label, value, color));
|
||||||
if (value > maxVal) maxVal = value;
|
if (value > maxVal) maxVal = value;
|
||||||
}
|
}
|
||||||
@@ -399,17 +407,17 @@ public class HtmlSkill : IAgentTool
|
|||||||
|
|
||||||
private static string RenderCards(JsonElement s)
|
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 "";
|
return "";
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
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\">");
|
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())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var cardTitle = item.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
|
var cardTitle = item.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
|
||||||
var cardBody = item.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
|
var cardBody = item.SafeTryGetProperty("body", out var bd) ? bd.SafeGetString() ?? "" : "";
|
||||||
var badge = item.TryGetProperty("badge", out var bg) ? bg.GetString() : null;
|
var badge = item.SafeTryGetProperty("badge", out var bg) ? bg.SafeGetString() : null;
|
||||||
var icon = item.TryGetProperty("icon", out var ic) ? ic.GetString() : 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.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\">");
|
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)
|
private static string RenderList(JsonElement s)
|
||||||
{
|
{
|
||||||
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "bullet" : "bullet";
|
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "bullet" : "bullet";
|
||||||
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
var tag = style == "number" ? "ol" : "ul";
|
var tag = style == "number" ? "ol" : "ul";
|
||||||
@@ -442,7 +450,7 @@ public class HtmlSkill : IAgentTool
|
|||||||
bool inSubList = false;
|
bool inSubList = false;
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var text = item.GetString() ?? "";
|
var text = item.SafeGetString() ?? "";
|
||||||
bool isSub = text.StartsWith(" ");
|
bool isSub = text.StartsWith(" ");
|
||||||
if (isSub && !inSubList)
|
if (isSub && !inSubList)
|
||||||
{
|
{
|
||||||
@@ -464,8 +472,8 @@ public class HtmlSkill : IAgentTool
|
|||||||
|
|
||||||
private static string RenderQuote(JsonElement s)
|
private static string RenderQuote(JsonElement s)
|
||||||
{
|
{
|
||||||
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||||
var author = s.TryGetProperty("author", out var a) ? a.GetString() : null;
|
var author = s.SafeTryGetProperty("author", out var a) ? a.SafeGetString() : null;
|
||||||
var sb = new StringBuilder();
|
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("<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>");
|
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)
|
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 "";
|
return "";
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
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\">");
|
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())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
|
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
|
||||||
var value = item.TryGetProperty("value", out var vl) ? vl.GetString() ?? "" : "";
|
var value = item.SafeTryGetProperty("value", out var vl) ? vl.SafeGetString() ?? "" : "";
|
||||||
var change = item.TryGetProperty("change", out var ch) ? ch.GetString() : null;
|
var change = item.SafeTryGetProperty("change", out var ch) ? ch.SafeGetString() : null;
|
||||||
var positive = item.TryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
|
var positive = item.SafeTryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
|
||||||
var changeColor = positive ? "#16a34a" : "#dc2626";
|
var changeColor = positive ? "#16a34a" : "#dc2626";
|
||||||
var changeArrow = positive ? "▲" : "▼";
|
var changeArrow = positive ? "▲" : "▼";
|
||||||
|
|
||||||
@@ -515,6 +523,9 @@ public class HtmlSkill : IAgentTool
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return text;
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
|
||||||
|
// 0. LLM이 이미 삽입한 <br> / <br/> 태그를 보존 — 이스케이프 전에 플레이스홀더로 치환
|
||||||
|
text = Regex.Replace(text, @"<br\s*/?>", "\x00BR\x00");
|
||||||
|
|
||||||
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
|
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
|
||||||
text = text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
text = text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
|
||||||
@@ -535,6 +546,9 @@ public class HtmlSkill : IAgentTool
|
|||||||
// 6. newline → <br>
|
// 6. newline → <br>
|
||||||
text = text.Replace("\n", "<br>");
|
text = text.Replace("\n", "<br>");
|
||||||
|
|
||||||
|
// 7. 보존된 <br> 플레이스홀더를 복원
|
||||||
|
text = text.Replace("\x00BR\x00", "<br>");
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,16 +699,17 @@ public class HtmlSkill : IAgentTool
|
|||||||
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
|
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
|
||||||
private static string GenerateCover(JsonElement cover, string fallbackTitle)
|
private static string GenerateCover(JsonElement cover, string fallbackTitle)
|
||||||
{
|
{
|
||||||
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
|
var coverTitle = cover.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? fallbackTitle : fallbackTitle;
|
||||||
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
|
var subtitle = cover.SafeTryGetProperty("subtitle", out var sub) ? sub.SafeGetString() ?? "" : "";
|
||||||
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
|
var author = cover.SafeTryGetProperty("author", out var auth) ? auth.SafeGetString() ?? "" : "";
|
||||||
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
|
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.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
|
var gradient = cover.SafeTryGetProperty("gradient", out var grad) ? grad.SafeGetString() : null;
|
||||||
|
|
||||||
var styleAttr = "";
|
var styleAttr = "";
|
||||||
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
|
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
|
||||||
{
|
{
|
||||||
var colors = gradient.Split(',');
|
var colors = gradient.Split(',');
|
||||||
|
if (colors.Length >= 2)
|
||||||
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
|
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ public class HttpTool : IAgentTool
|
|||||||
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
|
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
|
||||||
return ToolResult.Fail("사내모드에서는 HTTP 도구 실행이 차단됩니다. operationMode=external에서만 사용할 수 있습니다.");
|
return ToolResult.Fail("사내모드에서는 HTTP 도구 실행이 차단됩니다. operationMode=external에서만 사용할 수 있습니다.");
|
||||||
|
|
||||||
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
|
var method = (args.SafeGetProperty("method")?.SafeGetString() ?? "GET").ToUpperInvariant();
|
||||||
var url = args.GetProperty("url").GetString() ?? "";
|
var url = args.SafeGetProperty("url")?.SafeGetString() ?? "";
|
||||||
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
var body = args.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
|
||||||
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
|
var headers = args.SafeTryGetProperty("headers", out var h) ? h.SafeGetString() ?? "" : "";
|
||||||
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
|
var timeout = args.SafeTryGetProperty("timeout", out var t) ? Math.Min(t.SafeGetInt32(30), 120) : 30;
|
||||||
|
|
||||||
// 보안: 허용된 호스트만
|
// 보안: 허용된 호스트만
|
||||||
if (!IsAllowedHost(url))
|
if (!IsAllowedHost(url))
|
||||||
@@ -77,8 +77,8 @@ public class HttpTool : IAgentTool
|
|||||||
if (!string.IsNullOrEmpty(headers))
|
if (!string.IsNullOrEmpty(headers))
|
||||||
{
|
{
|
||||||
using var headerDoc = JsonDocument.Parse(headers);
|
using var headerDoc = JsonDocument.Parse(headers);
|
||||||
foreach (var prop in headerDoc.RootElement.EnumerateObject())
|
foreach (var prop in headerDoc.RootElement.SafeEnumerateObject())
|
||||||
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
|
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)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var imagePath = args.GetProperty("image_path").GetString() ?? "";
|
var imagePath = args.GetProperty("image_path").SafeGetString() ?? "";
|
||||||
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
|
var task = args.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "describe" : "describe";
|
||||||
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
|
var question = args.SafeTryGetProperty("question", out var qEl) ? qEl.SafeGetString() ?? "" : "";
|
||||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
|
||||||
if (!context.IsPathAllowed(fullPath))
|
if (!context.IsPathAllowed(fullPath))
|
||||||
@@ -88,9 +88,9 @@ public class ImageAnalyzeTool : IAgentTool
|
|||||||
// 비교 모드: 두 번째 이미지
|
// 비교 모드: 두 번째 이미지
|
||||||
string? compareBase64 = null;
|
string? compareBase64 = null;
|
||||||
string? compareMime = 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))
|
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
|
||||||
{
|
{
|
||||||
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,18 +54,18 @@ public class JsonTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
var json = args.GetProperty("json").GetString() ?? "";
|
var json = args.GetProperty("json").SafeGetString() ?? "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Task.FromResult(action switch
|
return Task.FromResult(action switch
|
||||||
{
|
{
|
||||||
"validate" => Validate(json),
|
"validate" => Validate(json),
|
||||||
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
|
"format" => Format(json, args.SafeTryGetProperty("minify", out var m) && m.SafeGetString() == "true"),
|
||||||
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
|
"query" => Query(json, args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""),
|
||||||
"keys" => Keys(json),
|
"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}"),
|
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ public class JsonTool : IAgentTool
|
|||||||
}
|
}
|
||||||
else
|
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");
|
return ToolResult.Fail($"Key '{segment.Key}' not found");
|
||||||
current = prop;
|
current = prop;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ public class JsonTool : IAgentTool
|
|||||||
|
|
||||||
var value = current.ValueKind switch
|
var value = current.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.String => current.GetString() ?? "",
|
JsonValueKind.String => current.SafeGetString() ?? "",
|
||||||
JsonValueKind.Number => current.GetRawText(),
|
JsonValueKind.Number => current.GetRawText(),
|
||||||
JsonValueKind.True => "true",
|
JsonValueKind.True => "true",
|
||||||
JsonValueKind.False => "false",
|
JsonValueKind.False => "false",
|
||||||
@@ -192,9 +192,9 @@ public class JsonTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var values = allKeys.Select(k =>
|
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
|
return v.ValueKind == JsonValueKind.String
|
||||||
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
|
? $"\"{v.SafeGetString()?.Replace("\"", "\"\"") ?? ""}\""
|
||||||
: v.GetRawText();
|
: v.GetRawText();
|
||||||
});
|
});
|
||||||
sb.AppendLine(string.Join(",", values));
|
sb.AppendLine(string.Join(",", values));
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ public class LspTool : IAgentTool, IDisposable
|
|||||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
|
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
|
||||||
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||||
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||||
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
|
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
|
||||||
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
|
var line = args.SafeTryGetProperty("line", out var l) ? l.GetInt32() : 0;
|
||||||
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
|
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(filePath))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
return ToolResult.Fail("file_path가 필요합니다.");
|
return ToolResult.Fail("file_path가 필요합니다.");
|
||||||
|
|||||||
@@ -57,30 +57,30 @@ public class MarkdownSkill : IAgentTool
|
|||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// ── 필수 파라미터 ──────────────────────────────────────────────────
|
// ── 필수 파라미터 ──────────────────────────────────────────────────
|
||||||
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||||
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
|
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
|
||||||
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
||||||
|
|
||||||
if (!hasSections && !hasContent)
|
if (!hasSections && !hasContent)
|
||||||
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
|
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
|
||||||
|
|
||||||
// path 미제공 시 title에서 자동 생성
|
// path 미제공 시 title에서 자동 생성
|
||||||
string path;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
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('.');
|
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||||
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
|
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
|
||||||
}
|
}
|
||||||
var title = args.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
|
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
||||||
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
||||||
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||||
|
|
||||||
// ── 경로 처리 ──────────────────────────────────────────────────────
|
// ── 경로 처리 ──────────────────────────────────────────────────────
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
@@ -109,7 +109,7 @@ public class MarkdownSkill : IAgentTool
|
|||||||
foreach (var prop in frontEl.EnumerateObject())
|
foreach (var prop in frontEl.EnumerateObject())
|
||||||
{
|
{
|
||||||
var val = prop.Value.ValueKind == JsonValueKind.String
|
var val = prop.Value.ValueKind == JsonValueKind.String
|
||||||
? prop.Value.GetString() ?? ""
|
? prop.Value.SafeGetString() ?? ""
|
||||||
: prop.Value.ToString();
|
: prop.Value.ToString();
|
||||||
// 값에 특수 문자가 있으면 인용
|
// 값에 특수 문자가 있으면 인용
|
||||||
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
|
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
|
||||||
@@ -143,8 +143,8 @@ public class MarkdownSkill : IAgentTool
|
|||||||
// 섹션 렌더링
|
// 섹션 렌더링
|
||||||
foreach (var section in sectionsEl.EnumerateArray())
|
foreach (var section in sectionsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (!section.TryGetProperty("type", out var typeEl)) continue;
|
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
|
||||||
var type = typeEl.GetString()?.ToLowerInvariant() ?? "";
|
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
|
||||||
|
|
||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
@@ -187,7 +187,7 @@ public class MarkdownSkill : IAgentTool
|
|||||||
if (useToc)
|
if (useToc)
|
||||||
{
|
{
|
||||||
// content에서 헤딩 파싱하여 TOC 생성
|
// content에서 헤딩 파싱하여 TOC 생성
|
||||||
var raw = contentEl.GetString() ?? "";
|
var raw = contentEl.SafeGetString() ?? "";
|
||||||
var headings = ParseHeadingsFromContent(raw);
|
var headings = ParseHeadingsFromContent(raw);
|
||||||
if (headings.Count > 0)
|
if (headings.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -195,7 +195,7 @@ public class MarkdownSkill : IAgentTool
|
|||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.Append(contentEl.GetString() ?? "");
|
sb.Append(contentEl.SafeGetString() ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 파일 쓰기 ──────────────────────────────────────────────────
|
// ── 파일 쓰기 ──────────────────────────────────────────────────
|
||||||
@@ -221,9 +221,9 @@ public class MarkdownSkill : IAgentTool
|
|||||||
var list = new List<(int, string)>();
|
var list = new List<(int, string)>();
|
||||||
foreach (var s in sections.EnumerateArray())
|
foreach (var s in sections.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (!s.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
|
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
|
||||||
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
||||||
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
|
||||||
if (!string.IsNullOrEmpty(text))
|
if (!string.IsNullOrEmpty(text))
|
||||||
list.Add((Math.Clamp(level, 1, 6), 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)
|
private static void RenderHeading(StringBuilder sb, JsonElement s)
|
||||||
{
|
{
|
||||||
var level = s.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
||||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||||
sb.AppendLine($"{new string('#', level)} {text}");
|
sb.AppendLine($"{new string('#', level)} {text}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RenderParagraph(StringBuilder sb, JsonElement s)
|
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))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
sb.AppendLine(text);
|
sb.AppendLine(text);
|
||||||
@@ -291,12 +291,12 @@ public class MarkdownSkill : IAgentTool
|
|||||||
|
|
||||||
private static void RenderTable(StringBuilder sb, JsonElement s)
|
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;
|
return;
|
||||||
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||||
return;
|
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;
|
if (headers.Count == 0) return;
|
||||||
|
|
||||||
// 헤더 행
|
// 헤더 행
|
||||||
@@ -322,14 +322,14 @@ public class MarkdownSkill : IAgentTool
|
|||||||
|
|
||||||
private static void RenderList(StringBuilder sb, JsonElement s)
|
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;
|
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;
|
int idx = 1;
|
||||||
foreach (var item in itemsEl.EnumerateArray())
|
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++}. " : "- ";
|
var prefix = ordered ? $"{idx++}. " : "- ";
|
||||||
if (text.StartsWith(" ") || text.StartsWith("\t"))
|
if (text.StartsWith(" ") || text.StartsWith("\t"))
|
||||||
@@ -342,8 +342,8 @@ public class MarkdownSkill : IAgentTool
|
|||||||
|
|
||||||
private static void RenderCallout(StringBuilder sb, JsonElement s)
|
private static void RenderCallout(StringBuilder sb, JsonElement s)
|
||||||
{
|
{
|
||||||
var style = s.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
||||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
// 표준 GitHub/Obsidian 콜아웃 형식
|
// 표준 GitHub/Obsidian 콜아웃 형식
|
||||||
sb.AppendLine($"> [!{style}]");
|
sb.AppendLine($"> [!{style}]");
|
||||||
@@ -354,8 +354,8 @@ public class MarkdownSkill : IAgentTool
|
|||||||
|
|
||||||
private static void RenderCode(StringBuilder sb, JsonElement s)
|
private static void RenderCode(StringBuilder sb, JsonElement s)
|
||||||
{
|
{
|
||||||
var lang = s.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
|
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
|
||||||
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
|
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
sb.AppendLine($"```{lang}");
|
sb.AppendLine($"```{lang}");
|
||||||
sb.AppendLine(code);
|
sb.AppendLine(code);
|
||||||
@@ -365,8 +365,8 @@ public class MarkdownSkill : IAgentTool
|
|||||||
|
|
||||||
private static void RenderQuote(StringBuilder sb, JsonElement s)
|
private static void RenderQuote(StringBuilder sb, JsonElement s)
|
||||||
{
|
{
|
||||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||||
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
|
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
|
||||||
|
|
||||||
foreach (var line in text.Split('\n'))
|
foreach (var line in text.Split('\n'))
|
||||||
sb.AppendLine($"> {line}");
|
sb.AppendLine($"> {line}");
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ public class MathTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var expression = args.GetProperty("expression").GetString() ?? "";
|
var expression = args.GetProperty("expression").SafeGetString() ?? "";
|
||||||
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
|
var precision = args.SafeTryGetProperty("precision", out var p) ? p.GetInt32() : 6;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(expression))
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
|
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)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var serverName = args.ValueKind == JsonValueKind.Object &&
|
var serverName = args.ValueKind == JsonValueKind.Object &&
|
||||||
args.TryGetProperty("server_name", out var serverProp)
|
args.SafeTryGetProperty("server_name", out var serverProp)
|
||||||
? serverProp.GetString() ?? ""
|
? serverProp.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
var clients = _getClients()
|
var clients = _getClients()
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ public class McpReadResourceTool : IAgentTool
|
|||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (args.ValueKind != JsonValueKind.Object ||
|
if (args.ValueKind != JsonValueKind.Object ||
|
||||||
!args.TryGetProperty("uri", out var uriProp) ||
|
!args.SafeTryGetProperty("uri", out var uriProp) ||
|
||||||
string.IsNullOrWhiteSpace(uriProp.GetString()))
|
string.IsNullOrWhiteSpace(uriProp.SafeGetString()))
|
||||||
return ToolResult.Fail("uri가 필요합니다.");
|
return ToolResult.Fail("uri가 필요합니다.");
|
||||||
|
|
||||||
var uri = uriProp.GetString()!;
|
var uri = uriProp.SafeGetString()!;
|
||||||
var serverName = args.TryGetProperty("server_name", out var serverProp)
|
var serverName = args.SafeTryGetProperty("server_name", out var serverProp)
|
||||||
? serverProp.GetString() ?? ""
|
? serverProp.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
var clients = _getClients()
|
var clients = _getClients()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class McpTool : IAgentTool
|
|||||||
{
|
{
|
||||||
arguments[prop.Name] = prop.Value.ValueKind switch
|
arguments[prop.Name] = prop.Value.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.String => prop.Value.GetString()!,
|
JsonValueKind.String => prop.Value.SafeGetString()!,
|
||||||
JsonValueKind.Number => prop.Value.GetDouble(),
|
JsonValueKind.Number => prop.Value.GetDouble(),
|
||||||
JsonValueKind.True => true,
|
JsonValueKind.True => true,
|
||||||
JsonValueKind.False => false,
|
JsonValueKind.False => false,
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
memoryService.Load(context.WorkFolder);
|
memoryService.Load(context.WorkFolder);
|
||||||
|
|
||||||
if (!args.TryGetProperty("action", out var actionEl))
|
if (!args.SafeTryGetProperty("action", out var actionEl))
|
||||||
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
||||||
var action = actionEl.GetString() ?? "";
|
var action = actionEl.SafeGetString() ?? "";
|
||||||
|
|
||||||
return Task.FromResult(action switch
|
return Task.FromResult(action switch
|
||||||
{
|
{
|
||||||
@@ -70,8 +70,8 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
|
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||||
{
|
{
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
|
var type = args.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "fact" : "fact";
|
||||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
return ToolResult.Fail("content가 필요합니다.");
|
return ToolResult.Fail("content가 필요합니다.");
|
||||||
@@ -87,7 +87,7 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
|
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))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
return ToolResult.Fail("query가 필요합니다.");
|
return ToolResult.Fail("query가 필요합니다.");
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
|
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))
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
return ToolResult.Fail("id가 필요합니다.");
|
return ToolResult.Fail("id가 필요합니다.");
|
||||||
|
|
||||||
@@ -161,8 +161,8 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
private static ToolResult ExecuteSaveScope(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() ?? "" : "";
|
||||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
if (string.IsNullOrWhiteSpace(scope))
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
@@ -176,8 +176,8 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
private static ToolResult ExecuteDeleteScope(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() ?? "" : "";
|
||||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
|
||||||
if (string.IsNullOrWhiteSpace(scope))
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
@@ -191,7 +191,7 @@ public class MemoryTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteShowScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
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))
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent;
|
|||||||
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
|
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
|
||||||
public class MultiReadTool : IAgentTool
|
public class MultiReadTool : IAgentTool
|
||||||
{
|
{
|
||||||
private const int MaxFiles = 20;
|
private const int MaxFiles = 8;
|
||||||
private const int DefaultMaxLines = 300;
|
private const int DefaultMaxLines = 300;
|
||||||
private const int HardMaxLines = 2000;
|
private const int HardMaxLines = 2000;
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ public class MultiReadTool : IAgentTool
|
|||||||
{
|
{
|
||||||
// --- Parse parameters ---
|
// --- Parse parameters ---
|
||||||
var maxLines = DefaultMaxLines;
|
var maxLines = DefaultMaxLines;
|
||||||
if (args.TryGetProperty("max_lines", out var mlEl))
|
if (args.SafeTryGetProperty("max_lines", out var mlEl))
|
||||||
{
|
{
|
||||||
maxLines = mlEl.GetInt32();
|
maxLines = mlEl.GetInt32();
|
||||||
if (maxLines <= 0) maxLines = DefaultMaxLines;
|
if (maxLines <= 0) maxLines = DefaultMaxLines;
|
||||||
@@ -60,23 +60,23 @@ public class MultiReadTool : IAgentTool
|
|||||||
|
|
||||||
// offset is 1-based; convert to 0-based skip count
|
// offset is 1-based; convert to 0-based skip count
|
||||||
var offsetParam = 1;
|
var offsetParam = 1;
|
||||||
if (args.TryGetProperty("offset", out var offEl))
|
if (args.SafeTryGetProperty("offset", out var offEl))
|
||||||
{
|
{
|
||||||
offsetParam = offEl.GetInt32();
|
offsetParam = offEl.GetInt32();
|
||||||
if (offsetParam < 1) offsetParam = 1;
|
if (offsetParam < 1) offsetParam = 1;
|
||||||
}
|
}
|
||||||
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 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 ---
|
// --- 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'는 문자열 배열이어야 합니다."));
|
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
|
||||||
|
|
||||||
var rawPaths = new List<string>();
|
var rawPaths = new List<string>();
|
||||||
foreach (var p in pathsEl.EnumerateArray())
|
foreach (var p in pathsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var s = p.GetString();
|
var s = p.SafeGetString();
|
||||||
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
|
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,23 +43,24 @@ public class NotifyTool : IAgentTool
|
|||||||
Required = ["title", "message"],
|
Required = ["title", "message"],
|
||||||
};
|
};
|
||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var title = args.GetProperty("title").GetString() ?? "알림";
|
var title = args.GetProperty("title").SafeGetString() ?? "알림";
|
||||||
var message = args.GetProperty("message").GetString() ?? "";
|
var message = args.GetProperty("message").SafeGetString() ?? "";
|
||||||
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
|
var level = args.SafeTryGetProperty("level", out var lv) ? lv.SafeGetString() ?? "info" : "info";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
// InvokeAsync로 변경 — Dispatcher.Invoke는 UI 스레드가 _convLock 대기 중일 때 데드락 발생
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
ShowToast(title, message, level);
|
ShowToast(title, message, level);
|
||||||
});
|
});
|
||||||
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
|
return ToolResult.Ok($"✓ Notification sent: [{level}] {title}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
|
return ToolResult.Fail($"알림 전송 실패: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +140,13 @@ public class NotifyTool : IAgentTool
|
|||||||
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
|
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
|
||||||
grid.Children.Add(toast);
|
grid.Children.Add(toast);
|
||||||
|
|
||||||
// 5초 후 자동 제거 (페이드 아웃)
|
// 5초 후 자동 제거 — DispatcherTimer 대신 애니메이션 Completed 사용하여 타이머 누적 방지
|
||||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300))
|
||||||
timer.Tick += (_, _) =>
|
|
||||||
{
|
{
|
||||||
timer.Stop();
|
BeginTime = TimeSpan.FromSeconds(5),
|
||||||
grid.Children.Remove(toast);
|
|
||||||
};
|
};
|
||||||
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)
|
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))
|
if (string.IsNullOrWhiteSpace(rawPath))
|
||||||
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
|
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
internal sealed record PermissionRequestPresentation(
|
internal sealed record PermissionRequestPresentation(
|
||||||
@@ -20,104 +22,104 @@ internal static class PermissionRequestPresentationCatalog
|
|||||||
if (tool.Contains("bash"))
|
if (tool.Contains("bash"))
|
||||||
return Build("bash", pending, "\uE756",
|
return Build("bash", pending, "\uE756",
|
||||||
"Bash 실행 권한 요청", "Bash 실행 확인",
|
"Bash 실행 권한 요청", "Bash 실행 확인",
|
||||||
"셸 명령을 실행하기 전에 확인이 필요합니다.",
|
"Bash 명령을 실행하기 전에 사용자 확인이 필요합니다.",
|
||||||
"Bash 실행이 승인되어 계속 진행합니다.",
|
"Bash 실행을 확인해 주시면 같은 흐름으로 이어집니다.",
|
||||||
"명령과 작업 위치를 확인하세요.",
|
"명령과 작업 위치를 먼저 확인해 주세요.",
|
||||||
"#FEF2F2", "#DC2626", "high", false);
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
if (tool.Contains("powershell"))
|
if (tool.Contains("powershell"))
|
||||||
return Build("powershell", pending, "\uE756",
|
return Build("powershell", pending, "\uE756",
|
||||||
"PowerShell 권한 요청", "PowerShell 실행 확인",
|
"PowerShell 권한 요청", "PowerShell 실행 확인",
|
||||||
"PowerShell 명령을 실행하기 전에 확인이 필요합니다.",
|
"PowerShell 명령을 실행하기 전에 사용자 확인이 필요합니다.",
|
||||||
"PowerShell 실행이 승인되어 계속 진행합니다.",
|
"PowerShell 실행을 승인하면 같은 작업을 이어서 진행합니다.",
|
||||||
"스크립트와 실행 범위를 확인하세요.",
|
"스크립트와 실행 범위를 확인해 주세요.",
|
||||||
"#FEF2F2", "#DC2626", "high", false);
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
if (tool.Contains("process") || tool.Contains("build") || tool.Contains("test"))
|
if (tool.Contains("process") || tool.Contains("build") || tool.Contains("test"))
|
||||||
return Build("command", pending, "\uE756",
|
return Build("command", pending, "\uE756",
|
||||||
"명령 실행 권한 요청", "명령 실행 확인",
|
"명령 실행 권한 요청", "명령 실행 확인",
|
||||||
"명령 또는 빌드 작업 실행 전에 확인이 필요합니다.",
|
"명령이나 빌드 작업을 실행하기 전에 확인이 필요합니다.",
|
||||||
"명령 실행이 승인되어 계속 진행합니다.",
|
"명령 실행을 승인하면 현재 흐름을 이어서 진행합니다.",
|
||||||
"실행 명령과 영향 범위를 확인하세요.",
|
"실행 명령과 영향 범위를 먼저 확인해 주세요.",
|
||||||
"#FEF2F2", "#DC2626", "high", false);
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||||||
return Build("web_fetch", pending, "\uE774",
|
return Build("web_fetch", pending, "\uE774",
|
||||||
"웹 요청 권한 요청", "웹 요청 확인",
|
"웹 요청 권한 요청", "웹 요청 확인",
|
||||||
"외부 요청을 보내기 전에 확인이 필요합니다.",
|
"외부 웹 요청을 보내기 전에 확인이 필요합니다.",
|
||||||
"웹 요청이 승인되어 계속 진행합니다.",
|
"요청 대상을 확인해 주시면 이어서 처리합니다.",
|
||||||
"조회 URL과 전송 범위를 확인하세요.",
|
"조회 URL과 전송 범위를 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "medium", false);
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
if (tool.Contains("mcp"))
|
if (tool.Contains("mcp"))
|
||||||
return Build("mcp", pending, "\uE943",
|
return Build("mcp", pending, "\uE943",
|
||||||
"MCP 도구 권한 요청", "MCP 도구 확인",
|
"MCP 도구 권한 요청", "MCP 도구 확인",
|
||||||
"연결된 MCP 도구 사용 전에 확인이 필요합니다.",
|
"연결된 MCP 도구를 사용하기 전에 확인이 필요합니다.",
|
||||||
"MCP 도구 사용이 승인되어 계속 진행합니다.",
|
"MCP 도구 사용을 승인하면 흐름을 이어서 진행합니다.",
|
||||||
"서버와 호출 의도를 확인하세요.",
|
"서버와 호출 도구를 확인해 주세요.",
|
||||||
"#F5F3FF", "#7C3AED", "medium", false);
|
"#F5F3FF", "#7C3AED", "medium", false);
|
||||||
|
|
||||||
if (tool.Contains("skill"))
|
if (tool.Contains("skill"))
|
||||||
return Build("skill", pending, "\uE8A5",
|
return Build("skill", pending, "\uE8A5",
|
||||||
"스킬 실행 권한 요청", "스킬 실행 확인",
|
"스킬 실행 권한 요청", "스킬 실행 확인",
|
||||||
"연결된 스킬을 실행하기 전에 확인이 필요합니다.",
|
"연결된 스킬을 실행하기 전에 확인이 필요합니다.",
|
||||||
"스킬 실행이 승인되어 계속 진행합니다.",
|
"스킬 실행을 승인하면 같은 작업 흐름을 이어갑니다.",
|
||||||
"허용 도구와 실행 컨텍스트를 확인하세요.",
|
"사용 도구와 실행 컨텍스트를 확인해 주세요.",
|
||||||
"#F5F3FF", "#7C3AED", "medium", false);
|
"#F5F3FF", "#7C3AED", "medium", false);
|
||||||
|
|
||||||
if (tool.Contains("ask"))
|
if (tool.Contains("ask"))
|
||||||
return Build("question", pending, "\uE897",
|
return Build("question", pending, "\uE897",
|
||||||
"의견 요청 확인", "의견 요청 완료",
|
"질문 요청 확인", "질문 요청 완료",
|
||||||
"사용자에게 선택이나 답변을 요청합니다.",
|
"사용자에게 추가 질문이나 선택을 요청합니다.",
|
||||||
"사용자 답변을 받아 다음 단계로 진행합니다.",
|
"응답을 받으면 다음 단계로 자동으로 이어집니다.",
|
||||||
"질문 의도와 선택지를 확인하세요.",
|
"질문 의도와 선택지를 확인해 주세요.",
|
||||||
"#EFF6FF", "#2563EB", "low", false);
|
"#EFF6FF", "#2563EB", "low", false);
|
||||||
|
|
||||||
if (tool.Contains("file_edit") || tool.Contains("edit"))
|
if (tool.Contains("file_edit") || tool.Contains("edit"))
|
||||||
return Build("file_edit", pending, "\uE70F",
|
return Build("file_edit", pending, "\uE70F",
|
||||||
"파일 수정 권한 요청", "파일 수정 확인",
|
"파일 수정 권한 요청", "파일 수정 확인",
|
||||||
"파일을 변경하기 전에 확인이 필요합니다.",
|
"파일을 변경하기 전에 사용자 확인이 필요합니다.",
|
||||||
"파일 수정이 승인되어 계속 진행합니다.",
|
"변경 내용을 확인하면 같은 흐름으로 이어집니다.",
|
||||||
"변경 diff와 대상 파일을 확인하세요.",
|
"변경 diff와 대상 파일을 먼저 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "high", true);
|
"#FFF7ED", "#C2410C", "high", true);
|
||||||
|
|
||||||
if (tool.Contains("file_write") || tool.Contains("write"))
|
if (tool.Contains("file_write") || tool.Contains("write"))
|
||||||
return Build("file_write", pending, "\uE70F",
|
return Build("file_write", pending, "\uE70F",
|
||||||
"파일 쓰기 권한 요청", "파일 쓰기 확인",
|
"파일 쓰기 권한 요청", "파일 쓰기 확인",
|
||||||
"새 파일 작성 또는 덮어쓰기 전에 확인이 필요합니다.",
|
"새 파일 생성이나 덮어쓰기 전에 사용자 확인이 필요합니다.",
|
||||||
"파일 쓰기가 승인되어 계속 진행합니다.",
|
"파일 쓰기를 승인하면 같은 흐름을 이어갑니다.",
|
||||||
"작성 위치와 새 내용 미리보기를 확인하세요.",
|
"생성 위치와 내용 미리보기를 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "high", true);
|
"#FFF7ED", "#C2410C", "high", true);
|
||||||
|
|
||||||
if (tool.Contains("git"))
|
if (tool.Contains("git"))
|
||||||
return Build("git", pending, "\uE8A7",
|
return Build("git", pending, "\uE8A7",
|
||||||
"Git 작업 권한 요청", "Git 작업 확인",
|
"Git 작업 권한 요청", "Git 작업 확인",
|
||||||
"브랜치나 커밋 상태를 바꾸기 전에 확인이 필요합니다.",
|
"브랜치나 작업트리 상태를 바꾸기 전에 확인이 필요합니다.",
|
||||||
"Git 작업이 승인되어 계속 진행합니다.",
|
"Git 작업을 승인하면 같은 흐름을 이어갑니다.",
|
||||||
"브랜치와 변경 범위를 확인하세요.",
|
"브랜치와 변경 범위를 확인해 주세요.",
|
||||||
"#EFF6FF", "#2563EB", "medium", false);
|
"#EFF6FF", "#2563EB", "medium", false);
|
||||||
|
|
||||||
if (tool.Contains("document") || tool.Contains("template") || tool.Contains("format"))
|
if (tool.Contains("document") || tool.Contains("template") || tool.Contains("format"))
|
||||||
return Build("document", pending, "\uE8A5",
|
return Build("document", pending, "\uE8A5",
|
||||||
"문서 작업 권한 요청", "문서 작업 확인",
|
"문서 작업 권한 요청", "문서 작업 확인",
|
||||||
"문서 생성 또는 변환 작업 전에 확인이 필요합니다.",
|
"문서 생성이나 변환 작업 전에 확인이 필요합니다.",
|
||||||
"문서 작업이 승인되어 계속 진행합니다.",
|
"문서 작업을 승인하면 같은 흐름을 이어갑니다.",
|
||||||
"출력 형식과 저장 위치를 확인하세요.",
|
"출력 형식과 대상 위치를 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "medium", false);
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
if (tool.Contains("file") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder"))
|
if (tool.Contains("file") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder"))
|
||||||
return Build("filesystem", pending, "\uE8A5",
|
return Build("filesystem", pending, "\uE8A5",
|
||||||
"파일 접근 권한 요청", "파일 접근 확인",
|
"파일 접근 권한 요청", "파일 접근 확인",
|
||||||
"폴더나 파일 내용을 읽기 전에 확인이 필요합니다.",
|
"파일이나 폴더를 읽기 전에 확인이 필요합니다.",
|
||||||
"파일 접근이 승인되어 계속 진행합니다.",
|
"파일 접근을 승인하면 같은 흐름으로 이어집니다.",
|
||||||
"읽기 범위와 접근 경로를 확인하세요.",
|
"읽기 범위와 경로를 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "medium", false);
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
return Build("generic", pending, "\uE897",
|
return Build("generic", pending, "\uE897",
|
||||||
"권한 요청", "권한 확인",
|
"권한 요청", "권한 확인",
|
||||||
"계속 진행하기 전에 사용자의 확인이 필요합니다.",
|
"계속 진행하기 전에 사용자 확인이 필요합니다.",
|
||||||
"요청이 승인되어 계속 진행합니다.",
|
"요청을 승인하면 같은 흐름으로 이어집니다.",
|
||||||
"실행 의도와 대상 범위를 확인하세요.",
|
"실행 의도와 대상 범위를 확인해 주세요.",
|
||||||
"#FFF7ED", "#C2410C", "medium", false);
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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이 필요합니다.");
|
return ToolResult.Fail("action이 필요합니다.");
|
||||||
var action = actionEl.GetString() ?? "";
|
var action = actionEl.SafeGetString() ?? "";
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||||
@@ -84,8 +84,8 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
|
||||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return ToolResult.Fail("플레이북 name이 필요합니다.");
|
return ToolResult.Fail("플레이북 name이 필요합니다.");
|
||||||
@@ -94,11 +94,11 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
// steps 파싱
|
// steps 파싱
|
||||||
var steps = new List<string>();
|
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())
|
foreach (var step in stepsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var s = step.GetString();
|
var s = step.SafeGetString();
|
||||||
if (!string.IsNullOrWhiteSpace(s))
|
if (!string.IsNullOrWhiteSpace(s))
|
||||||
steps.Add(s);
|
steps.Add(s);
|
||||||
}
|
}
|
||||||
@@ -109,11 +109,11 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
// tools_used 파싱
|
// tools_used 파싱
|
||||||
var toolsUsed = new List<string>();
|
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())
|
foreach (var tool in toolsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var t = tool.GetString();
|
var t = tool.SafeGetString();
|
||||||
if (!string.IsNullOrWhiteSpace(t))
|
if (!string.IsNullOrWhiteSpace(t))
|
||||||
toolsUsed.Add(t);
|
toolsUsed.Add(t);
|
||||||
}
|
}
|
||||||
@@ -192,10 +192,10 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
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가 필요합니다.");
|
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);
|
var playbook = await FindPlaybookById(playbookDir, id, ct);
|
||||||
|
|
||||||
if (playbook == null)
|
if (playbook == null)
|
||||||
@@ -222,10 +222,10 @@ public class PlaybookTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
|
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가 필요합니다.");
|
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))
|
if (!Directory.Exists(playbookDir))
|
||||||
return ToolResult.Fail("저장된 플레이북이 없습니다.");
|
return ToolResult.Fail("저장된 플레이북이 없습니다.");
|
||||||
|
|||||||
@@ -298,12 +298,12 @@ public class PptxSkill : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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;
|
string path;
|
||||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
path = pathEl.GetString()!;
|
path = pathEl.SafeGetString()!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -311,10 +311,10 @@ public class PptxSkill : IAgentTool
|
|||||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||||
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
|
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
|
||||||
}
|
}
|
||||||
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
|
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
|
||||||
var aspect = args.TryGetProperty("aspect", out var asp) ? asp.GetString() ?? "widescreen" : "widescreen";
|
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 배열이 필요합니다.");
|
return ToolResult.Fail("slides 배열이 필요합니다.");
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
@@ -333,10 +333,10 @@ public class PptxSkill : IAgentTool
|
|||||||
// ── 테마 결정 우선순위: theme_file > custom_colors > theme 이름 ──────
|
// ── 테마 결정 우선순위: theme_file > custom_colors > theme 이름 ──────
|
||||||
FullTheme fullTheme;
|
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)
|
// 기존 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 extracted = ExtractThemeFromPptx(tfPath);
|
||||||
var baseLayout = FullThemes["professional"].Layout;
|
var baseLayout = FullThemes["professional"].Layout;
|
||||||
fullTheme = extracted != null
|
fullTheme = extracted != null
|
||||||
@@ -344,12 +344,12 @@ public class PptxSkill : IAgentTool
|
|||||||
: FullThemes["professional"];
|
: FullThemes["professional"];
|
||||||
}
|
}
|
||||||
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
|
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)
|
&& ccEl.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
// 사용자 지정 색상 → default layout (professional)
|
// 사용자 지정 색상 → default layout (professional)
|
||||||
static string Hex(JsonElement obj, string key, string fallback) =>
|
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(
|
var customColors = new ThemeColors(
|
||||||
Primary: Hex(ccEl, "primary", "1F4E79"),
|
Primary: Hex(ccEl, "primary", "1F4E79"),
|
||||||
Accent: Hex(ccEl, "accent", "2E75B6"),
|
Accent: Hex(ccEl, "accent", "2E75B6"),
|
||||||
@@ -441,7 +441,7 @@ public class PptxSkill : IAgentTool
|
|||||||
|
|
||||||
foreach (var slideEl in slidesEl.EnumerateArray())
|
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>();
|
var slidePart = presPart.AddNewPart<SlidePart>();
|
||||||
slidePart.AddPart(layoutPart);
|
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 &&
|
notesEl.ValueKind == JsonValueKind.String &&
|
||||||
!string.IsNullOrWhiteSpace(notesEl.GetString()))
|
!string.IsNullOrWhiteSpace(notesEl.SafeGetString()))
|
||||||
{
|
{
|
||||||
AddNotesSlide(slidePart, notesEl.GetString()!);
|
AddNotesSlide(slidePart, notesEl.SafeGetString()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
slidePart.Slide.Save();
|
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)
|
private static void BuildTableSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
|
||||||
{
|
{
|
||||||
var title = Str(s, "title");
|
var title = Str(s, "title");
|
||||||
var headers = s.TryGetProperty("headers", out var hEl)
|
var headers = s.SafeTryGetProperty("headers", out var hEl)
|
||||||
? hEl.EnumerateArray().Select(x => x.GetString() ?? "").ToList()
|
? hEl.EnumerateArray().Select(x => x.SafeGetString() ?? "").ToList()
|
||||||
: new List<string>();
|
: new List<string>();
|
||||||
var rows = s.TryGetProperty("rows", out var rEl)
|
var rows = s.SafeTryGetProperty("rows", out var rEl)
|
||||||
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.GetString() ?? "").ToList()).ToList()
|
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
|
||||||
: new List<List<string>>();
|
: new List<List<string>>();
|
||||||
const long M = 450000;
|
const long M = 450000;
|
||||||
|
|
||||||
@@ -1233,8 +1233,8 @@ public class PptxSkill : IAgentTool
|
|||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
private static string Str(JsonElement e, string key)
|
private static string Str(JsonElement e, string key)
|
||||||
=> e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
|
=> e.SafeTryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
? v.GetString() ?? ""
|
? v.SafeGetString() ?? ""
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
private static void AddNotesSlide(SlidePart slidePart, string notes)
|
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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,11 +37,11 @@ public class ProcessTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
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가 필요합니다.");
|
return ToolResult.Fail("command가 필요합니다.");
|
||||||
var command = cmdEl.GetString() ?? "";
|
var command = cmdEl.SafeGetString() ?? "";
|
||||||
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
|
var shell = args.SafeTryGetProperty("shell", out var sh) ? sh.SafeGetString() ?? "cmd" : "cmd";
|
||||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(command))
|
if (string.IsNullOrWhiteSpace(command))
|
||||||
return ToolResult.Fail("명령이 비어 있습니다.");
|
return ToolResult.Fail("명령이 비어 있습니다.");
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ public class ProjectRuleTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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() ?? "" : "";
|
||||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
|
var section = args.SafeTryGetProperty("section", out var s) ? s.SafeGetString() ?? "" : "";
|
||||||
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
|
var ruleName = args.SafeTryGetProperty("rule_name", out var rn) ? rn.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ public class RegexTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||||
var text = args.GetProperty("text").GetString() ?? "";
|
var text = args.GetProperty("text").SafeGetString() ?? "";
|
||||||
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
|
var replacement = args.SafeTryGetProperty("replacement", out var r) ? r.SafeGetString() ?? "" : "";
|
||||||
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
|
var flags = args.SafeTryGetProperty("flags", out var f) ? f.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ public class SkillManagerTool : IAgentTool
|
|||||||
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
|
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
|
||||||
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
|
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
|
||||||
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||||
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
|
var skillName = args.SafeTryGetProperty("skill_name", out var s) ? s.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
return action switch
|
return action switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ public class SnippetRunnerTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var language = args.GetProperty("language").GetString() ?? "";
|
var language = args.GetProperty("language").SafeGetString() ?? "";
|
||||||
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
|
var code = args.SafeTryGetProperty("code", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(code))
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
return ToolResult.Fail("code가 비어 있습니다.");
|
return ToolResult.Fail("code가 비어 있습니다.");
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ public class SqlTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
var dbPath = args.GetProperty("db_path").GetString() ?? "";
|
var dbPath = args.GetProperty("db_path").SafeGetString() ?? "";
|
||||||
|
|
||||||
if (!Path.IsPathRooted(dbPath))
|
if (!Path.IsPathRooted(dbPath))
|
||||||
dbPath = Path.Combine(context.WorkFolder, dbPath);
|
dbPath = Path.Combine(context.WorkFolder, dbPath);
|
||||||
@@ -86,10 +86,10 @@ public class SqlTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
|
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");
|
return ToolResult.Fail("'sql' parameter is required for query action");
|
||||||
|
|
||||||
var sql = sqlProp.GetString() ?? "";
|
var sql = sqlProp.SafeGetString() ?? "";
|
||||||
|
|
||||||
// SELECT만 허용
|
// SELECT만 허용
|
||||||
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
|
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -97,8 +97,8 @@ public class SqlTool : IAgentTool
|
|||||||
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
|
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
|
||||||
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
|
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)
|
var maxRows = args.SafeTryGetProperty("max_rows", out var mr)
|
||||||
? Math.Min(mrv, 1000) : 100;
|
? Math.Min(mr.SafeGetInt32(100), 1000) : 100;
|
||||||
|
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
cmd.CommandText = sql;
|
cmd.CommandText = sql;
|
||||||
@@ -135,10 +135,10 @@ public class SqlTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
|
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");
|
return ToolResult.Fail("'sql' parameter is required for execute action");
|
||||||
|
|
||||||
var sql = sqlProp.GetString() ?? "";
|
var sql = sqlProp.SafeGetString() ?? "";
|
||||||
|
|
||||||
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
|
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
|
||||||
var trimmed = sql.TrimStart().ToUpperInvariant();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ public class SubAgentTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
|
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
|
||||||
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
|
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
|
||||||
return Task.FromResult(ToolResult.Fail("task and id are required."));
|
return Task.FromResult(ToolResult.Fail("task and id are required."));
|
||||||
@@ -72,11 +72,15 @@ public class SubAgentTool : IAgentTool
|
|||||||
StartedAt = DateTime.Now,
|
StartedAt = DateTime.Now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// P2: 부모 취소 토큰 연동 — 부모 에이전트 중지 시 자식도 즉시 취소
|
||||||
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
subTask.Cts = cts;
|
||||||
|
|
||||||
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
|
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
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.Result = result;
|
||||||
subTask.Success = true;
|
subTask.Success = true;
|
||||||
NotifyStatus(new SubAgentStatusEvent
|
NotifyStatus(new SubAgentStatusEvent
|
||||||
@@ -89,6 +93,20 @@ public class SubAgentTool : IAgentTool
|
|||||||
Timestamp = DateTime.Now,
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
subTask.Result = $"Error: {ex.Message}";
|
subTask.Result = $"Error: {ex.Message}";
|
||||||
@@ -106,8 +124,9 @@ public class SubAgentTool : IAgentTool
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
subTask.CompletedAt = DateTime.Now;
|
subTask.CompletedAt = DateTime.Now;
|
||||||
|
cts.Dispose();
|
||||||
}
|
}
|
||||||
}, CancellationToken.None);
|
}, cts.Token);
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
_activeTasks[id] = subTask;
|
_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."));
|
$"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);
|
var settings = CreateSubAgentSettings(parentContext);
|
||||||
using var llm = new LlmService(settings);
|
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 eventSummary = SummarizeEvents(loop.Events);
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -451,16 +470,16 @@ public class WaitAgentsTool : IAgentTool
|
|||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<string>? ids = null;
|
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()
|
ids = idsEl.EnumerateArray()
|
||||||
.Where(x => x.ValueKind == JsonValueKind.String)
|
.Where(x => x.ValueKind == JsonValueKind.String)
|
||||||
.Select(x => x.GetString() ?? "")
|
.Select(x => x.SafeGetString() ?? "")
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
|
var completedOnly = args.SafeTryGetProperty("completed_only", out var completedEl) &&
|
||||||
completedEl.ValueKind == JsonValueKind.True;
|
completedEl.ValueKind == JsonValueKind.True;
|
||||||
|
|
||||||
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
|
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
|
||||||
@@ -477,6 +496,7 @@ public class SubAgentTask
|
|||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public string? Result { get; set; }
|
public string? Result { get; set; }
|
||||||
public Task? RunTask { get; set; }
|
public Task? RunTask { get; set; }
|
||||||
|
public CancellationTokenSource? Cts { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum SubAgentRunStatus
|
public enum SubAgentRunStatus
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ public class SuggestActionsTool : IAgentTool
|
|||||||
{
|
{
|
||||||
try
|
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 배열이 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
|
||||||
|
|
||||||
var actions = new List<Dictionary<string, string>>();
|
var actions = new List<Dictionary<string, string>>();
|
||||||
foreach (var item in actionsEl.EnumerateArray())
|
foreach (var item in actionsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
|
var label = item.SafeTryGetProperty("label", out var l) ? l.SafeGetString() ?? "" : "";
|
||||||
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
|
var command = item.SafeTryGetProperty("command", out var c) ? c.SafeGetString() ?? "" : "";
|
||||||
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
|
var icon = item.SafeTryGetProperty("icon", out var i) ? i.SafeGetString() ?? "" : "";
|
||||||
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
|
var priority = item.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(label))
|
if (string.IsNullOrWhiteSpace(label))
|
||||||
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
|
||||||
@@ -76,7 +76,7 @@ public class SuggestActionsTool : IAgentTool
|
|||||||
if (actions.Count < 1 || actions.Count > 5)
|
if (actions.Count < 1 || actions.Count > 5)
|
||||||
return Task.FromResult(ToolResult.Fail("actions는 1~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 응답 생성
|
// 구조화된 JSON 응답 생성
|
||||||
var result = new Dictionary<string, object>
|
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)
|
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))
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
return Task.FromResult(ToolResult.Fail("title is required."));
|
return Task.FromResult(ToolResult.Fail("title is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|
||||||
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
|
var description = args.SafeTryGetProperty("description", out var descEl) ? (descEl.SafeGetString() ?? "").Trim() : "";
|
||||||
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
|
var priority = args.SafeTryGetProperty("priority", out var priEl) ? (priEl.SafeGetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
|
||||||
if (!TaskBoardStore.IsValidPriority(priority))
|
if (!TaskBoardStore.IsValidPriority(priority))
|
||||||
priority = "medium";
|
priority = "medium";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed class TaskGetTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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."));
|
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class TaskListTool : IAgentTool
|
|||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
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);
|
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||||
if (!string.IsNullOrWhiteSpace(status))
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();
|
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)
|
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."));
|
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."));
|
return Task.FromResult(ToolResult.Fail("output is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|
||||||
var id = idEl.GetInt32();
|
var id = idEl.GetInt32();
|
||||||
var output = (outEl.GetString() ?? "").Trim();
|
var output = (outEl.SafeGetString() ?? "").Trim();
|
||||||
if (string.IsNullOrWhiteSpace(output))
|
if (string.IsNullOrWhiteSpace(output))
|
||||||
return Task.FromResult(ToolResult.Fail("output is empty."));
|
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 tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||||
if (task == null)
|
if (task == null)
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ public sealed class TaskStopTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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."));
|
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|
||||||
var id = idEl.GetInt32();
|
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 tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||||
if (task == null)
|
if (task == null)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class TaskTrackerTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.GetProperty("action").GetString() ?? "";
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -75,8 +75,8 @@ public class TaskTrackerTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
|
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
|
||||||
{
|
{
|
||||||
var extStr = args.TryGetProperty("extensions", out var e)
|
var extStr = args.SafeTryGetProperty("extensions", out var e)
|
||||||
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
|
? e.SafeGetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
|
||||||
: ".cs,.py,.js,.ts,.java,.cpp,.c";
|
: ".cs,.py,.js,.ts,.java,.cpp,.c";
|
||||||
var exts = new HashSet<string>(
|
var exts = new HashSet<string>(
|
||||||
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
|
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)
|
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))
|
if (string.IsNullOrEmpty(title))
|
||||||
return ToolResult.Fail("'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 tasks = LoadTasks(context);
|
||||||
|
|
||||||
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
|
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)
|
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'가 필요합니다.");
|
return ToolResult.Fail("'id'가 필요합니다.");
|
||||||
var id = idEl.GetInt32();
|
var id = idEl.GetInt32();
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class TaskUpdateTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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."));
|
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
@@ -37,27 +37,27 @@ public sealed class TaskUpdateTool : IAgentTool
|
|||||||
if (task == null)
|
if (task == null)
|
||||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
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))
|
if (!TaskBoardStore.IsValidStatus(status))
|
||||||
return Task.FromResult(ToolResult.Fail("Invalid status."));
|
return Task.FromResult(ToolResult.Fail("Invalid status."));
|
||||||
task.Status = 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))
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
task.Title = title;
|
task.Title = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.TryGetProperty("description", out var descEl))
|
if (args.SafeTryGetProperty("description", out var descEl))
|
||||||
task.Description = (descEl.GetString() ?? "").Trim();
|
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))
|
if (!TaskBoardStore.IsValidPriority(priority))
|
||||||
return Task.FromResult(ToolResult.Fail("Invalid priority."));
|
return Task.FromResult(ToolResult.Fail("Invalid priority."));
|
||||||
task.Priority = priority;
|
task.Priority = priority;
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ public sealed class TeamCreateTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return Task.FromResult(ToolResult.Fail("name is required."));
|
return Task.FromResult(ToolResult.Fail("name is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
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 members = TeamStore.Load(context.WorkFolder);
|
||||||
var member = new TeamStore.Member { Name = name, Role = role };
|
var member = new TeamStore.Member { Name = name, Role = role };
|
||||||
members.Add(member);
|
members.Add(member);
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public sealed class TeamDeleteTool : IAgentTool
|
|||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||||
|
|
||||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
|
||||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
|
||||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||||
|
|
||||||
|
|||||||
@@ -51,25 +51,25 @@ public class TemplateRenderTool : IAgentTool
|
|||||||
{
|
{
|
||||||
// 템플릿 텍스트 로드
|
// 템플릿 텍스트 로드
|
||||||
string template;
|
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))
|
if (!context.IsPathAllowed(templatePath))
|
||||||
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
|
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
|
||||||
if (!File.Exists(templatePath))
|
if (!File.Exists(templatePath))
|
||||||
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
|
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
|
||||||
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
|
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
|
else
|
||||||
{
|
{
|
||||||
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
|
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가 필요합니다.");
|
return ToolResult.Fail("variables가 필요합니다.");
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -78,9 +78,9 @@ public class TemplateRenderTool : IAgentTool
|
|||||||
var rendered = Render(template, varsEl);
|
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.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
|
||||||
if (!context.IsPathAllowed(outputPath))
|
if (!context.IsPathAllowed(outputPath))
|
||||||
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
|
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
|
||||||
@@ -117,7 +117,7 @@ public class TemplateRenderTool : IAgentTool
|
|||||||
var key = match.Groups[1].Value;
|
var key = match.Groups[1].Value;
|
||||||
var body = match.Groups[2].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)
|
if (val.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
@@ -167,11 +167,11 @@ public class TemplateRenderTool : IAgentTool
|
|||||||
var key = match.Groups[1].Value;
|
var key = match.Groups[1].Value;
|
||||||
var body = match.Groups[2].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 ||
|
if (val.ValueKind == JsonValueKind.False ||
|
||||||
val.ValueKind == JsonValueKind.Null ||
|
val.ValueKind == JsonValueKind.Null ||
|
||||||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
|
(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 body;
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@@ -188,11 +188,11 @@ public class TemplateRenderTool : IAgentTool
|
|||||||
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
|
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
|
||||||
{
|
{
|
||||||
var key = match.Groups[1].Value;
|
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
|
return val.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.String => val.GetString() ?? "",
|
JsonValueKind.String => val.SafeGetString() ?? "",
|
||||||
JsonValueKind.Number => val.ToString(),
|
JsonValueKind.Number => val.ToString(),
|
||||||
JsonValueKind.True => "true",
|
JsonValueKind.True => "true",
|
||||||
JsonValueKind.False => "false",
|
JsonValueKind.False => "false",
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public static class TemplateService
|
|||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||||
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
|
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;
|
border-radius: 16px; padding: 56px 52px;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
|
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; }
|
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; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||||
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
|
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;
|
border-radius: 8px; padding: 48px;
|
||||||
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
|
||||||
border-top: 4px solid #1e3a5f; }
|
border-top: 4px solid #1e3a5f; }
|
||||||
@@ -260,7 +260,7 @@ public static class TemplateService
|
|||||||
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
|
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;
|
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||||
h1 { font-size: 30px; font-weight: 700;
|
h1 { font-size: 30px; font-weight: 700;
|
||||||
@@ -338,7 +338,7 @@ public static class TemplateService
|
|||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
|
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
|
||||||
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
|
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;
|
border-radius: 4px; padding: 56px 52px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
border: 1px solid #e8e4dd; }
|
border: 1px solid #e8e4dd; }
|
||||||
@@ -377,7 +377,7 @@ public static class TemplateService
|
|||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||||
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
|
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-radius: 12px; padding: 52px;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
|
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;
|
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||||
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
|
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
|
||||||
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
|
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;
|
border-radius: 20px; padding: 52px;
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
|
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
|
||||||
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
|
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; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||||
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
|
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); }
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
|
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
|
||||||
border-bottom: 3px solid #ff6600; }
|
border-bottom: 3px solid #ff6600; }
|
||||||
@@ -499,7 +499,7 @@ public static class TemplateService
|
|||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
|
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
|
||||||
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
|
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;
|
border-radius: 2px; padding: 0; overflow: hidden;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||||
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public class TestLoopTool : IAgentTool
|
|||||||
|
|
||||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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
|
return action switch
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@ public class TestLoopTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
|
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))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
return ToolResult.Fail("file_path가 필요합니다.");
|
return ToolResult.Fail("file_path가 필요합니다.");
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ public class TestLoopTool : IAgentTool
|
|||||||
|
|
||||||
private static ToolResult AnalyzeTestOutput(JsonElement args)
|
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))
|
if (string.IsNullOrEmpty(output))
|
||||||
return ToolResult.Fail("test_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)
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var source = args.GetProperty("source").GetString() ?? "";
|
var source = args.GetProperty("source").SafeGetString() ?? "";
|
||||||
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
|
var maxLength = args.SafeTryGetProperty("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 style = args.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "bullet" : "bullet";
|
||||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
|
||||||
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
|
var focus = args.SafeTryGetProperty("focus", out var focEl) ? focEl.SafeGetString() ?? "" : "";
|
||||||
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
|
var bySections = args.SafeTryGetProperty("sections", out var secEl) && secEl.GetBoolean();
|
||||||
|
|
||||||
string text;
|
string text;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public sealed class TodoWriteTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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))
|
if (string.IsNullOrWhiteSpace(action))
|
||||||
return Task.FromResult(ToolResult.Fail("action is required."));
|
return Task.FromResult(ToolResult.Fail("action is required."));
|
||||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
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)
|
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))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
return ToolResult.Fail("text is required for add.");
|
return ToolResult.Fail("text is required for add.");
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ public sealed class TodoWriteTool : IAgentTool
|
|||||||
{
|
{
|
||||||
if (!File.Exists(todoPath))
|
if (!File.Exists(todoPath))
|
||||||
return ToolResult.Fail("TODO file does not exist.");
|
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.");
|
return ToolResult.Fail("index is required for done.");
|
||||||
|
|
||||||
var targetIndex = indexEl.GetInt32();
|
var targetIndex = indexEl.GetInt32();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
internal sealed record ToolResultPresentation(
|
internal sealed record ToolResultPresentation(
|
||||||
@@ -29,7 +31,7 @@ internal static class ToolResultPresentationCatalog
|
|||||||
"\uE711",
|
"\uE711",
|
||||||
$"{baseLabel} 취소",
|
$"{baseLabel} 취소",
|
||||||
"요청이 중단되어 결과가 취소되었습니다.",
|
"요청이 중단되어 결과가 취소되었습니다.",
|
||||||
"필요하면 같은 요청을 다시 실행하세요.",
|
"필요하면 같은 요청을 다시 실행할 수 있습니다.",
|
||||||
"#F8FAFC",
|
"#F8FAFC",
|
||||||
"#475569",
|
"#475569",
|
||||||
"cancel",
|
"cancel",
|
||||||
@@ -45,7 +47,7 @@ internal static class ToolResultPresentationCatalog
|
|||||||
"\uE783",
|
"\uE783",
|
||||||
$"{baseLabel} 거부",
|
$"{baseLabel} 거부",
|
||||||
"권한이 거부되어 작업이 중단되었습니다.",
|
"권한이 거부되어 작업이 중단되었습니다.",
|
||||||
"권한 모드를 바꾸거나 다시 승인하면 이어서 진행할 수 있습니다.",
|
"권한 모드를 바꾸거나 다시 확인하면 이어서 진행할 수 있습니다.",
|
||||||
"#FEF2F2",
|
"#FEF2F2",
|
||||||
"#DC2626",
|
"#DC2626",
|
||||||
"reject",
|
"reject",
|
||||||
@@ -58,8 +60,8 @@ internal static class ToolResultPresentationCatalog
|
|||||||
return new ToolResultPresentation(
|
return new ToolResultPresentation(
|
||||||
kind,
|
kind,
|
||||||
"\uE8D7",
|
"\uE8D7",
|
||||||
$"{baseLabel} 승인 대기",
|
$"{baseLabel} 승인 필요",
|
||||||
"다음 단계로 진행하려면 사용자 승인이 필요합니다.",
|
"다음 단계로 진행하려면 사용자 승인이나 확인이 필요합니다.",
|
||||||
"승인 후 같은 작업 흐름이 이어집니다.",
|
"승인 후 같은 작업 흐름이 이어집니다.",
|
||||||
"#FFF7ED",
|
"#FFF7ED",
|
||||||
"#C2410C",
|
"#C2410C",
|
||||||
@@ -74,8 +76,8 @@ internal static class ToolResultPresentationCatalog
|
|||||||
kind,
|
kind,
|
||||||
"\uE7BA",
|
"\uE7BA",
|
||||||
$"{baseLabel} 부분 완료",
|
$"{baseLabel} 부분 완료",
|
||||||
"일부 단계만 완료되어 후속 확인이나 재실행이 필요할 수 있습니다.",
|
"일부 단계만 완료되어 후속 확인이나 재시도가 필요할 수 있습니다.",
|
||||||
"남은 단계나 누락된 결과를 확인하세요.",
|
"후속 단계와 파일 결과를 확인해 주세요.",
|
||||||
"#FFFBEA",
|
"#FFFBEA",
|
||||||
"#A16207",
|
"#A16207",
|
||||||
"partial",
|
"partial",
|
||||||
@@ -114,7 +116,7 @@ internal static class ToolResultPresentationCatalog
|
|||||||
return "file_edit";
|
return "file_edit";
|
||||||
if (tool.Contains("file_write"))
|
if (tool.Contains("file_write"))
|
||||||
return "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";
|
return "filesystem";
|
||||||
if (tool.Contains("file"))
|
if (tool.Contains("file"))
|
||||||
return "file";
|
return "file";
|
||||||
@@ -141,9 +143,7 @@ internal static class ToolResultPresentationCatalog
|
|||||||
return "generic";
|
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_edit" => "파일 수정 완료",
|
||||||
"file_write" => "파일 쓰기 완료",
|
"file_write" => "파일 쓰기 완료",
|
||||||
@@ -154,16 +154,13 @@ internal static class ToolResultPresentationCatalog
|
|||||||
"document" => "문서 작업 완료",
|
"document" => "문서 작업 완료",
|
||||||
"skill" => "스킬 실행 완료",
|
"skill" => "스킬 실행 완료",
|
||||||
"mcp" => "MCP 도구 완료",
|
"mcp" => "MCP 도구 완료",
|
||||||
"question" => "의견 요청 완료",
|
"question" => "질문 요청 완료",
|
||||||
"web" => "웹 요청 완료",
|
"web" => "웹 요청 완료",
|
||||||
"command" => "명령 실행 완료",
|
"command" => "명령 실행 완료",
|
||||||
_ => baseLabel,
|
_ => 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_edit" => "파일 수정 실패",
|
||||||
"file_write" => "파일 쓰기 실패",
|
"file_write" => "파일 쓰기 실패",
|
||||||
@@ -174,78 +171,65 @@ internal static class ToolResultPresentationCatalog
|
|||||||
"document" => "문서 작업 실패",
|
"document" => "문서 작업 실패",
|
||||||
"skill" => "스킬 실행 실패",
|
"skill" => "스킬 실행 실패",
|
||||||
"mcp" => "MCP 도구 실패",
|
"mcp" => "MCP 도구 실패",
|
||||||
"question" => "의견 요청 실패",
|
"question" => "질문 요청 실패",
|
||||||
"web" => "웹 요청 실패",
|
"web" => "웹 요청 실패",
|
||||||
"command" => "명령 실행 실패",
|
"command" => "명령 실행 실패",
|
||||||
_ => $"{baseLabel} 실패",
|
_ => $"{baseLabel} 실패",
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildSuccessDescription(string kind)
|
private static string BuildSuccessDescription(string kind) => kind switch
|
||||||
{
|
{
|
||||||
return kind switch
|
"file_edit" => "파일 수정 결과가 정상적으로 반영되었습니다.",
|
||||||
{
|
"file_write" => "새 파일 생성이나 쓰기 작업이 완료되었습니다.",
|
||||||
"file_edit" => "파일 수정 결과가 저장되었습니다.",
|
"filesystem" => "질문과 관련된 파일과 구조 정보를 찾았습니다.",
|
||||||
"file_write" => "새 파일 작성 결과가 저장되었습니다.",
|
|
||||||
"filesystem" => "파일과 폴더 정보를 성공적으로 읽었습니다.",
|
|
||||||
"file" => "파일 관련 작업이 정상적으로 끝났습니다.",
|
"file" => "파일 관련 작업이 정상적으로 끝났습니다.",
|
||||||
"build_test" => "빌드 또는 테스트 단계가 성공적으로 끝났습니다.",
|
"build_test" => "빌드나 테스트 단계가 정상적으로 끝났습니다.",
|
||||||
"git" => "Git 관련 작업이 정상적으로 끝났습니다.",
|
"git" => "저장소 상태 확인 또는 변경 작업이 완료되었습니다.",
|
||||||
"document" => "문서 생성 또는 변환 작업이 완료되었습니다.",
|
"document" => "문서 산출물 생성 또는 조립이 완료되었습니다.",
|
||||||
"skill" => "선택한 스킬이 정상적으로 실행되었습니다.",
|
"skill" => "선택한 스킬이 정상적으로 실행되었습니다.",
|
||||||
"mcp" => "등록된 MCP 도구 호출이 성공적으로 끝났습니다.",
|
"mcp" => "등록된 MCP 도구 호출이 완료되었습니다.",
|
||||||
"question" => "사용자 응답을 받아 다음 단계로 넘어갈 수 있습니다.",
|
"question" => "사용자 응답을 받아 다음 단계로 이어갈 수 있습니다.",
|
||||||
"web" => "웹 요청이 정상적으로 끝났습니다.",
|
"web" => "필요한 웹 정보 조회가 완료되었습니다.",
|
||||||
"command" => "명령 실행이 정상적으로 끝났습니다.",
|
"command" => "명령 실행이 완료되어 결과를 확인할 수 있습니다.",
|
||||||
_ => "요청한 작업이 정상적으로 완료되었습니다.",
|
_ => "요청한 작업이 정상적으로 완료되었습니다.",
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildFailureDescription(string kind)
|
private static string BuildFailureDescription(string kind) => kind switch
|
||||||
{
|
|
||||||
return kind switch
|
|
||||||
{
|
{
|
||||||
"file_edit" => "파일 변경 과정에서 문제가 발생했습니다.",
|
"file_edit" => "파일 변경 과정에서 문제가 발생했습니다.",
|
||||||
"file_write" => "파일 작성 또는 저장 과정에서 문제가 발생했습니다.",
|
"file_write" => "파일 생성 또는 쓰기 과정에서 문제가 발생했습니다.",
|
||||||
"filesystem" => "파일/폴더 접근 중 문제가 발생했습니다.",
|
"filesystem" => "파일이나 폴더를 확인하는 과정에서 문제가 발생했습니다.",
|
||||||
"file" => "파일 처리 중 문제가 발생했습니다.",
|
"file" => "파일 처리 중 문제가 발생했습니다.",
|
||||||
"build_test" => "빌드 또는 테스트 단계에서 실패가 발생했습니다.",
|
"build_test" => "빌드 또는 테스트 단계에서 실패가 발생했습니다.",
|
||||||
"git" => "Git 관련 작업이 실패했습니다.",
|
"git" => "Git 작업 중 오류가 발생했습니다.",
|
||||||
"document" => "문서 생성 또는 변환 작업이 실패했습니다.",
|
"document" => "문서 생성 또는 조합 과정이 실패했습니다.",
|
||||||
"skill" => "스킬 실행 중 문제가 발생했습니다.",
|
"skill" => "스킬 실행 중 문제가 발생했습니다.",
|
||||||
"mcp" => "MCP 도구 호출 중 문제가 발생했습니다.",
|
"mcp" => "MCP 도구 호출 중 문제가 발생했습니다.",
|
||||||
"question" => "사용자 의견 요청 과정에서 문제가 발생했습니다.",
|
"question" => "사용자 질문/응답 처리 단계에서 문제가 발생했습니다.",
|
||||||
"web" => "웹 요청 처리에 실패했습니다.",
|
"web" => "웹 요청 처리에 실패했습니다.",
|
||||||
"command" => "명령 실행 중 오류가 발생했습니다.",
|
"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" => "출력 로그와 후속 수정 필요 여부를 확인해 주세요.",
|
||||||
"file_edit" or "file_write" => "변경 내용을 preview나 diff에서 다시 확인할 수 있습니다.",
|
"git" => "브랜치와 변경 수치를 이어서 확인해 주세요.",
|
||||||
"build_test" => "출력 로그와 후속 수정 필요 여부를 확인하세요.",
|
"document" => "생성된 산출물 경로를 열어 결과를 확인해 주세요.",
|
||||||
"git" => "브랜치 상태나 변경 요약을 이어서 확인하세요.",
|
|
||||||
"document" => "생성된 산출물 경로를 열어 결과를 확인하세요.",
|
|
||||||
"skill" => "같은 스킬을 다른 입력으로 이어서 실행할 수 있습니다.",
|
"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" => "실패 로그와 컴파일/테스트 오류 메시지를 먼저 확인해 주세요.",
|
||||||
"file_edit" or "file_write" => "대상 파일 경로와 권한, diff를 다시 확인하세요.",
|
"git" => "브랜치 상태와 충돌 여부를 다시 확인해 주세요.",
|
||||||
"build_test" => "실패 로그와 컴파일 오류 메시지를 먼저 확인하세요.",
|
"document" => "입력 데이터, 출력 형식, 대상 위치를 다시 확인해 주세요.",
|
||||||
"git" => "현재 브랜치, 잠금 상태, 충돌 여부를 확인하세요.",
|
"skill" => "사용 도구와 스킬 요구사항을 다시 확인해 주세요.",
|
||||||
"document" => "입력 데이터와 출력 형식, 저장 위치를 다시 확인하세요.",
|
"web" => "연결 상태와 요청 URL을 다시 확인해 주세요.",
|
||||||
"skill" => "허용 도구와 런타임 요구사항을 다시 확인하세요.",
|
"mcp" => "MCP 서버 연결 상태와 도구 등록 상태를 다시 확인해 주세요.",
|
||||||
"web" => "연결 상태와 요청 대상 URL을 다시 확인하세요.",
|
_ => "같은 요청을 다시 시도하기 전에 원인 메시지를 먼저 확인해 주세요.",
|
||||||
"mcp" => "MCP 서버 연결 상태와 도구 등록 상태를 다시 확인하세요.",
|
|
||||||
_ => "같은 요청을 재시도하기 전에 원인 메시지를 먼저 확인하세요.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ public sealed class ToolSearchTool : IAgentTool
|
|||||||
|
|
||||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
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))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
return Task.FromResult(ToolResult.Fail("query is required."));
|
return Task.FromResult(ToolResult.Fail("query is required."));
|
||||||
|
|
||||||
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
|
var limit = args.SafeTryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
|
||||||
var includeDescription = args.TryGetProperty("include_description", out var descEl)
|
var includeDescription = args.SafeTryGetProperty("include_description", out var descEl)
|
||||||
&& descEl.ValueKind == JsonValueKind.True;
|
&& descEl.ValueKind == JsonValueKind.True;
|
||||||
|
|
||||||
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user