에이전트 선택적 탐색 구조 개선과 경고 정리 반영
Some checks failed
Release Gate / gate (push) Has been cancelled

- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

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

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

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

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

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
2026-04-09 14:27:59 +09:00
parent 7931566212
commit 33c1db4dae
119 changed files with 4453 additions and 6943 deletions

View File

@@ -1541,3 +1541,10 @@ MIT License
- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다.
- [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다.
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다.
- 업데이트: 2026-04-09 10:36 (KST)
- `claude-code`의 선택적 탐색 흐름을 다시 대조해, Cowork/Code가 질문 범위와 무관한 워크스페이스 전체를 훑는 경향을 줄이기 시작했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 Cowork/Code 시스템 프롬프트를 조정해 `folder_map`을 항상 첫 단계로 요구하지 않고, 좁은 질문에서는 `glob/grep + targeted read`를 우선하도록 바꿨습니다.
- [FolderMapTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/FolderMapTool.cs)는 기본 depth를 2로, `include_files` 기본값을 `false`로 조정해 첫 탐색 폭을 더 보수적으로 만들었습니다.
- [MultiReadTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MultiReadTool.cs)는 한 번에 읽을 수 있는 최대 파일 수를 20개에서 8개로 낮춰 초기 과탐색 토큰 낭비를 줄이도록 했습니다.
- [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)와 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)에 탐색 범위 분류기와 broad-scan corrective hint를 추가해, 좁은 질문에서 반복적인 `folder_map`/대량 `multi_read`가 나오면 관련 파일만 다시 고르도록 교정합니다.
- [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)는 `%APPDATA%\\AxCopilot\\perf``exploration_breadth` 로그를 남겨 `folder_map` 호출 수, 총 읽은 파일 수, broad scan 여부를 실사용 기준으로 확인할 수 있게 했습니다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
# 구조적 리팩토링 개발 계획
> 작성: 2026-04-09
---
## 배경
ChatWindow의 두 가지 구조적 결함이 장시간 사용 시 메모리 누수와 UI 프레임 드롭을 유발합니다.
1. **람다 이벤트 핸들러 누적** — 동적 UI 요소에 붙인 람다 핸들러가 `Children.Clear()` 후에도 GC되지 않음
2. **스트리밍 중 인크리멘탈 렌더 실패** — prefix 키 비교가 스트리밍 상황에서 항상 실패하여 전체 재빌드로 폴백
---
## 과제 A: 람다 이벤트 핸들러 누적 해소
### 현황
26개소 이상에서 동적 UI 요소(Border, TextBlock, Button)에 람다 `+=` 핸들러를 붙이고, 컨테이너 `.Clear()` 시 핸들러를 해제하지 않음. 클로저가 캡처한 변수(UI 요소, 문자열)가 GC 대상에서 제외됨.
### 영향도별 분류
| 빈도 | 파일 | Clear 대상 | 핸들러 수/회 | 추정 누적 |
|------|------|-----------|-------------|----------|
| **높음** | `ConversationListPresentation.cs` | `ConversationPanel.Children` | 대화당 5개 × 50항목 = 250 | 탭 전환/필터마다 250개 |
| **높음** | `TopicPresetPresentation.cs` | `TopicButtonPanel.Children` | 프리셋당 3개 × 15항목 = 45 | 탭 전환마다 45개 |
| **중간** | `PermissionPresentation.cs` | `PermissionItems.Children` | 항목당 4개 × 20항목 = 80 | 권한 팝업 열 때마다 80개 |
| **중간** | `PreviewPresentation.cs` | `PreviewTabPanel.Children` | 탭당 5개 × 10탭 = 50 | 미리보기 탭 변경마다 50개 |
| **중간** | `ComposerQueuePresentation.cs` | `DraftQueuePanel.Children` | 항목당 2개 × 5항목 = 10 | 큐 갱신마다 10개 |
| **중간** | `GitBranchPresentation.cs` | `GitBranchItems.Children` | 항목당 3개 × 20항목 = 60 | 브랜치 팝업 열 때마다 60개 |
| **낮음** | `FileBrowserPresentation.cs` | `FileTreeView.Items` | 항목당 3개 × 100항목 = 300 | 트리 재구축마다 300개 |
### 설계 방침
**이벤트 위임(Event Delegation) 패턴** 적용 — 개별 자식 요소에 핸들러를 붙이지 않고, 부모 컨테이너에 단일 핸들러를 두고 `e.Source`/`e.OriginalSource`로 분기.
### 단계별 구현 계획
#### Phase 1: 고빈도 대상 (추정 공수: 2시간)
**1-1. ConversationListPresentation.cs**
현재:
```csharp
// 대화 항목마다 5개 람다 핸들러
border.MouseEnter += (s, _) => { ... };
border.MouseLeave += (s, _) => { ... };
border.MouseLeftButtonDown += (s, _) => { ... };
border.MouseRightButtonUp += (s, _) => { ... };
border.MouseLeftButtonUp += (s, _) => { ... };
ConversationPanel.Children.Add(border);
```
변경 전략:
```csharp
// ConversationPanel에 단일 핸들러 (생성자에서 1회 등록)
ConversationPanel.MouseEnter += ConversationPanel_MouseEnter;
ConversationPanel.MouseLeave += ConversationPanel_MouseLeave;
ConversationPanel.MouseLeftButtonDown += ConversationPanel_MouseLeftButtonDown;
// ...
private void ConversationPanel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// e.OriginalSource에서 가장 가까운 Border를 찾아 DataContext의 ConversationMeta로 분기
if (FindAncestor<Border>(e.OriginalSource as DependencyObject) is { Tag: string conversationId })
HandleConversationClick(conversationId);
}
```
핵심 변경:
- 각 Border의 `Tag` 속성에 `conversationId` 저장
- `ConversationPanel`에 이벤트 5개를 생성자에서 1회만 등록
- `AddConversationItem()`에서 람다 핸들러 제거
- hover 효과는 `ConversationPanel_MouseMove`에서 `VisualTreeHelper.HitTest`로 처리
**1-2. TopicPresetPresentation.cs**
동일 패턴 적용:
- `TopicButtonPanel``PreviewMouseLeftButtonDown`, `MouseEnter`, `MouseLeave` 3개만 등록
- 각 프리셋 카드의 `Tag`에 프리셋 ID 저장
- `AttachTopicCardHover` 메서드를 제거하고 부모 위임으로 대체
#### Phase 2: 중빈도 대상 (추정 공수: 2시간)
**2-1. PermissionPresentation.cs** — 동일 패턴
**2-2. PreviewPresentation.cs** — 탭 닫기 버튼만 주의 (부모 위임 + `Tag` 분기)
**2-3. ComposerQueuePresentation.cs** — 큐 항목 적음, 간단
**2-4. GitBranchPresentation.cs**`CreateFlatPopupRow()` 내부 핸들러를 부모 위임으로 전환
#### Phase 3: 저빈도 + 공통 유틸 (추정 공수: 1시간)
**3-1. FileBrowserPresentation.cs** — TreeViewItem은 WPF 내부 이벤트 라우팅이 복잡하므로, Clear 전에 명시적 해제 방식 적용:
```csharp
private void DetachFileTreeHandlers(ItemCollection items)
{
foreach (var item in items.OfType<TreeViewItem>())
{
item.Expanded -= FileTreeItem_Expanded;
item.MouseDoubleClick -= FileTreeItem_DoubleClick;
item.MouseRightButtonUp -= FileTreeItem_RightClick;
DetachFileTreeHandlers(item.Items); // 재귀
}
}
// BuildFileTree() 시작 시 DetachFileTreeHandlers(FileTreeView.Items) 호출
```
**3-2. 공통 헬퍼 추가**
```csharp
// ChatWindow.VisualInteractionHelpers.cs에 추가
private static T? FindAncestorWithTag<T>(DependencyObject? source) where T : FrameworkElement
{
while (source != null)
{
if (source is T fe && fe.Tag != null)
return fe;
source = VisualTreeHelper.GetParent(source);
}
return null;
}
```
### 검증 방법
1. 수정 전/후 Visual Studio Memory Profiler로 GC Gen2 객체 수 비교
2. 대화 탭 100회 전환 후 핸들러 참조 수 변화 측정
3. UI 기능 회귀 테스트 (hover 효과, 클릭, 우클릭 메뉴)
---
## 과제 B: 스트리밍 인크리멘탈 렌더 실패 해소
### 현황
`TryApplyIncrementalTranscriptRender`는 이론적으로 새 항목만 추가/교체하지만, 스트리밍 중 3가지 이유로 항상 실패하여 `ApplyFullTranscriptRender`로 폴백합니다:
| 실패 원인 | 위치 | 설명 |
|----------|------|------|
| `hiddenCount` 불일치 | `BuildTranscriptRenderPlan` L39 | 스트리밍 시 `GetActiveTimelineRenderLimit()`가 다른 값 반환 → `_lastRenderedHiddenCount != hiddenCount` |
| prefix 키 불일치 | `TryApplyIncrementalTranscriptRender` L13 | hiddenCount 변화로 visible 범위가 밀려서 첫 번째 키가 달라짐 |
| `_agentLiveContainer` 존재 | `BuildTranscriptRenderPlan` L36 | 라이브 카드가 transcript에 삽입되면 `hasExternalChildren=true``canIncremental=false` |
### 현재 렌더 흐름
```
RenderMessages(preserveViewport: true) — 350ms/2200ms 타이머
→ BuildTranscriptRenderPlan()
→ GetActiveTimelineRenderLimit() → 스트리밍이면 더 작은 값
→ hiddenCount 계산 → 매번 다름 → canIncremental=false
→ TryApplyIncrementalTranscriptRender() → 실패
→ ApplyFullTranscriptRender() → ClearTranscriptElements() + 전체 재구축
```
### 설계 방침
**스트리밍 전용 경량 업데이트 경로** 도입 — 스트리밍 중에는 전체 타임라인을 재구축하지 않고, 마지막 메시지 영역만 갱신.
### 단계별 구현 계획
#### Phase 1: hiddenCount 안정화 (추정 공수: 30분)
**문제**: 스트리밍 중 `GetActiveTimelineRenderLimit()`가 더 작은 값을 반환하여 hiddenCount가 변동.
**수정**: 스트리밍 중에는 `_lastRenderedHiddenCount`를 고정하여 비교 안정화.
```csharp
// BuildTranscriptRenderPlan 수정
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
// 스트리밍 중에는 hiddenCount가 증가만 허용 (줄어들지 않음)
// → 이미 렌더링된 stable 항목을 숨기지 않아 prefix 불일치 방지
if (_isStreaming && hiddenCount < _lastRenderedHiddenCount)
hiddenCount = _lastRenderedHiddenCount;
```
#### Phase 2: _agentLiveContainer 분리 (추정 공수: 1시간)
**문제**: 라이브 진행 카드(`_agentLiveContainer`)가 transcript 패널에 삽입되면 `hasExternalChildren=true` → 인크리멘탈 불가.
**수정**: 라이브 카드를 transcript 패널 밖의 별도 오버레이로 분리.
```xml
<!-- ChatWindow.xaml 수정 -->
<Grid>
<!-- 기존 transcript -->
<ScrollViewer x:Name="TranscriptScroll">
<StackPanel x:Name="TranscriptPanel" />
</ScrollViewer>
<!-- 라이브 진행 카드 — transcript 밖 하단 오버레이 -->
<Border x:Name="AgentLiveOverlay"
VerticalAlignment="Bottom"
Margin="0,0,0,8" />
</Grid>
```
변경 사항:
- `_agentLiveContainer``AgentLiveOverlay.Child`로 설정 (transcript 패널에서 분리)
- `ApplyFullTranscriptRender`에서 `_agentLiveContainer` 삽입 코드 제거
- `hasExternalChildren` 체크 불필요 → `canIncremental` 조건 단순화
- 스크롤 위치 조정 로직은 오버레이 높이를 반영
#### Phase 3: 스트리밍 전용 append-only 경로 (추정 공수: 1.5시간)
**목표**: 스트리밍 중에는 `TryApplyIncrementalTranscriptRender`의 prefix 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용.
```csharp
// TranscriptRenderExecution.cs에 추가
private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan)
{
// 스트리밍 전용: prefix 비교 대신 stable 키 집합의 부분집합 관계만 확인
if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0)
return false;
// stable 키가 변하지 않았는지 확인 (순서 무관, 존재 여부만)
var lastStableKeys = new HashSet<string>(
_lastRenderedTimelineKeys.Where(k => !k.StartsWith("_live_")),
StringComparer.Ordinal);
var newStableKeys = renderPlan.NewKeys
.Where(k => !k.StartsWith("_live_"))
.ToList();
// 새 stable 키가 기존의 superset이면 append 가능
if (!lastStableKeys.IsSubsetOf(newStableKeys))
return false;
// 1. 기존 live 항목 제거
for (var i = 0; i < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; i++)
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
// 2. 새로 추가된 stable 항목 렌더
foreach (var item in renderPlan.VisibleTimeline)
{
if (!lastStableKeys.Contains(item.Key))
item.Render();
}
// 3. 새 live 항목 렌더
foreach (var item in renderPlan.VisibleTimeline.Where(
t => t.Key.StartsWith("_live_")))
item.Render();
_lastRenderedTimelineKeys = renderPlan.NewKeys;
_lastRenderedHiddenCount = renderPlan.HiddenCount;
return true;
}
```
호출 순서 수정 (TranscriptRendering.cs):
```csharp
if (!TryApplyStreamingAppendRender(renderPlan) // 스트리밍 전용 빠른 경로
&& !TryApplyIncrementalTranscriptRender(renderPlan) // 일반 인크리멘탈
)
ApplyFullTranscriptRender(renderPlan); // 최후 수단
```
#### Phase 4: 비가시 상태 렌더 차단 (추정 공수: 15분)
```csharp
// RenderMessages 최상단에 추가
if (WindowState == WindowState.Minimized || !IsVisible)
return;
```
### 검증 방법
1. 스트리밍 중 `ApplyFullTranscriptRender` 호출 횟수를 성능 로그로 측정 (수정 전 vs 후)
2. 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit)
3. 50개 이상 메시지가 있는 대화에서 스트리밍 응답 시 스크롤 버벅임 확인
---
## 실행 계획 요약
| 단계 | 과제 | 작업 | 공수 | 우선순위 |
|------|------|------|------|---------|
| A-1 | 핸들러 | ConversationList + TopicPreset 이벤트 위임 | 2h | P1 | ✅ 완료 (2026-04-09) |
| B-1 | 렌더 | hiddenCount 안정화 | 30m | P1 | ✅ 완료 (2026-04-09) |
| B-4 | 렌더 | 비가시 상태 렌더 차단 | 15m | P1 | ✅ 완료 (2026-04-09) |
| B-2 | 렌더 | _agentLiveContainer 인크리멘탈 허용 | 1h | P2 | ✅ 완료 (2026-04-09) |
| B-3 | 렌더 | 스트리밍 append-only 경로 | 1.5h | P2 | ✅ 완료 (2026-04-09) |
| A-2 | 핸들러 | Permission + Preview + GitBranch 위임 | 2h | P2 | ✅ 완료 (2026-04-09) |
| A-3 | 핸들러 | FileBrowser 명시적 해제 + 공통 헬퍼 | 1h | P3 | ✅ 완료 (2026-04-09) |
**총 예상 공수**: ~8시간
### 위험 요소
| 위험 | 영향 | 대응 |
|------|------|------|
| 이벤트 위임 시 hover 효과 깨짐 | UI 품질 저하 | 기존 CSS-style 직접 설정 대신 VisualStateManager 활용 |
| 인크리멘탈 렌더 중 DOM 불일치 | 렌더링 오류 | try/catch + 전체 재빌드 폴백 유지 |
| 라이브 카드 오버레이 분리 시 스크롤 동기화 | 라이브 카드가 transcript와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |

View File

@@ -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;

View File

@@ -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++;
}
}
}

View File

@@ -27,6 +27,13 @@ public partial class AgentLoopService
private readonly ToolRegistry _tools; private readonly ToolRegistry _tools;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator; 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>
@@ -168,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",
@@ -229,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);
@@ -530,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, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
@@ -814,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));
@@ -857,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));
@@ -1058,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 });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행) // 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
@@ -1136,15 +1162,16 @@ public partial class AgentLoopService
if (toolCalls.Count == 0) continue; if (toolCalls.Count == 0) continue;
} }
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
var activeToolNames = cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var call in toolCalls) foreach (var call in toolCalls)
{ {
if (ct.IsCancellationRequested) break; if (ct.IsCancellationRequested) break;
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames); var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var globallyRegisteredTool = _tools.Get(resolvedToolName); var globallyRegisteredTool = _tools.Get(resolvedToolName);
if (globallyRegisteredTool != null && if (globallyRegisteredTool != null &&
@@ -1305,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;
@@ -1412,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, "", "작업이 중단되었습니다.");
@@ -1613,7 +1662,8 @@ public partial class AgentLoopService
llm, llm,
executionPolicy, executionPolicy,
context, context,
ct); ct,
documentPlanCalled);
if (terminalCompleted) if (terminalCompleted)
{ {
if (consumedExtraIteration) if (consumedExtraIteration)
@@ -1664,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);
} }
@@ -1699,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
@@ -2985,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);
} }
} }
@@ -4092,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);
@@ -4253,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;
} }
@@ -4297,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;
@@ -4324,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] + "...";
@@ -4352,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",
@@ -4364,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",
@@ -4636,10 +4700,25 @@ public partial class AgentLoopService
}; };
if (Dispatcher != null) if (Dispatcher != null)
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); }); {
Dispatcher(() =>
{
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
EventOccurred?.Invoke(evt);
});
}
else else
{ {
Events.Add(evt); // Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
lock (Events)
{
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
}
EventOccurred?.Invoke(evt); EventOccurred?.Invoke(evt);
} }
} }
@@ -4655,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}";
} }
} }
@@ -4669,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;
@@ -4681,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
@@ -4706,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}";
} }
} }
@@ -4724,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}";
} }
} }
@@ -4739,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; }

View File

@@ -103,11 +103,17 @@ public partial class AgentLoopService
Models.LlmSettings llm, Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context, AgentContext context,
CancellationToken ct) CancellationToken ct,
bool documentPlanWasCalled = false)
{ {
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1) if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false); return (false, false);
// document_plan 없이 바로 문서 도구가 호출된 경우 — 아직 LLM이 추가 반복을 할 수 있음.
// 한 번에 생성된 문서는 내용이 부실할 수 있으므로 조기 종료하지 않고 LLM에 판단을 맡긴다.
if (!_docFallbackAttempted && !documentPlanWasCalled)
return (false, false);
var verificationEnabled = executionPolicy.EnablePostToolVerification var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification( var shouldVerify = ShouldRunPostToolVerification(

View File

@@ -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}");

View File

@@ -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);

View File

@@ -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))

View File

@@ -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++;

View File

@@ -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} 범위를 사용하세요.");

View File

@@ -38,7 +38,7 @@ public class ClipboardTool : 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() ?? "";
try try
{ {
@@ -79,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)");
} }

View File

@@ -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("작업 폴더가 설정되어 있지 않습니다.");

View File

@@ -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가 필요합니다.");

View File

@@ -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))

View File

@@ -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."));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}'");

View File

@@ -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)

View File

@@ -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);

View File

@@ -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
{ {

View File

@@ -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));
} }

View File

@@ -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이 비어있습니다."));

View File

@@ -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;
} }
} }

View File

@@ -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}\"");
} }

View File

@@ -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

View File

@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
/// <summary>DOCX 파일을 HTML로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class DocxToHtmlConverter
{
public static string Convert(string docxPath)
{
try
{
using var doc = WordprocessingDocument.Open(docxPath, false);
var body = doc.MainDocumentPart?.Document?.Body;
if (body == null)
return WrapHtml("<p style='color:#888'>문서 내용이 없습니다.</p>");
var sb = new StringBuilder();
foreach (var element in body.Elements())
{
if (element is Paragraph para)
sb.AppendLine(RenderParagraph(para, doc));
else if (element is Table table)
sb.AppendLine(RenderTable(table, doc));
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>DOCX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderParagraph(Paragraph para, WordprocessingDocument doc)
{
var style = para.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
var text = RenderRuns(para, doc);
if (string.IsNullOrWhiteSpace(text))
return "<p>&nbsp;</p>";
// 제목 스타일 감지
if (!string.IsNullOrEmpty(style))
{
var lower = style.ToLowerInvariant();
if (lower.Contains("heading1") || lower == "1") return $"<h1>{text}</h1>";
if (lower.Contains("heading2") || lower == "2") return $"<h2>{text}</h2>";
if (lower.Contains("heading3") || lower == "3") return $"<h3>{text}</h3>";
if (lower.Contains("heading4") || lower == "4") return $"<h4>{text}</h4>";
if (lower.Contains("heading5") || lower == "5") return $"<h5>{text}</h5>";
if (lower.Contains("heading6") || lower == "6") return $"<h6>{text}</h6>";
if (lower.Contains("title")) return $"<h1 class='title'>{text}</h1>";
if (lower.Contains("subtitle")) return $"<p class='subtitle'>{text}</p>";
if (lower.Contains("listparagraph") || lower.Contains("list"))
return $"<li>{text}</li>";
}
// 글머리 기호 감지
var numProps = para.ParagraphProperties?.NumberingProperties;
if (numProps != null)
return $"<li>{text}</li>";
// 정렬
var align = para.ParagraphProperties?.Justification?.Val?.Value;
var alignStyle = "";
if (align == JustificationValues.Center) alignStyle = " style='text-align:center'";
else if (align == JustificationValues.Right) alignStyle = " style='text-align:right'";
return $"<p{alignStyle}>{text}</p>";
}
private static string RenderRuns(Paragraph para, WordprocessingDocument doc)
{
var sb = new StringBuilder();
foreach (var run in para.Elements<Run>())
{
var text = string.Join("", run.Elements<Text>().Select(t => Escape(t.Text)));
if (string.IsNullOrEmpty(text))
{
// 이미지 체크
var drawing = run.Elements<Drawing>().FirstOrDefault();
if (drawing != null)
{
var img = ExtractImage(drawing, doc);
if (img != null)
sb.Append(img);
}
continue;
}
var rp = run.RunProperties;
if (rp?.Bold is { } bold && (bold.Val is null || bold.Val.Value))
text = $"<strong>{text}</strong>";
if (rp?.Italic is { } italic && (italic.Val is null || italic.Val.Value))
text = $"<em>{text}</em>";
if (rp?.Underline?.Val is { } underlineVal && underlineVal != UnderlineValues.None)
text = $"<u>{text}</u>";
if (rp?.Strike is { } strike && (strike.Val is null || strike.Val.Value))
text = $"<del>{text}</del>";
// 글자 색상
var color = rp?.Color?.Val?.Value;
if (!string.IsNullOrEmpty(color) && color != "000000" && color != "auto")
text = $"<span style='color:#{color}'>{text}</span>";
sb.Append(text);
}
return sb.ToString();
}
private static string? ExtractImage(Drawing drawing, WordprocessingDocument doc)
{
try
{
var blip = drawing.Descendants<DocumentFormat.OpenXml.Drawing.Blip>().FirstOrDefault();
if (blip?.Embed?.Value == null) return null;
var part = doc.MainDocumentPart?.GetPartById(blip.Embed.Value);
if (part == null) return null;
using var stream = part.GetStream();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var base64 = System.Convert.ToBase64String(ms.ToArray());
var mime = part.ContentType ?? "image/png";
return $"<img src='data:{mime};base64,{base64}' style='max-width:100%;height:auto;margin:8px 0' />";
}
catch
{
return null;
}
}
private static string RenderTable(Table table, WordprocessingDocument doc)
{
var sb = new StringBuilder();
sb.AppendLine("<table>");
foreach (var row in table.Elements<TableRow>())
{
sb.Append("<tr>");
foreach (var cell in row.Elements<TableCell>())
{
var cellContent = new StringBuilder();
foreach (var para in cell.Elements<Paragraph>())
cellContent.Append(RenderRuns(para, doc));
// 셀 병합
var gridSpan = cell.TableCellProperties?.GridSpan?.Val?.Value;
var spanAttr = gridSpan > 1 ? $" colspan='{gridSpan}'" : "";
sb.Append($"<td{spanAttr}>{cellContent}</td>");
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table>");
return sb.ToString();
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }}
h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }}
.subtitle {{ font-size: 14px; color: #666; text-align: center; margin-bottom: 20px; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 20px 0 8px; color: #222;
border-bottom: 2px solid #e5e7eb; padding-bottom: 4px; }}
h3 {{ font-size: 15px; font-weight: 600; margin: 16px 0 6px; color: #333; }}
h4,h5,h6 {{ font-size: 13.5px; font-weight: 600; margin: 12px 0 4px; }}
p {{ margin: 6px 0; }}
li {{ margin: 3px 0 3px 24px; }}
table {{ width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px; }}
th, td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
th {{ background: #f3f4f6; font-weight: 600; }}
tr:nth-child(even) {{ background: #f9fafb; }}
img {{ border-radius: 4px; }}
strong {{ font-weight: 600; }}
</style>
</head>
<body>
{body}
</body>
</html>";
}
}

View File

@@ -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
{ {

View File

@@ -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))

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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))

View File

@@ -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);

View File

@@ -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);

View File

@@ -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))

View File

@@ -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);

View File

@@ -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)

View File

@@ -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);

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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
{ {

View File

@@ -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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;"); text = text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
@@ -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,17 +699,18 @@ 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(',');
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\""; if (colors.Length >= 2)
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
} }
var sb = new StringBuilder(); var sb = new StringBuilder();

View File

@@ -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());
} }
// 본문 설정 // 본문 설정

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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));

View File

@@ -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가 필요합니다.");

View File

@@ -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}");

View File

@@ -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("수식이 비어 있습니다."));

View File

@@ -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()

View File

@@ -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()

View File

@@ -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,

View File

@@ -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 중 선택하세요.");

View File

@@ -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);
} }

View File

@@ -45,9 +45,9 @@ public class NotifyTool : 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 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
{ {
@@ -140,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);
} }
} }
} }

View File

@@ -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("경로가 비어 있습니다."));

View File

@@ -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("저장된 플레이북이 없습니다.");

View File

@@ -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)

View File

@@ -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>";
}
}

View File

@@ -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("명령이 비어 있습니다.");

View File

@@ -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("작업 폴더가 설정되어 있지 않습니다.");

View File

@@ -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
{ {

View File

@@ -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
{ {

View File

@@ -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가 비어 있습니다.");

View File

@@ -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();

View File

@@ -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

View File

@@ -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>

View File

@@ -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";

View File

@@ -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."));

View File

@@ -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();

View File

@@ -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)

View File

@@ -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)

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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."));

View File

@@ -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",

View File

@@ -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%);

View File

@@ -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이 필요합니다.");

View File

@@ -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;

View File

@@ -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();

View File

@@ -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)

View File

@@ -38,15 +38,15 @@ public class UserAskTool : 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 question = args.GetProperty("question").GetString() ?? ""; var question = args.GetProperty("question").SafeGetString() ?? "";
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : ""; var defaultVal = args.SafeTryGetProperty("default_value", out var dv) ? dv.SafeGetString() ?? "" : "";
var options = new List<string>(); var options = new List<string>();
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array) if (args.SafeTryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
{ {
foreach (var o in opts.EnumerateArray()) foreach (var o in opts.EnumerateArray())
{ {
var s = o.GetString(); var s = o.SafeGetString();
if (!string.IsNullOrEmpty(s)) options.Add(s); if (!string.IsNullOrEmpty(s)) options.Add(s);
} }
} }

View File

@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace AxCopilot.Services.Agent;
/// <summary>XLSX 파일을 HTML 테이블로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class XlsxToHtmlConverter
{
private const int MaxRows = 500;
private const int MaxCols = 50;
public static string Convert(string xlsxPath)
{
try
{
using var doc = SpreadsheetDocument.Open(xlsxPath, false);
var workbookPart = doc.WorkbookPart;
if (workbookPart == null)
return WrapHtml("<p style='color:#888'>스프레드시트 내용이 없습니다.</p>");
var sheets = workbookPart.Workbook?.Sheets?.Elements<Sheet>().ToList();
if (sheets == null || sheets.Count == 0)
return WrapHtml("<p style='color:#888'>시트가 없습니다.</p>");
var sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable;
var sb = new StringBuilder();
// 시트 탭 UI
if (sheets.Count > 1)
{
sb.AppendLine("<div class='sheet-tabs'>");
for (int i = 0; i < sheets.Count; i++)
{
var name = sheets[i].Name?.Value ?? $"Sheet{i + 1}";
var activeClass = i == 0 ? " active" : "";
sb.AppendLine($"<button class='sheet-tab{activeClass}' onclick='showSheet({i})'>{Escape(name)}</button>");
}
sb.AppendLine("</div>");
}
// 시트별 테이블
for (int si = 0; si < sheets.Count; si++)
{
var sheet = sheets[si];
var partId = sheet.Id?.Value;
if (string.IsNullOrEmpty(partId)) continue;
WorksheetPart sheetPart;
try { sheetPart = (WorksheetPart)workbookPart.GetPartById(partId); }
catch { continue; }
var display = si == 0 ? "block" : "none";
sb.AppendLine($"<div class='sheet-content' id='sheet-{si}' style='display:{display}'>");
sb.AppendLine(RenderSheet(sheetPart, sharedStrings));
sb.AppendLine("</div>");
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>XLSX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderSheet(WorksheetPart sheetPart, SharedStringTable? sharedStrings)
{
var sheetData = sheetPart.Worksheet?.GetFirstChild<SheetData>();
if (sheetData == null)
return "<p style='color:#888'>시트 데이터가 없습니다.</p>";
var rows = sheetData.Elements<Row>().Take(MaxRows + 1).ToList();
if (rows.Count == 0)
return "<p style='color:#888'>데이터가 없습니다.</p>";
// 병합 셀 정보
var mergeCells = sheetPart.Worksheet?.GetFirstChild<MergeCells>()
?.Elements<MergeCell>().ToList() ?? new List<MergeCell>();
var mergeMap = BuildMergeMap(mergeCells);
var sb = new StringBuilder();
sb.AppendLine("<div style='overflow-x:auto'><table>");
var rowIndex = 0;
foreach (var row in rows.Take(MaxRows))
{
rowIndex++;
sb.Append(rowIndex == 1 ? "<tr class='header'>" : "<tr>");
var cells = row.Elements<Cell>().Take(MaxCols).ToList();
foreach (var cell in cells)
{
var cellRef = cell.CellReference?.Value ?? "";
if (mergeMap.TryGetValue(cellRef, out var merge))
{
if (merge.IsHidden) continue; // 병합된 셀 건너뛰기
var spanAttrs = "";
if (merge.ColSpan > 1) spanAttrs += $" colspan='{merge.ColSpan}'";
if (merge.RowSpan > 1) spanAttrs += $" rowspan='{merge.RowSpan}'";
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}{spanAttrs}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
else
{
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table></div>");
if (rows.Count > MaxRows)
sb.AppendLine($"<p class='truncated'>... {MaxRows}행까지 표시 (전체 데이터는 Excel에서 확인)</p>");
return sb.ToString();
}
private static string GetCellValue(Cell cell, SharedStringTable? sharedStrings)
{
var value = cell.CellValue?.Text ?? cell.InnerText ?? "";
if (string.IsNullOrEmpty(value)) return "";
if (cell.DataType?.Value == CellValues.SharedString && sharedStrings != null)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count())
return sharedStrings.ElementAt(idx).InnerText;
}
return value;
}
private sealed record MergeCellInfo(int ColSpan, int RowSpan, bool IsHidden);
private static Dictionary<string, MergeCellInfo> BuildMergeMap(List<MergeCell> mergeCells)
{
var map = new Dictionary<string, MergeCellInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var mc in mergeCells)
{
var range = mc.Reference?.Value;
if (string.IsNullOrEmpty(range)) continue;
var parts = range.Split(':');
if (parts.Length != 2) continue;
var (startCol, startRow) = ParseCellRef(parts[0]);
var (endCol, endRow) = ParseCellRef(parts[1]);
var colSpan = endCol - startCol + 1;
var rowSpan = endRow - startRow + 1;
// 시작 셀: span 정보 포함
map[parts[0]] = new MergeCellInfo(colSpan, rowSpan, false);
// 나머지 셀: 숨김
for (int r = startRow; r <= endRow; r++)
for (int c = startCol; c <= endCol; c++)
{
var cellRef = ColumnIndexToLetter(c) + r;
if (!string.Equals(cellRef, parts[0], StringComparison.OrdinalIgnoreCase))
map[cellRef] = new MergeCellInfo(1, 1, true);
}
}
return map;
}
private static (int Col, int Row) ParseCellRef(string cellRef)
{
int col = 0, i = 0;
while (i < cellRef.Length && char.IsLetter(cellRef[i]))
{
col = col * 26 + (char.ToUpper(cellRef[i]) - 'A' + 1);
i++;
}
int.TryParse(cellRef[i..], out var row);
return (col, row);
}
private static string ColumnIndexToLetter(int col)
{
var sb = new StringBuilder();
while (col > 0)
{
col--;
sb.Insert(0, (char)('A' + col % 26));
col /= 26;
}
return sb.ToString();
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.5; padding: 16px; font-size: 12.5px; }}
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;
font-size: 11.5px; border-radius: 6px 6px 0 0; color: #6b7280; }}
.sheet-tab.active {{ background: #fff; color: #2563eb; font-weight: 600;
border-bottom: 2px solid #2563eb; margin-bottom: -2px; }}
.sheet-tab:hover {{ background: #e5e7eb; }}
table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
th, td {{ border: 1px solid #d1d5db; padding: 5px 8px; text-align: left; white-space: nowrap; }}
th, .header td {{ background: #f3f4f6; font-weight: 600; position: sticky; top: 0; }}
tr:nth-child(even) {{ background: #f9fafb; }}
.truncated {{ color: #9ca3af; font-size: 11px; margin-top: 8px; font-style: italic; }}
</style>
<script>
function showSheet(idx) {{
document.querySelectorAll('.sheet-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.sheet-tab').forEach(el => el.classList.remove('active'));
document.getElementById('sheet-' + idx).style.display = 'block';
document.querySelectorAll('.sheet-tab')[idx].classList.add('active');
}}
</script>
</head>
<body>
{body}
</body>
</html>";
}
}

View File

@@ -49,10 +49,10 @@ public class XmlTool : 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 xmlStr = args.TryGetProperty("xml", out var x) ? x.GetString() ?? "" : ""; var xmlStr = args.SafeTryGetProperty("xml", out var x) ? x.SafeGetString() ?? "" : "";
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : ""; var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
var expression = args.TryGetProperty("expression", out var ex) ? ex.GetString() ?? "" : ""; var expression = args.SafeTryGetProperty("expression", out var ex) ? ex.SafeGetString() ?? "" : "";
try try
{ {

View File

@@ -49,8 +49,8 @@ public class ZipTool : 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 zipPath = args.GetProperty("zip_path").GetString() ?? ""; var zipPath = args.GetProperty("zip_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(zipPath)) if (!Path.IsPathRooted(zipPath))
zipPath = Path.Combine(context.WorkFolder, zipPath); zipPath = Path.Combine(context.WorkFolder, zipPath);
@@ -73,10 +73,10 @@ public class ZipTool : IAgentTool
private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context) private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context)
{ {
if (!args.TryGetProperty("source_path", out var sp)) if (!args.SafeTryGetProperty("source_path", out var sp))
return ToolResult.Fail("'source_path' is required for compress action"); return ToolResult.Fail("'source_path' is required for compress action");
var sourcePath = sp.GetString() ?? ""; var sourcePath = sp.SafeGetString() ?? "";
if (!Path.IsPathRooted(sourcePath)) if (!Path.IsPathRooted(sourcePath))
sourcePath = Path.Combine(context.WorkFolder, sourcePath); sourcePath = Path.Combine(context.WorkFolder, sourcePath);
@@ -107,8 +107,8 @@ public class ZipTool : IAgentTool
if (!File.Exists(zipPath)) if (!File.Exists(zipPath))
return ToolResult.Fail($"Zip file not found: {zipPath}"); return ToolResult.Fail($"Zip file not found: {zipPath}");
var destPath = args.TryGetProperty("dest_path", out var dp) var destPath = args.SafeTryGetProperty("dest_path", out var dp)
? dp.GetString() ?? "" : ""; ? dp.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(destPath)) if (string.IsNullOrEmpty(destPath))
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder, destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
Path.GetFileNameWithoutExtension(zipPath)); Path.GetFileNameWithoutExtension(zipPath));

View File

@@ -57,6 +57,14 @@ public static class AgentPerformanceLogService
} }
} }
public static void LogExplorationBreadth(
string conversationId,
string tab,
object detail)
{
LogMetric("agent_loop", "exploration_breadth", conversationId, tab, 0, detail);
}
public static string GetPerformanceFolder() => PerfDir; public static string GetPerformanceFolder() => PerfDir;
} }

View File

@@ -255,6 +255,25 @@ public class ChatStorageService
} }
} }
/// <summary>지정한 탭에 속한 대화만 삭제합니다.</summary>
public int DeleteAllByTab(string tab)
{
if (string.IsNullOrEmpty(tab)) return DeleteAll();
var metas = LoadAllMeta();
var targets = metas
.Where(m => string.Equals(m.Tab, tab, StringComparison.OrdinalIgnoreCase))
.Select(m => m.Id)
.ToList();
int count = 0;
foreach (var id in targets)
{
try { Delete(id); count++; }
catch { /* 개별 삭제 실패 무시 */ }
}
return count;
}
/// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary> /// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary>
public int PurgeExpired(int retentionDays) public int PurgeExpired(int retentionDays)
{ {

View File

@@ -108,6 +108,25 @@ public sealed class DraftQueueProcessorService
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
=> ClearByState(session, tab, "failed", storage); => ClearByState(session, tab, "failed", storage);
/// <summary>대기 중인 항목을 모두 제거합니다 (중지 시 사용).</summary>
public int ClearQueued(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
=> ClearByState(session, tab, "queued", storage);
/// <summary>실행 중인 항목을 실패로 전환합니다 (중지 시 사용).</summary>
public int CancelRunning(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
{
if (session == null) return 0;
int count = 0;
foreach (var item in session.GetDraftQueueItems(tab)
.Where(x => string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase))
.ToList())
{
if (session.RemoveDraft(tab, item.Id, storage))
count++;
}
return count;
}
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage) private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage)
{ {
if (session == null) if (session == null)

View File

@@ -162,22 +162,22 @@ public partial class LlmService
var root = doc.RootElement; var root = doc.RootElement;
// 토큰 사용량 // 토큰 사용량
if (root.TryGetProperty("usage", out var usage)) if (root.SafeTryGetProperty("usage", out var usage))
TryParseSigmoidUsageFromElement(usage); TryParseSigmoidUsageFromElement(usage);
// 컨텐츠 블록 파싱 // 컨텐츠 블록 파싱
var blocks = new List<ContentBlock>(); var blocks = new List<ContentBlock>();
if (root.TryGetProperty("content", out var content)) if (root.SafeTryGetProperty("content", out var content))
{ {
foreach (var block in content.EnumerateArray()) foreach (var block in content.EnumerateArray())
{ {
var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : ""; var type = block.SafeTryGetProperty("type", out var tp) ? tp.SafeGetString() : "";
if (type == "text") if (type == "text")
{ {
blocks.Add(new ContentBlock blocks.Add(new ContentBlock
{ {
Type = "text", Type = "text",
Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : "" Text = block.SafeTryGetProperty("text", out var txt) ? txt.SafeGetString() ?? "" : ""
}); });
} }
else if (type == "tool_use") else if (type == "tool_use")
@@ -185,9 +185,9 @@ public partial class LlmService
blocks.Add(new ContentBlock blocks.Add(new ContentBlock
{ {
Type = "tool_use", Type = "tool_use",
ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", ToolName = block.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", ToolId = block.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null ToolInput = block.SafeTryGetProperty("input", out var inp) ? inp.Clone() : null
}); });
} }
} }
@@ -220,8 +220,8 @@ public partial class LlmService
new new
{ {
type = "tool_result", type = "tool_result",
tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "", tool_use_id = root.SafeTryGetProperty("tool_use_id", out var tuid) ? tuid.SafeGetString() : "",
content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : "" content = root.SafeTryGetProperty("content", out var tcont) ? tcont.SafeGetString() : ""
} }
} }
}); });
@@ -236,20 +236,20 @@ public partial class LlmService
try try
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception(); if (!doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
var contentList = new List<object>(); var contentList = new List<object>();
foreach (var b in blocksArr.EnumerateArray()) foreach (var b in blocksArr.EnumerateArray())
{ {
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
if (bType == "text") if (bType == "text")
contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); contentList.Add(new { type = "text", text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
else if (bType == "tool_use") else if (bType == "tool_use")
contentList.Add(new contentList.Add(new
{ {
type = "tool_use", type = "tool_use",
id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "", id = b.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } input = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
}); });
} }
msgs.Add(new { role = "assistant", content = contentList }); msgs.Add(new { role = "assistant", content = contentList });
@@ -347,26 +347,26 @@ public partial class LlmService
TryParseGeminiUsage(root); TryParseGeminiUsage(root);
var blocks = new List<ContentBlock>(); var blocks = new List<ContentBlock>();
if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0) if (root.SafeTryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
{ {
var firstCandidate = candidates[0]; var firstCandidate = candidates[0];
if (firstCandidate.TryGetProperty("content", out var contentObj) && if (firstCandidate.SafeTryGetProperty("content", out var contentObj) &&
contentObj.TryGetProperty("parts", out var parts)) contentObj.SafeTryGetProperty("parts", out var parts))
{ {
foreach (var part in parts.EnumerateArray()) foreach (var part in parts.EnumerateArray())
{ {
if (part.TryGetProperty("text", out var text)) if (part.SafeTryGetProperty("text", out var text))
{ {
blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" }); blocks.Add(new ContentBlock { Type = "text", Text = text.SafeGetString() ?? "" });
} }
else if (part.TryGetProperty("functionCall", out var fc)) else if (part.SafeTryGetProperty("functionCall", out var fc))
{ {
blocks.Add(new ContentBlock blocks.Add(new ContentBlock
{ {
Type = "tool_use", Type = "tool_use",
ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "", ToolName = fc.SafeTryGetProperty("name", out var fcName) ? fcName.SafeGetString() ?? "" : "",
ToolId = Guid.NewGuid().ToString("N")[..12], ToolId = Guid.NewGuid().ToString("N")[..12],
ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null ToolInput = fc.SafeTryGetProperty("args", out var a) ? a.Clone() : null
}); });
} }
} }
@@ -391,8 +391,8 @@ public partial class LlmService
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement; var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : ""; var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "" : "";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : ""; var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
contents.Add(new contents.Add(new
{ {
role = "function", role = "function",
@@ -419,21 +419,21 @@ public partial class LlmService
try try
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr))
{ {
var parts = new List<object>(); var parts = new List<object>();
foreach (var b in blocksArr.EnumerateArray()) foreach (var b in blocksArr.EnumerateArray())
{ {
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : ""; var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
if (bType == "text") if (bType == "text")
parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" }); parts.Add(new { text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
else if (bType == "tool_use") else if (bType == "tool_use")
parts.Add(new parts.Add(new
{ {
functionCall = new functionCall = new
{ {
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "", name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } args = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
} }
}); });
} }
@@ -639,13 +639,13 @@ public partial class LlmService
json = json[braceStart..(braceEnd + 1)]; json = json[braceStart..(braceEnd + 1)];
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; var name = root.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) return null; if (string.IsNullOrEmpty(name)) return null;
JsonElement? args = null; JsonElement? args = null;
if (root.TryGetProperty("arguments", out var a)) if (root.SafeTryGetProperty("arguments", out var a))
args = a.Clone(); args = a.Clone();
else if (root.TryGetProperty("parameters", out var p)) else if (root.SafeTryGetProperty("parameters", out var p))
args = p.Clone(); args = p.Clone();
return new ContentBlock return new ContentBlock
@@ -702,8 +702,8 @@ public partial class LlmService
msgs.Add(new msgs.Add(new
{ {
role = "tool", role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(), tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").GetString(), content = root.GetProperty("content").SafeGetString(),
}); });
continue; continue;
} }
@@ -721,19 +721,19 @@ public partial class LlmService
var toolCallsList = new List<object>(); var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray()) foreach (var b in blocksArr.EnumerateArray())
{ {
var bType = b.GetProperty("type").GetString(); var bType = b.GetProperty("type").SafeGetString();
if (bType == "text") if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? ""; textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use") else if (bType == "tool_use")
{ {
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}"; var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new toolCallsList.Add(new
{ {
id = b.GetProperty("id").GetString() ?? "", id = b.GetProperty("id").SafeGetString() ?? "",
type = "function", type = "function",
function = new function = new
{ {
name = b.GetProperty("name").GetString() ?? "", name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson, arguments = argsJson,
} }
}); });
@@ -817,6 +817,8 @@ public partial class LlmService
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch, ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
}; };
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제 // tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응) // 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall) if (forceToolCall)
@@ -877,8 +879,8 @@ public partial class LlmService
msgs.Add(new msgs.Add(new
{ {
role = "tool", role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(), tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").GetString(), content = root.GetProperty("content").SafeGetString(),
}); });
continue; continue;
} }
@@ -896,19 +898,19 @@ public partial class LlmService
var toolCallsList = new List<object>(); var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray()) foreach (var b in blocksArr.EnumerateArray())
{ {
var bType = b.GetProperty("type").GetString(); var bType = b.GetProperty("type").SafeGetString();
if (bType == "text") if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? ""; textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use") else if (bType == "tool_use")
{ {
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}"; var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new toolCallsList.Add(new
{ {
id = b.GetProperty("id").GetString() ?? "", id = b.GetProperty("id").SafeGetString() ?? "",
type = "function", type = "function",
function = new function = new
{ {
name = b.GetProperty("name").GetString() ?? "", name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson, arguments = argsJson,
} }
}); });
@@ -1197,11 +1199,11 @@ public partial class LlmService
TryParseOpenAiUsage(root); TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi && if (usesIbmDeploymentApi &&
root.TryGetProperty("status", out var statusEl) && root.SafeTryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase)) string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{ {
var detail = root.TryGetProperty("message", out var msgEl) var detail = root.SafeTryGetProperty("message", out var msgEl)
? msgEl.GetString() ? msgEl.SafeGetString()
: "IBM vLLM 도구 호출 응답 오류"; : "IBM vLLM 도구 호출 응답 오류";
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류"); throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
} }
@@ -1223,15 +1225,15 @@ public partial class LlmService
} }
if (usesIbmDeploymentApi && if (usesIbmDeploymentApi &&
root.TryGetProperty("results", out var resultsEl) && root.SafeTryGetProperty("results", out var resultsEl) &&
resultsEl.ValueKind == JsonValueKind.Array && resultsEl.ValueKind == JsonValueKind.Array &&
resultsEl.GetArrayLength() > 0) resultsEl.GetArrayLength() > 0)
{ {
var first = resultsEl[0]; var first = resultsEl[0];
var generatedText = first.TryGetProperty("generated_text", out var generatedTextEl) var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.GetString() ? generatedTextEl.SafeGetString()
: first.TryGetProperty("output_text", out var outputTextEl) : first.SafeTryGetProperty("output_text", out var outputTextEl)
? outputTextEl.GetString() ? outputTextEl.SafeGetString()
: null; : null;
if (!string.IsNullOrEmpty(generatedText)) if (!string.IsNullOrEmpty(generatedText))
{ {
@@ -1251,18 +1253,18 @@ public partial class LlmService
} }
} }
if (root.TryGetProperty("choices", out var choicesEl) && if (root.SafeTryGetProperty("choices", out var choicesEl) &&
choicesEl.ValueKind == JsonValueKind.Array && choicesEl.ValueKind == JsonValueKind.Array &&
choicesEl.GetArrayLength() > 0) choicesEl.GetArrayLength() > 0)
{ {
var firstChoice = choicesEl[0]; var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl)) if (firstChoice.SafeTryGetProperty("delta", out var deltaEl))
{ {
var emittedContent = false; var emittedContent = false;
if (deltaEl.TryGetProperty("content", out var contentEl) && if (deltaEl.SafeTryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String) contentEl.ValueKind == JsonValueKind.String)
{ {
var chunk = contentEl.GetString(); var chunk = contentEl.SafeGetString();
if (!string.IsNullOrEmpty(chunk)) if (!string.IsNullOrEmpty(chunk))
{ {
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk); yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
@@ -1272,20 +1274,20 @@ public partial class LlmService
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용 // Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버 // else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
if (!emittedContent && if (!emittedContent &&
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) && deltaEl.SafeTryGetProperty("reasoning_content", out var reasoningEl) &&
reasoningEl.ValueKind == JsonValueKind.String) reasoningEl.ValueKind == JsonValueKind.String)
{ {
var reasoningChunk = reasoningEl.GetString(); var reasoningChunk = reasoningEl.SafeGetString();
if (!string.IsNullOrEmpty(reasoningChunk)) if (!string.IsNullOrEmpty(reasoningChunk))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk); yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
} }
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) && if (deltaEl.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array) toolCallsEl.ValueKind == JsonValueKind.Array)
{ {
foreach (var toolCallEl in toolCallsEl.EnumerateArray()) foreach (var toolCallEl in toolCallsEl.EnumerateArray())
{ {
var index = toolCallEl.TryGetProperty("index", out var indexEl) && var index = toolCallEl.SafeTryGetProperty("index", out var indexEl) &&
indexEl.TryGetInt32(out var parsedIndex) indexEl.TryGetInt32(out var parsedIndex)
? parsedIndex ? parsedIndex
: toolAccumulators.Count; : toolAccumulators.Count;
@@ -1296,18 +1298,18 @@ public partial class LlmService
toolAccumulators[index] = acc; toolAccumulators[index] = acc;
} }
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.GetString() ?? acc.Id; acc.Id = idEl.SafeGetString() ?? acc.Id;
if (toolCallEl.TryGetProperty("function", out var functionEl)) if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
{ {
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.GetString() ?? acc.Name; acc.Name = nameEl.SafeGetString() ?? acc.Name;
if (functionEl.TryGetProperty("arguments", out var argumentsEl)) if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
{ {
if (argumentsEl.ValueKind == JsonValueKind.String) if (argumentsEl.ValueKind == JsonValueKind.String)
acc.Arguments.Append(argumentsEl.GetString()); acc.Arguments.Append(argumentsEl.SafeGetString());
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array) else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
acc.Arguments.Append(argumentsEl.GetRawText()); acc.Arguments.Append(argumentsEl.GetRawText());
} }
@@ -1320,7 +1322,7 @@ public partial class LlmService
} }
} }
if (firstChoice.TryGetProperty("message", out var messageEl)) if (firstChoice.SafeTryGetProperty("message", out var messageEl))
{ {
if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2)) if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2))
{ {
@@ -1358,14 +1360,14 @@ public partial class LlmService
text = ""; text = "";
toolBlocks = new List<ContentBlock>(); toolBlocks = new List<ContentBlock>();
JsonElement message = messageOrRoot; JsonElement message = messageOrRoot;
if (messageOrRoot.TryGetProperty("message", out var nestedMessage)) if (messageOrRoot.SafeTryGetProperty("message", out var nestedMessage))
message = nestedMessage; message = nestedMessage;
var consumed = false; var consumed = false;
if (message.TryGetProperty("content", out var contentEl) && if (message.SafeTryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String) contentEl.ValueKind == JsonValueKind.String)
{ {
var parsedText = contentEl.GetString(); var parsedText = contentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(parsedText)) if (!string.IsNullOrWhiteSpace(parsedText))
{ {
text = parsedText; text = parsedText;
@@ -1374,10 +1376,10 @@ public partial class LlmService
} }
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용 // Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed && if (!consumed &&
message.TryGetProperty("reasoning_content", out var reasoningContentEl) && message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String) reasoningContentEl.ValueKind == JsonValueKind.String)
{ {
var reasoningText = reasoningContentEl.GetString(); var reasoningText = reasoningContentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(reasoningText)) if (!string.IsNullOrWhiteSpace(reasoningText))
{ {
text = reasoningText; text = reasoningText;
@@ -1385,22 +1387,22 @@ public partial class LlmService
} }
} }
if (message.TryGetProperty("tool_calls", out var toolCallsEl) && if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array) toolCallsEl.ValueKind == JsonValueKind.Array)
{ {
foreach (var tc in toolCallsEl.EnumerateArray()) foreach (var tc in toolCallsEl.EnumerateArray())
{ {
if (!tc.TryGetProperty("function", out var functionEl)) if (!tc.SafeTryGetProperty("function", out var functionEl))
continue; continue;
JsonElement? parsedArgs = null; JsonElement? parsedArgs = null;
if (functionEl.TryGetProperty("arguments", out var argsEl)) if (functionEl.SafeTryGetProperty("arguments", out var argsEl))
{ {
if (argsEl.ValueKind == JsonValueKind.String) if (argsEl.ValueKind == JsonValueKind.String)
{ {
try try
{ {
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}"); using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone(); parsedArgs = argsDoc.RootElement.Clone();
} }
catch { parsedArgs = null; } catch { parsedArgs = null; }
@@ -1414,8 +1416,8 @@ public partial class LlmService
toolBlocks.Add(new ContentBlock toolBlocks.Add(new ContentBlock
{ {
Type = "tool_use", Type = "tool_use",
ToolName = functionEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : "", ToolName = functionEl.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "",
ToolId = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], ToolId = tc.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs, ToolInput = parsedArgs,
}); });
} }
@@ -1542,12 +1544,12 @@ public partial class LlmService
{ {
using var doc = JsonDocument.Parse(errBody); using var doc = JsonDocument.Parse(errBody);
// Ollama: {"error":"..."} // Ollama: {"error":"..."}
if (doc.RootElement.TryGetProperty("error", out var err)) if (doc.RootElement.SafeTryGetProperty("error", out var err))
{ {
if (err.ValueKind == JsonValueKind.String) if (err.ValueKind == JsonValueKind.String)
return err.GetString() ?? errBody; return err.SafeGetString() ?? errBody;
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m)) if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
return m.GetString() ?? errBody; return m.SafeGetString() ?? errBody;
} }
} }
catch { } catch { }

View File

@@ -4,6 +4,7 @@ using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AxCopilot.Models; using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
@@ -403,17 +404,17 @@ public partial class LlmService : IDisposable
try try
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocks)) if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocks))
{ {
var parts = new List<string>(); var parts = new List<string>();
foreach (var block in blocks.EnumerateArray()) foreach (var block in blocks.EnumerateArray())
{ {
if (!block.TryGetProperty("type", out var typeEl)) continue; if (!block.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString(); var type = typeEl.SafeGetString();
if (type == "text" && block.TryGetProperty("text", out var textEl)) if (type == "text" && block.SafeTryGetProperty("text", out var textEl))
parts.Add(textEl.GetString() ?? ""); parts.Add(textEl.SafeGetString() ?? "");
else if (type == "tool_use" && block.TryGetProperty("name", out var nameEl)) else if (type == "tool_use" && block.SafeTryGetProperty("name", out var nameEl))
parts.Add($"[도구 호출: {nameEl.GetString()}]"); parts.Add($"[도구 호출: {nameEl.SafeGetString()}]");
} }
var content = string.Join("\n", parts).Trim(); var content = string.Join("\n", parts).Trim();
if (!string.IsNullOrEmpty(content)) if (!string.IsNullOrEmpty(content))
@@ -431,8 +432,8 @@ public partial class LlmService : IDisposable
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement; var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "tool" : "tool"; var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "tool" : "tool";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : ""; var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
msgs.Add(new { role = "user", content = $"[{toolName} 결과]\n{toolContent}" }); msgs.Add(new { role = "user", content = $"[{toolName} 결과]\n{toolContent}" });
continue; continue;
} }
@@ -461,41 +462,41 @@ public partial class LlmService : IDisposable
private static string ExtractIbmDeploymentText(JsonElement root) private static string ExtractIbmDeploymentText(JsonElement root)
{ {
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0) if (root.SafeTryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
{ {
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default; var message = choices[0].SafeTryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object) if (message.ValueKind == JsonValueKind.Object)
{ {
if (message.TryGetProperty("content", out var content)) if (message.SafeTryGetProperty("content", out var content))
{ {
var text = content.GetString(); var text = content.SafeGetString();
if (!string.IsNullOrEmpty(text)) if (!string.IsNullOrEmpty(text))
return text; return text;
} }
// Qwen3.5 thinking 모드 폴백 // Qwen3.5 thinking 모드 폴백
if (message.TryGetProperty("reasoning_content", out var reasoning)) if (message.SafeTryGetProperty("reasoning_content", out var reasoning))
{ {
var text = reasoning.GetString(); var text = reasoning.SafeGetString();
if (!string.IsNullOrEmpty(text)) if (!string.IsNullOrEmpty(text))
return text; return text;
} }
} }
} }
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0) if (root.SafeTryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
{ {
var first = results[0]; var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText)) if (first.SafeTryGetProperty("generated_text", out var generatedText))
return generatedText.GetString() ?? ""; return generatedText.SafeGetString() ?? "";
if (first.TryGetProperty("output_text", out var outputText)) if (first.SafeTryGetProperty("output_text", out var outputText))
return outputText.GetString() ?? ""; return outputText.SafeGetString() ?? "";
} }
if (root.TryGetProperty("generated_text", out var generated)) if (root.SafeTryGetProperty("generated_text", out var generated))
return generated.GetString() ?? ""; return generated.SafeGetString() ?? "";
if (root.TryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String) if (root.SafeTryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
return messageValue.GetString() ?? ""; return messageValue.SafeGetString() ?? "";
return ""; return "";
} }
@@ -719,7 +720,10 @@ public partial class LlmService : IDisposable
return SafeParseJson(resp, root => return SafeParseJson(resp, root =>
{ {
TryParseOllamaUsage(root); TryParseOllamaUsage(root);
return root.GetProperty("message").GetProperty("content").GetString() ?? ""; var msg = root.SafeGetProperty("message");
if (msg == null) return root.SafeGetString() ?? "(빈 응답)";
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "Ollama 응답"); }, "Ollama 응답");
} }
@@ -759,10 +763,10 @@ public partial class LlmService : IDisposable
try try
{ {
using var doc = JsonDocument.Parse(line); using var doc = JsonDocument.Parse(line);
if (doc.RootElement.TryGetProperty("message", out var msg) && if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
msg.TryGetProperty("content", out var c)) msg.SafeTryGetProperty("content", out var c))
text = c.GetString(); text = c.SafeGetString();
if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean()) if (doc.RootElement.SafeTryGetProperty("done", out var done) && done.GetBoolean())
TryParseOllamaUsage(doc.RootElement); TryParseOllamaUsage(doc.RootElement);
} }
catch (JsonException ex) catch (JsonException ex)
@@ -827,9 +831,15 @@ public partial class LlmService : IDisposable
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed; return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
} }
var choices = root.GetProperty("choices"); if (!root.SafeTryGetProperty("choices", out var choices)
if (choices.GetArrayLength() == 0) return "(빈 응답)"; || choices.ValueKind != JsonValueKind.Array
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? ""; || choices.GetArrayLength() == 0)
return "(빈 응답)";
var firstChoice = choices[0];
var msg = firstChoice.SafeGetProperty("message");
if (msg == null) return firstChoice.SafeGetString() ?? "(빈 응답)";
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "vLLM 응답"); }, "vLLM 응답");
} }
@@ -867,27 +877,27 @@ public partial class LlmService : IDisposable
{ {
using var doc = JsonDocument.Parse(data); using var doc = JsonDocument.Parse(data);
// 스트리밍 청크(delta) → content 누적 // 스트리밍 청크(delta) → content 누적
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0) if (doc.RootElement.SafeTryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{ {
var first = ch[0]; var first = ch[0];
if (first.TryGetProperty("delta", out var delta)) if (first.SafeTryGetProperty("delta", out var delta))
{ {
string? txt = null; string? txt = null;
if (delta.TryGetProperty("content", out var cnt)) if (delta.SafeTryGetProperty("content", out var cnt))
txt = cnt.GetString(); txt = cnt.SafeGetString();
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용 // Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc)) if (string.IsNullOrEmpty(txt) && delta.SafeTryGetProperty("reasoning_content", out var rc))
txt = rc.GetString(); txt = rc.SafeGetString();
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; } if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
} }
else if (first.TryGetProperty("message", out _)) else if (first.SafeTryGetProperty("message", out _))
{ {
// 완성 응답 → 이 JSON을 그대로 사용 // 완성 응답 → 이 JSON을 그대로 사용
return data; return data;
} }
} }
// IBM results[] 형식 // IBM results[] 형식
else if (doc.RootElement.TryGetProperty("results", out var res) && res.GetArrayLength() > 0) else if (doc.RootElement.SafeTryGetProperty("results", out var res) && res.GetArrayLength() > 0)
{ {
return data; return data;
} }
@@ -960,44 +970,67 @@ public partial class LlmService : IDisposable
TryParseOpenAiUsage(doc.RootElement); TryParseOpenAiUsage(doc.RootElement);
if (usesIbmDeploymentApi) if (usesIbmDeploymentApi)
{ {
if (doc.RootElement.TryGetProperty("status", out var status) && if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase)) string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{ {
var detail = doc.RootElement.TryGetProperty("message", out var message) var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
? message.GetString() ? message.SafeGetString()
: "IBM vLLM 스트리밍 오류"; : "IBM vLLM 스트리밍 오류";
throw new InvalidOperationException(detail); throw new InvalidOperationException(detail);
} }
if (doc.RootElement.TryGetProperty("results", out var results) && if (doc.RootElement.SafeTryGetProperty("results", out var results) &&
results.ValueKind == JsonValueKind.Array && results.ValueKind == JsonValueKind.Array &&
results.GetArrayLength() > 0) results.GetArrayLength() > 0)
{ {
var first = results[0]; var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText)) if (first.SafeTryGetProperty("generated_text", out var generatedText))
text = generatedText.GetString(); text = generatedText.SafeGetString();
else if (first.TryGetProperty("output_text", out var outputText)) else if (first.SafeTryGetProperty("output_text", out var outputText))
text = outputText.GetString(); text = outputText.SafeGetString();
} }
else if (doc.RootElement.TryGetProperty("choices", out var ibmChoices) && ibmChoices.GetArrayLength() > 0) else if (doc.RootElement.SafeTryGetProperty("choices", out var ibmChoices)
&& ibmChoices.ValueKind == JsonValueKind.Array
&& ibmChoices.GetArrayLength() > 0)
{ {
var delta = ibmChoices[0].GetProperty("delta"); var fc = ibmChoices[0];
if (delta.TryGetProperty("content", out var c)) if (fc.SafeTryGetProperty("delta", out var delta))
text = c.GetString(); {
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc)) if (delta.ValueKind == JsonValueKind.String)
text = rc.GetString(); text = delta.SafeGetString();
else
{
if (delta.SafeTryGetProperty("content", out var c))
text = c.SafeGetString();
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc))
text = rc.SafeGetString();
}
}
else if (fc.ValueKind == JsonValueKind.String)
text = fc.SafeGetString();
} }
} }
else else
{ {
var choices = doc.RootElement.GetProperty("choices"); if (doc.RootElement.SafeTryGetProperty("choices", out var choices)
if (choices.GetArrayLength() > 0) && choices.ValueKind == JsonValueKind.Array
&& choices.GetArrayLength() > 0)
{ {
var delta = choices[0].GetProperty("delta"); var fc = choices[0];
if (delta.TryGetProperty("content", out var c)) if (fc.SafeTryGetProperty("delta", out var delta))
text = c.GetString(); {
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2)) if (delta.ValueKind == JsonValueKind.String)
text = rc2.GetString(); text = delta.SafeGetString();
else
{
if (delta.SafeTryGetProperty("content", out var c))
text = c.SafeGetString();
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc2))
text = rc2.SafeGetString();
}
}
else if (fc.ValueKind == JsonValueKind.String)
text = fc.SafeGetString();
} }
} }
} }
@@ -1021,6 +1054,9 @@ public partial class LlmService : IDisposable
["temperature"] = ResolveTemperature(), ["temperature"] = ResolveTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens() ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens()
}; };
// 스트리밍 시 마지막 청크에 토큰 사용량을 포함하도록 요청 (vLLM/OpenAI 호환)
if (stream)
body["stream_options"] = new { include_usage = true };
var effort = ResolveReasoningEffort(); var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort)) if (!string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort; body["reasoning_effort"] = effort;
@@ -1045,11 +1081,16 @@ public partial class LlmService : IDisposable
return SafeParseJson(resp, root => return SafeParseJson(resp, root =>
{ {
TryParseGeminiUsage(root); TryParseGeminiUsage(root);
var candidates = root.GetProperty("candidates"); if (!root.SafeTryGetProperty("candidates", out var candidates)
if (candidates.GetArrayLength() == 0) return "(빈 응답)"; || candidates.ValueKind != JsonValueKind.Array
var parts = candidates[0].GetProperty("content").GetProperty("parts"); || candidates.GetArrayLength() == 0)
if (parts.GetArrayLength() == 0) return "(빈 응답)"; return "(빈 응답)";
return parts[0].GetProperty("text").GetString() ?? ""; var first = candidates[0];
var content = first.SafeGetProperty("content");
if (content == null || !content.Value.SafeTryGetProperty("parts", out var parts)
|| parts.ValueKind != JsonValueKind.Array || parts.GetArrayLength() == 0)
return first.SafeGetString() ?? "(빈 응답)";
return parts[0].SafeGetProperty("text")?.SafeGetString() ?? "";
}, "Gemini 응답"); }, "Gemini 응답");
} }
@@ -1093,15 +1134,19 @@ public partial class LlmService : IDisposable
{ {
using var doc = JsonDocument.Parse(data); using var doc = JsonDocument.Parse(data);
TryParseGeminiUsage(doc.RootElement); TryParseGeminiUsage(doc.RootElement);
var candidates = doc.RootElement.GetProperty("candidates"); if (!doc.RootElement.SafeTryGetProperty("candidates", out var candidates)
if (candidates.GetArrayLength() == 0) continue; || candidates.ValueKind != JsonValueKind.Array
|| candidates.GetArrayLength() == 0) continue;
var sb = new StringBuilder(); var sb = new StringBuilder();
var parts = candidates[0].GetProperty("content").GetProperty("parts"); var firstCand = candidates[0];
var contentEl = firstCand.SafeGetProperty("content");
if (contentEl == null || !contentEl.Value.SafeTryGetProperty("parts", out var parts)
|| parts.ValueKind != JsonValueKind.Array) continue;
foreach (var part in parts.EnumerateArray()) foreach (var part in parts.EnumerateArray())
{ {
if (part.TryGetProperty("text", out var t)) if (part.SafeTryGetProperty("text", out var t))
{ {
var text = t.GetString(); var text = t.SafeGetString();
if (!string.IsNullOrEmpty(text)) sb.Append(text); if (!string.IsNullOrEmpty(text)) sb.Append(text);
} }
} }
@@ -1185,9 +1230,11 @@ public partial class LlmService : IDisposable
return SafeParseJson(respJson, root => return SafeParseJson(respJson, root =>
{ {
TryParseSigmoidUsage(root); TryParseSigmoidUsage(root);
var content = root.GetProperty("content"); if (!root.SafeTryGetProperty("content", out var content)
if (content.GetArrayLength() == 0) return "(빈 응답)"; || content.ValueKind != JsonValueKind.Array
return content[0].GetProperty("text").GetString() ?? ""; || content.GetArrayLength() == 0)
return root.SafeGetString() ?? "(빈 응답)";
return content[0].SafeGetProperty("text")?.SafeGetString() ?? "";
}, "Claude 응답"); }, "Claude 응답");
} }
@@ -1237,20 +1284,20 @@ public partial class LlmService : IDisposable
try try
{ {
using var doc = JsonDocument.Parse(data); using var doc = JsonDocument.Parse(data);
var type = doc.RootElement.GetProperty("type").GetString(); var type = doc.RootElement.SafeGetProperty("type")?.SafeGetString();
if (type == "content_block_delta") if (type == "content_block_delta")
{ {
var delta = doc.RootElement.GetProperty("delta"); if (!doc.RootElement.SafeTryGetProperty("delta", out var delta)) continue;
if (delta.TryGetProperty("text", out var t)) if (delta.SafeTryGetProperty("text", out var t))
text = t.GetString(); text = t.SafeGetString();
} }
else if (type is "message_start" or "message_delta") else if (type is "message_start" or "message_delta")
{ {
// message_start: usage in .message.usage, message_delta: usage in .usage // message_start: usage in .message.usage, message_delta: usage in .usage
if (doc.RootElement.TryGetProperty("message", out var msg) && if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u1)) msg.SafeTryGetProperty("usage", out var u1))
TryParseSigmoidUsageFromElement(u1); TryParseSigmoidUsageFromElement(u1);
else if (doc.RootElement.TryGetProperty("usage", out var u2)) else if (doc.RootElement.SafeTryGetProperty("usage", out var u2))
TryParseSigmoidUsageFromElement(u2); TryParseSigmoidUsageFromElement(u2);
} }
} }
@@ -1434,9 +1481,9 @@ public partial class LlmService : IDisposable
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
// API 에러 응답 감지 // API 에러 응답 감지
if (doc.RootElement.TryGetProperty("error", out var error)) if (doc.RootElement.SafeTryGetProperty("error", out var error))
{ {
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : error.ToString(); var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : error.ToString();
throw new HttpRequestException($"[{context}] API 에러: {msg}"); throw new HttpRequestException($"[{context}] API 에러: {msg}");
} }
@@ -1468,12 +1515,12 @@ public partial class LlmService : IDisposable
try try
{ {
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var err)) if (doc.RootElement.SafeTryGetProperty("error", out var err))
{ {
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m)) if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
detail = m.GetString() ?? ""; detail = m.SafeGetString() ?? "";
else if (err.ValueKind == JsonValueKind.String) else if (err.ValueKind == JsonValueKind.String)
detail = err.GetString() ?? ""; detail = err.SafeGetString() ?? "";
} }
} }
catch { } catch { }
@@ -1506,8 +1553,8 @@ public partial class LlmService : IDisposable
{ {
try try
{ {
var prompt = root.TryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0; var prompt = root.SafeTryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0;
var completion = root.TryGetProperty("eval_count", out var e) ? e.GetInt32() : 0; var completion = root.SafeTryGetProperty("eval_count", out var e) ? e.GetInt32() : 0;
if (prompt > 0 || completion > 0) if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion); LastTokenUsage = new TokenUsage(prompt, completion);
} }
@@ -1518,9 +1565,9 @@ public partial class LlmService : IDisposable
{ {
try try
{ {
if (!root.TryGetProperty("usage", out var usage)) return; if (!root.SafeTryGetProperty("usage", out var usage)) return;
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0; var prompt = usage.SafeTryGetProperty("prompt_tokens", out var p) ? p.SafeGetInt32(0) : 0;
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0; var completion = usage.SafeTryGetProperty("completion_tokens", out var c) ? c.SafeGetInt32(0) : 0;
if (prompt > 0 || completion > 0) if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion); LastTokenUsage = new TokenUsage(prompt, completion);
} }
@@ -1531,9 +1578,9 @@ public partial class LlmService : IDisposable
{ {
try try
{ {
if (!root.TryGetProperty("usageMetadata", out var usage)) return; if (!root.SafeTryGetProperty("usageMetadata", out var usage)) return;
var prompt = usage.TryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0; var prompt = usage.SafeTryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0;
var completion = usage.TryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0; var completion = usage.SafeTryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0;
if (prompt > 0 || completion > 0) if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion); LastTokenUsage = new TokenUsage(prompt, completion);
} }
@@ -1544,7 +1591,7 @@ public partial class LlmService : IDisposable
{ {
try try
{ {
if (!root.TryGetProperty("usage", out var usage)) return; if (!root.SafeTryGetProperty("usage", out var usage)) return;
TryParseSigmoidUsageFromElement(usage); TryParseSigmoidUsageFromElement(usage);
} }
catch { } catch { }
@@ -1554,8 +1601,8 @@ public partial class LlmService : IDisposable
{ {
try try
{ {
var input = usage.TryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0; var input = usage.SafeTryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0;
var output = usage.TryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0; var output = usage.SafeTryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0;
if (input > 0 || output > 0) if (input > 0 || output > 0)
LastTokenUsage = new TokenUsage(input, output); LastTokenUsage = new TokenUsage(input, output);
} }

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AxCopilot.Models; using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
@@ -114,34 +115,34 @@ public class McpClientService : IDisposable
try try
{ {
if (result.Value.TryGetProperty("tools", out var toolsArr)) if (result.Value.SafeTryGetProperty("tools", out var toolsArr))
{ {
foreach (var tool in toolsArr.EnumerateArray()) foreach (var tool in toolsArr.EnumerateArray())
{ {
var def = new McpToolDefinition var def = new McpToolDefinition
{ {
Name = tool.GetProperty("name").GetString() ?? "", Name = tool.GetProperty("name").SafeGetString() ?? "",
Description = tool.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "", Description = tool.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
ServerName = _config.Name, ServerName = _config.Name,
}; };
if (tool.TryGetProperty("inputSchema", out var schema) && if (tool.SafeTryGetProperty("inputSchema", out var schema) &&
schema.TryGetProperty("properties", out var props)) schema.SafeTryGetProperty("properties", out var props))
{ {
foreach (var prop in props.EnumerateObject()) foreach (var prop in props.EnumerateObject())
{ {
def.Parameters[prop.Name] = new McpParameterDef def.Parameters[prop.Name] = new McpParameterDef
{ {
Type = prop.Value.TryGetProperty("type", out var t) ? t.GetString() ?? "string" : "string", Type = prop.Value.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "string" : "string",
Description = prop.Value.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "", Description = prop.Value.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "",
}; };
} }
if (schema.TryGetProperty("required", out var reqArr)) if (schema.SafeTryGetProperty("required", out var reqArr))
{ {
foreach (var req in reqArr.EnumerateArray()) foreach (var req in reqArr.EnumerateArray())
{ {
var reqName = req.GetString(); var reqName = req.SafeGetString();
if (reqName != null && def.Parameters.TryGetValue(reqName, out var p)) if (reqName != null && def.Parameters.TryGetValue(reqName, out var p))
p.Required = true; p.Required = true;
} }
@@ -167,16 +168,16 @@ public class McpClientService : IDisposable
try try
{ {
if (result.Value.TryGetProperty("resources", out var resourcesArr)) if (result.Value.SafeTryGetProperty("resources", out var resourcesArr))
{ {
foreach (var resource in resourcesArr.EnumerateArray()) foreach (var resource in resourcesArr.EnumerateArray())
{ {
_resources.Add(new McpResourceDefinition _resources.Add(new McpResourceDefinition
{ {
Uri = resource.TryGetProperty("uri", out var uri) ? uri.GetString() ?? "" : "", Uri = resource.SafeTryGetProperty("uri", out var uri) ? uri.SafeGetString() ?? "" : "",
Name = resource.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "", Name = resource.SafeTryGetProperty("name", out var name) ? name.SafeGetString() ?? "" : "",
Description = resource.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "", Description = resource.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
MimeType = resource.TryGetProperty("mimeType", out var mime) ? mime.GetString() ?? "" : "", MimeType = resource.SafeTryGetProperty("mimeType", out var mime) ? mime.SafeGetString() ?? "" : "",
ServerName = _config.Name, ServerName = _config.Name,
}); });
} }
@@ -201,18 +202,18 @@ public class McpClientService : IDisposable
try try
{ {
if (result.Value.TryGetProperty("content", out var contentArr)) if (result.Value.SafeTryGetProperty("content", out var contentArr))
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var item in contentArr.EnumerateArray()) foreach (var item in contentArr.EnumerateArray())
{ {
if (item.TryGetProperty("text", out var text)) if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.GetString()); sb.AppendLine(text.SafeGetString());
} }
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
if (result.Value.TryGetProperty("isError", out var isErr) && isErr.GetBoolean()) if (result.Value.SafeTryGetProperty("isError", out var isErr) && isErr.GetBoolean())
{ {
return $"[MCP 오류] {result}"; return $"[MCP 오류] {result}";
} }
@@ -235,15 +236,15 @@ public class McpClientService : IDisposable
try try
{ {
if (result.Value.TryGetProperty("contents", out var contentsArr)) if (result.Value.SafeTryGetProperty("contents", out var contentsArr))
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var item in contentsArr.EnumerateArray()) foreach (var item in contentsArr.EnumerateArray())
{ {
if (item.TryGetProperty("text", out var text)) if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.GetString()); sb.AppendLine(text.SafeGetString());
else if (item.TryGetProperty("uri", out var itemUri)) else if (item.SafeTryGetProperty("uri", out var itemUri))
sb.AppendLine($"uri: {itemUri.GetString()}"); sb.AppendLine($"uri: {itemUri.SafeGetString()}");
} }
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
@@ -285,14 +286,14 @@ public class McpClientService : IDisposable
var root = doc.RootElement; var root = doc.RootElement;
// notification은 건너뛰기 // notification은 건너뛰기
if (!root.TryGetProperty("id", out _)) continue; if (!root.SafeTryGetProperty("id", out _)) continue;
if (root.TryGetProperty("result", out var result)) if (root.SafeTryGetProperty("result", out var result))
return result; return result;
if (root.TryGetProperty("error", out var error)) if (root.SafeTryGetProperty("error", out var error))
{ {
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : "Unknown error"; var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : "Unknown error";
LogService.Warn($"MCP '{_config.Name}' RPC 오류: {msg}"); LogService.Warn($"MCP '{_config.Name}' RPC 오류: {msg}");
return null; return null;
} }

View File

@@ -42,10 +42,30 @@ public partial class ChatWindow
/// <summary> /// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다. /// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다. /// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지).
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
/// </summary> /// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender) private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{ {
// ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ──
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var result = _chatEngine.AppendExecutionEvent(
session, null!, _currentConversation, _activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}");
}
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender)); _agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
} }

View File

@@ -181,9 +181,9 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth(); var msgMaxWidth = GetMessageMaxWidth();
var stack = new StackPanel var stack = new StackPanel
{ {
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = msgMaxWidth, MaxWidth = msgMaxWidth,
Margin = new Thickness(0), Margin = new Thickness(48, 1, 12, 1),
}; };
var liveWaitingStyle = evt.Type == AgentEventType.Thinking var liveWaitingStyle = evt.Type == AgentEventType.Thinking
@@ -400,13 +400,12 @@ public partial class ChatWindow
return new Border return new Border
{ {
Background = cardBackground, Background = Brushes.Transparent,
BorderBrush = cardBorder, BorderThickness = new Thickness(0),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10), CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8), Padding = new Thickness(0, 4, 0, 4),
Margin = new Thickness(12, 6, 12, 2), Margin = new Thickness(0, 1, 12, 1),
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Left,
Child = contentGrid, Child = contentGrid,
}; };
} }
@@ -1186,6 +1185,30 @@ public partial class ChatWindow
}; };
} }
// HTML/대용량 파일 내용이 이벤트 요약에 포함된 경우 인라인 표시 대신 1줄 요약으로 축소
if (!string.IsNullOrWhiteSpace(eventSummaryText) && evt.Type == AgentEventType.ToolResult
&& eventSummaryText.Length > 500)
{
// HTML 내용 감지: <!DOCTYPE, <html, <head, <body, <div 등
var trimmed = eventSummaryText.TrimStart();
var firstLine = trimmed.Split('\n')[0];
if (trimmed.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
|| trimmed.Contains("<html", StringComparison.OrdinalIgnoreCase)
|| (evt.FilePath?.EndsWith(".html", StringComparison.OrdinalIgnoreCase) == true)
|| (evt.FilePath?.EndsWith(".htm", StringComparison.OrdinalIgnoreCase) == true))
{
var lineCount = eventSummaryText.Count(c => c == '\n') + 1;
eventSummaryText = $"{firstLine}\n({lineCount}줄 · HTML 문서 — 미리보기 패널에서 확인)";
}
else if (eventSummaryText.Length > 2000)
{
// 기타 대용량 텍스트: 첫 3줄만 표시
var lines = eventSummaryText.Split('\n');
var preview = string.Join("\n", lines.Take(3));
eventSummaryText = preview + $"\n... ({lines.Length}줄 전체)";
}
}
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0) if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
UpdateProgressBar(evt); UpdateProgressBar(evt);
@@ -1204,13 +1227,12 @@ public partial class ChatWindow
var bannerMaxWidth = GetMessageMaxWidth(); var bannerMaxWidth = GetMessageMaxWidth();
var banner = new Border var banner = new Border
{ {
Background = hintBg, Background = Brushes.Transparent,
BorderBrush = borderColor, BorderThickness = new Thickness(0),
BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8),
CornerRadius = new CornerRadius(10), Padding = new Thickness(14, 5, 14, 5),
Padding = new Thickness(9, 7, 9, 7), Margin = new Thickness(48, 1, 12, 1),
Margin = new Thickness(12, 3, 12, 3), HorizontalAlignment = HorizontalAlignment.Left,
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = bannerMaxWidth, MaxWidth = bannerMaxWidth,
}; };
if (!string.IsNullOrWhiteSpace(evt.RunId)) if (!string.IsNullOrWhiteSpace(evt.RunId))

View File

@@ -33,12 +33,13 @@ public partial class ChatWindow
var triggerRatio = triggerPercent / 100.0; var triggerRatio = triggerPercent / 100.0;
// 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지) // 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지)
// 스트리밍 중에는 매번 재계산 (도구 결과 메시지가 실시간으로 추가됨)
int messageTokens; int messageTokens;
lock (_convLock) lock (_convLock)
{ {
var convId = _currentConversation?.Id; var convId = _currentConversation?.Id;
var msgCount = _currentConversation?.Messages?.Count ?? 0; var msgCount = _currentConversation?.Messages?.Count ?? 0;
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens) if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens || _isStreaming)
{ {
_cachedMessageTokens = msgCount > 0 _cachedMessageTokens = msgCount > 0
? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages) ? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages)

View File

@@ -16,6 +16,165 @@ public partial class ChatWindow
private const int ConversationPageSize = 50; private const int ConversationPageSize = 50;
private List<ConversationMeta>? _pendingConversations; private List<ConversationMeta>? _pendingConversations;
// ── A-1: 이벤트 위임 필드 ──
/// <summary>현재 마우스가 올라가 있는 대화 항목 Border.</summary>
private Border? _lastHoveredConvBorder;
private bool _convPanelDelegationInitialized;
/// <summary>대화 항목 Border.Tag에 저장하는 메타 데이터.</summary>
private sealed class ConversationItemTag
{
public required string Id { get; init; }
public required bool IsSelected { get; init; }
public TextBlock? TitleBlock { get; init; }
public Brush? TitleColor { get; init; }
public Button? CatButton { get; init; }
public Brush? HoverBackground { get; init; }
}
/// <summary>ConversationPanel에 이벤트 위임 핸들러를 1회 등록합니다.</summary>
private void InitConversationPanelDelegation()
{
if (_convPanelDelegationInitialized || ConversationPanel == null)
return;
_convPanelDelegationInitialized = true;
ConversationPanel.MouseMove += ConversationPanel_DelegatedMouseMove;
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp;
}
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (ReferenceEquals(border, _lastHoveredConvBorder))
return;
// 이전 호버 해제
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
{
if (!prevTag.IsSelected)
_lastHoveredConvBorder.Background = Brushes.Transparent;
if (prevTag.CatButton != null)
prevTag.CatButton.Visibility = Visibility.Collapsed;
}
_lastHoveredConvBorder = border;
// 새 호버 적용
if (border?.Tag is ConversationItemTag tag)
{
if (!tag.IsSelected)
border.Background = tag.HoverBackground ?? Brushes.Transparent;
if (tag.CatButton != null)
tag.CatButton.Visibility = Visibility.Visible;
}
}
private void ConversationPanel_DelegatedMouseLeave(object sender, MouseEventArgs e)
{
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
{
if (!prevTag.IsSelected)
_lastHoveredConvBorder.Background = Brushes.Transparent;
if (prevTag.CatButton != null)
prevTag.CatButton.Visibility = Visibility.Collapsed;
}
_lastHoveredConvBorder = null;
}
private void ConversationPanel_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// catBtn Click은 자체 이벤트로 처리 — Button 내부 클릭이면 무시
if (FindAncestor<Button>(e.OriginalSource as DependencyObject) is not null)
return;
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not ConversationItemTag tag)
return;
e.Handled = true;
HandleConversationItemClick(tag);
}
private void ConversationPanel_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
if (border?.Tag is not ConversationItemTag tag)
return;
e.Handled = true;
HandleConversationItemRightClick(tag);
}
private void HandleConversationItemClick(ConversationItemTag tag)
{
try
{
if (tag.IsSelected)
{
if (tag.TitleBlock != null && tag.TitleColor != null)
EnterTitleEditMode(tag.TitleBlock, tag.Id, tag.TitleColor);
return;
}
if (_streamingTabs.Contains(_activeTab))
{
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(tag.Id);
if (conv == null)
return;
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
}
private void HandleConversationItemRightClick(ConversationItemTag tag)
{
if (!tag.IsSelected)
{
var conv = _storage.Load(tag.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(tag.Id)), DispatcherPriority.Input);
}
public void RefreshConversationList() public void RefreshConversationList()
{ {
var metas = _storage.LoadAllMeta(); var metas = _storage.LoadAllMeta();
@@ -66,20 +225,29 @@ public partial class ChatWindow
}; };
}).ToList(); }).ToList();
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList(); // LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
items = items.Where(i => items = items.Where(i =>
i.Pinned string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.ParentId) && (i.Pinned
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.IsNullOrWhiteSpace(i.Preview) || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| i.AgentRunCount > 0 || !string.IsNullOrWhiteSpace(i.Preview)
|| i.FailedAgentRunCount > 0 || i.AgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase) || i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase))
).ToList(); ).ToList();
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0); // Count를 한 번의 루프로 계산 (3번 순회 → 1번)
_runningConversationCount = items.Count(i => i.IsRunning); int failedCount = 0, runningCount = 0, spotlightCount = 0;
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3); foreach (var i in items)
{
if (i.FailedAgentRunCount > 0) failedCount++;
if (i.IsRunning) runningCount++;
if (i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) spotlightCount++;
}
_failedConversationCount = failedCount;
_runningConversationCount = runningCount;
_spotlightConversationCount = spotlightCount;
UpdateConversationFailureFilterUi(); UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi(); UpdateConversationRunningFilterUi();
UpdateConversationQuickStripUi(); UpdateConversationQuickStripUi();
@@ -137,6 +305,7 @@ public partial class ChatWindow
private void RenderConversationList(List<ConversationMeta> items) private void RenderConversationList(List<ConversationMeta> items)
{ {
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear(); ConversationPanel.Children.Clear();
_pendingConversations = null; _pendingConversations = null;
@@ -211,6 +380,8 @@ public partial class ChatWindow
Padding = new Thickness(8, 10, 8, 10), Padding = new Thickness(8, 10, 8, 10),
Margin = new Thickness(6, 4, 6, 4), Margin = new Thickness(6, 4, 6, 4),
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch,
// Tag를 설정하지 않아 이벤트 위임 대상에서 제외됨 (별도 핸들러)
Tag = new LoadMoreTag(),
}; };
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center }; var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
stack.Children.Add(new TextBlock stack.Children.Add(new TextBlock
@@ -238,6 +409,7 @@ public partial class ChatWindow
var all = _pendingConversations; var all = _pendingConversations;
_pendingConversations = null; _pendingConversations = null;
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear(); ConversationPanel.Children.Clear();
string? lastGroup = null; string? lastGroup = null;
@@ -256,6 +428,9 @@ public partial class ChatWindow
ConversationPanel.Children.Add(btn); ConversationPanel.Children.Add(btn);
} }
/// <summary>"더 보기" 버튼의 Tag — ConversationItemTag와 구분하여 이벤트 위임에서 무시.</summary>
private sealed class LoadMoreTag;
private static string GetConversationDateGroup(DateTime updatedAt) private static string GetConversationDateGroup(DateTime updatedAt)
{ {
var today = DateTime.Today; var today = DateTime.Today;
@@ -440,85 +615,15 @@ public partial class ChatWindow
border.Child = grid; border.Child = grid;
var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)); // A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장, 부모에서 처리
border.MouseEnter += (_, _) => border.Tag = new ConversationItemTag
{ {
if (!isSelected) Id = item.Id,
border.Background = hoverBg; IsSelected = isSelected,
catBtn.Visibility = Visibility.Visible; TitleBlock = title,
}; TitleColor = titleColor,
border.MouseLeave += (_, _) => CatButton = catBtn,
{ HoverBackground = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)),
if (!isSelected)
border.Background = Brushes.Transparent;
catBtn.Visibility = Visibility.Collapsed;
};
border.MouseLeftButtonDown += (_, _) =>
{
try
{
if (isSelected)
{
EnterTitleEditMode(title, item.Id, titleColor);
return;
}
if (_streamingTabs.Contains(_activeTab))
{
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(item.Id);
if (conv == null)
return;
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
};
border.MouseRightButtonUp += (_, me) =>
{
me.Handled = true;
if (!isSelected)
{
var conv = _storage.Load(item.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
}; };
ConversationPanel.Children.Add(border); ConversationPanel.Children.Add(border);

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