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

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;
if (root.TryGetProperty("updatedInput", out var inputProp) &&
if (root.SafeTryGetProperty("updatedInput", out var inputProp) &&
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
updatedInput = inputProp.Clone();
@@ -245,10 +245,10 @@ public static class AgentHookRunner
structured = true;
}
if (root.TryGetProperty("message", out var msgProp) &&
if (root.SafeTryGetProperty("message", out var msgProp) &&
msgProp.ValueKind == JsonValueKind.String)
{
var msg = msgProp.GetString();
var msg = msgProp.SafeGetString();
if (!string.IsNullOrWhiteSpace(msg))
message = msg.Trim();
}
@@ -281,8 +281,8 @@ public static class AgentHookRunner
updatedPermissions = null;
JsonElement permProp;
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|| root.TryGetProperty("permissionUpdates", out permProp)))
if (!(root.SafeTryGetProperty("updatedPermissions", out permProp)
|| root.SafeTryGetProperty("permissionUpdates", out permProp)))
return false;
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -301,14 +301,14 @@ public static class AgentHookRunner
if (entry.ValueKind != JsonValueKind.Object)
continue;
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
if (!entry.SafeTryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
continue;
var tool = toolProp.GetString()?.Trim();
var tool = toolProp.SafeGetString()?.Trim();
if (string.IsNullOrWhiteSpace(tool))
continue;
if (entry.TryGetProperty("permission", out var permValue) &&
if (entry.SafeTryGetProperty("permission", out var permValue) &&
TryExtractPermissionValue(permValue, out var normalized))
{
map[tool] = normalized;
@@ -329,7 +329,7 @@ public static class AgentHookRunner
if (permissionElement.ValueKind == JsonValueKind.String)
{
var text = permissionElement.GetString();
var text = permissionElement.SafeGetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
@@ -337,10 +337,10 @@ public static class AgentHookRunner
}
}
else if (permissionElement.ValueKind == JsonValueKind.Object &&
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
permissionElement.SafeTryGetProperty("permission", out var nestedPermission) &&
nestedPermission.ValueKind == JsonValueKind.String)
{
var text = nestedPermission.GetString();
var text = nestedPermission.SafeGetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
@@ -354,12 +354,12 @@ public static class AgentHookRunner
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
{
additionalContext = null;
if (!root.TryGetProperty("additionalContext", out var ctxProp))
if (!root.SafeTryGetProperty("additionalContext", out var ctxProp))
return false;
if (ctxProp.ValueKind == JsonValueKind.String)
{
var text = ctxProp.GetString()?.Trim();
var text = ctxProp.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
{
additionalContext = text;
@@ -376,7 +376,7 @@ public static class AgentHookRunner
if (part.ValueKind != JsonValueKind.String)
continue;
var text = part.GetString()?.Trim();
var text = part.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
chunks.Add(text);
}
@@ -394,10 +394,10 @@ public static class AgentHookRunner
// 우선순위: message > content > text
foreach (var key in new[] { "message", "content", "text" })
{
if (!ctxProp.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
if (!ctxProp.SafeTryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
continue;
var text = value.GetString()?.Trim();
var text = value.SafeGetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
{
additionalContext = text;

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 SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
// P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지
private static readonly JsonSerializerOptions s_jsonOpts = new()
{
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
@@ -168,6 +175,11 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
var explorationState = new ExplorationTrackingState
{
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
SelectiveHit = true,
};
// 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
@@ -229,6 +241,17 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
EmitEvent(
AgentEventType.Thinking,
"",
explorationState.Scope switch
{
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
_ => "점진 탐색 · 필요한 범위부터 확인하는 중",
});
if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
@@ -530,12 +553,15 @@ public partial class AgentLoopService
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
// LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew();
try
{
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
var activeTools = cachedActiveTools;
if (activeTools.Count == 0)
{
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
@@ -814,7 +840,7 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var activeToolPreview = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -857,7 +883,7 @@ public partial class AgentLoopService
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var planToolList = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
@@ -1058,7 +1084,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = textResponse });
foreach (var tc in toolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
@@ -1136,15 +1162,16 @@ public partial class AgentLoopService
if (toolCalls.Count == 0) continue;
}
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
var activeToolNames = cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var call in toolCalls)
{
if (ct.IsCancellationRequested) break;
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var globallyRegisteredTool = _tools.Get(resolvedToolName);
if (globallyRegisteredTool != null &&
@@ -1305,6 +1332,28 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
FormatToolCallSummary(effectiveCall));
var toolInputJson = effectiveCall.ToolInput?.ToString() ?? "{}";
TrackExplorationToolUse(explorationState, effectiveCall.ToolName, toolInputJson);
if (!explorationState.CorrectiveHintInjected &&
ShouldInjectExplorationCorrection(explorationState, effectiveCall.ToolName, toolInputJson))
{
explorationState.CorrectiveHintInjected = true;
explorationState.BroadScanDetected = true;
explorationState.SelectiveHit = false;
messages.Add(new ChatMessage
{
Role = "system",
Content =
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " +
"Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " +
"Do not repeat folder_map unless repository structure is genuinely required."
});
EmitEvent(
AgentEventType.Thinking,
"",
"탐색 범위를 좁히는 중 · 관련 파일만 다시 선택합니다");
}
var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages);
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
return decisionTransition.TerminalResponse;
@@ -1412,7 +1461,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }, s_jsonOpts),
"cancelled",
success: false);
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
@@ -1613,7 +1662,8 @@ public partial class AgentLoopService
llm,
executionPolicy,
context,
ct);
ct,
documentPlanCalled);
if (terminalCompleted)
{
if (consumedExtraIteration)
@@ -1664,7 +1714,7 @@ public partial class AgentLoopService
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }, s_jsonOpts),
"cancelled",
success: false);
}
@@ -1699,6 +1749,20 @@ public partial class AgentLoopService
// 통계 기록 (도구 호출이 1회 이상인 세션만)
if (totalToolCalls > 0)
{
AgentPerformanceLogService.LogExplorationBreadth(
_conversationId,
ActiveTab,
new
{
scope = explorationState.Scope.ToString().ToLowerInvariant(),
folder_map_calls = explorationState.FolderMapCalls,
total_files_read = explorationState.TotalFilesRead,
multi_read_files = explorationState.MultiReadFilesRead,
broad_scan = explorationState.BroadScanDetected,
selective_hit = explorationState.SelectiveHit,
corrective_hint = explorationState.CorrectiveHintInjected
});
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
@@ -2985,11 +3049,11 @@ public partial class AgentLoopService
try
{
using var doc = JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameProp))
if (doc.RootElement.SafeTryGetProperty("tool_name", out var toolNameProp))
{
toolName = toolNameProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("content", out var contentProp))
content = contentProp.GetString() ?? "";
toolName = toolNameProp.SafeGetString() ?? "";
if (doc.RootElement.SafeTryGetProperty("content", out var contentProp))
content = contentProp.SafeGetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
}
@@ -4092,7 +4156,7 @@ public partial class AgentLoopService
contentBlocks.Add(new { type = "text", text = verifyResponse });
foreach (var tc in verifyToolCalls)
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
messages.Add(assistantMsg);
addedMessages.Add(assistantMsg);
@@ -4253,9 +4317,9 @@ public partial class AgentLoopService
{
foreach (var name in names)
{
if (inputElement.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
if (inputElement.SafeTryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
var value = prop.GetString();
var value = prop.SafeGetString();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
@@ -4297,8 +4361,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_write"))
{
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString() ?? ""
var content = input.SafeTryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
? c.SafeGetString() ?? ""
: "";
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
string? previous = null;
@@ -4324,17 +4388,17 @@ public partial class AgentLoopService
if (normalizedTool.Contains("file_edit"))
{
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
if (input.SafeTryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
{
var lines = edits.EnumerateArray()
.Take(6)
.Select((edit, index) =>
{
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.GetString() ?? ""
var oldText = edit.SafeTryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
? oldElem.SafeGetString() ?? ""
: "";
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.GetString() ?? ""
var newText = edit.SafeTryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
? newElem.SafeGetString() ?? ""
: "";
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
@@ -4352,8 +4416,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
{
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.GetString() ?? target
var command = input.SafeTryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
? cmd.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "command",
@@ -4364,8 +4428,8 @@ public partial class AgentLoopService
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
{
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.GetString() ?? target
var url = input.SafeTryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
? u.SafeGetString() ?? target
: target;
return new PermissionPromptPreview(
Kind: "web",
@@ -4636,10 +4700,25 @@ public partial class AgentLoopService
};
if (Dispatcher != null)
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
{
Dispatcher(() =>
{
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
EventOccurred?.Invoke(evt);
});
}
else
{
Events.Add(evt);
// Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
lock (Events)
{
while (Events.Count > 500)
Events.RemoveAt(0);
Events.Add(evt);
}
EventOccurred?.Invoke(evt);
}
}
@@ -4655,10 +4734,10 @@ public partial class AgentLoopService
// Git 커밋 — 수준에 관계없이 무조건 확인
if (toolName == "git_tool")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
if (action == "commit")
{
var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : "";
var msg = input?.SafeTryGetProperty("args", out var m) == true ? m.SafeGetString() : "";
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
}
}
@@ -4669,7 +4748,7 @@ public partial class AgentLoopService
// process 도구 (외부 명령 실행)
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
return null;
@@ -4681,14 +4760,14 @@ public partial class AgentLoopService
// 외부 명령 실행
if (toolName == "process")
{
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
}
// 새 파일 생성
if (toolName == "file_write")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
if (!string.IsNullOrEmpty(path))
{
var fullPath = System.IO.Path.IsPathRooted(path) ? path
@@ -4706,15 +4785,15 @@ public partial class AgentLoopService
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return null;
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
var path = (input?.SafeTryGetProperty("path", out var p1) == true ? p1.SafeGetString() : null)
?? (input?.SafeTryGetProperty("file_path", out var p2) == true ? p2.SafeGetString() : "");
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
// 빌드/테스트 실행
if (toolName is "build_run" or "test_loop")
{
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
}
}
@@ -4724,7 +4803,7 @@ public partial class AgentLoopService
{
if (toolName is "file_write" or "file_edit")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
}
}
@@ -4739,12 +4818,12 @@ public partial class AgentLoopService
{
// 주요 파라미터만 표시
var input = call.ToolInput.Value;
if (input.TryGetProperty("path", out var path))
return $"{call.ToolName}: {path.GetString()}";
if (input.TryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.GetString()}";
if (input.TryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.GetString()}";
if (input.SafeTryGetProperty("path", out var path))
return $"{call.ToolName}: {path.SafeGetString()}";
if (input.SafeTryGetProperty("command", out var cmd))
return $"{call.ToolName}: {cmd.SafeGetString()}";
if (input.SafeTryGetProperty("pattern", out var pat))
return $"{call.ToolName}: {pat.SafeGetString()}";
return call.ToolName;
}
catch { return call.ToolName; }

View File

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

View File

@@ -42,8 +42,8 @@ public class Base64Tool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var text = args.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
try
{
@@ -69,9 +69,9 @@ public class Base64Tool : IAgentTool
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("file_path", out var fp))
if (!args.SafeTryGetProperty("file_path", out var fp))
return ToolResult.Fail("'file_path' is required for b64file action");
var path = fp.GetString() ?? "";
var path = fp.SafeGetString() ?? "";
if (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");

View File

@@ -42,9 +42,9 @@ public class BatchSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var path = args.GetProperty("path").SafeGetString() ?? "";
var content = args.GetProperty("content").SafeGetString() ?? "";
var desc = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);

View File

@@ -56,9 +56,9 @@ public class BuildRunTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var action = args.GetProperty("action").GetString() ?? "detect";
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "detect";
var customCmd = args.SafeTryGetProperty("command", out var cmd) ? cmd.SafeGetString() ?? "" : "";
var subPath = args.SafeTryGetProperty("project_path", out var pp) ? pp.SafeGetString() ?? "" : "";
var workDir = context.WorkFolder;
if (!string.IsNullOrEmpty(subPath))

View File

@@ -56,12 +56,12 @@ public class ChartSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var title = args.GetProperty("title").GetString() ?? "Chart";
var title = args.GetProperty("title").SafeGetString() ?? "Chart";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -69,12 +69,12 @@ public class ChartSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
}
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
var globalAccent = args.TryGetProperty("accent_color", out var ga) ? ga.GetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "dashboard" : "dashboard";
var layout = args.SafeTryGetProperty("layout", out var l) ? l.SafeGetString() ?? "single" : "single";
var globalAccent = args.SafeTryGetProperty("accent_color", out var ga) ? ga.SafeGetString() : null;
var globalShowValues = true;
if (args.TryGetProperty("show_values", out var sv))
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
if (args.SafeTryGetProperty("show_values", out var sv))
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -89,7 +89,7 @@ public class ChartSkill : IAgentTool
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (!args.TryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
var chartCount = chartsEl.GetArrayLength();
@@ -138,17 +138,17 @@ public class ChartSkill : IAgentTool
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
{
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
var type = chart.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "bar" : "bar";
var chartTitle = chart.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? "" : "";
var unit = chart.SafeTryGetProperty("unit", out var u) ? u.SafeGetString() ?? "" : "";
var labels = ParseStringArray(chart, "labels");
var datasets = ParseDatasets(chart);
// Per-chart overrides
var accentColor = chart.TryGetProperty("accent_color", out var ac) ? ac.GetString() : globalAccent;
var accentColor = chart.SafeTryGetProperty("accent_color", out var ac) ? ac.SafeGetString() : globalAccent;
var showValues = globalShowValues;
if (chart.TryGetProperty("show_values", out var sv))
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
if (chart.SafeTryGetProperty("show_values", out var sv))
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
// Apply accent color to first dataset if single-color context
if (!string.IsNullOrEmpty(accentColor) && datasets.Count > 0 && datasets[0].Color == Palette[0])
@@ -534,17 +534,17 @@ public class ChartSkill : IAgentTool
foreach (var ds in datasets)
{
var pts = new List<(double x, double y)>();
if (chart.TryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
{
foreach (var dEl in dsArr.EnumerateArray())
{
var dName = dEl.TryGetProperty("name", out var nn) ? nn.GetString() : null;
var dName = dEl.SafeTryGetProperty("name", out var nn) ? nn.SafeGetString() : null;
if (dName != ds.Name) continue;
if (dEl.TryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
if (dEl.SafeTryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
{
foreach (var pEl in ptsEl.EnumerateArray())
{
if (pEl.TryGetProperty("x", out var px) && pEl.TryGetProperty("y", out var py)
if (pEl.SafeTryGetProperty("x", out var px) && pEl.SafeTryGetProperty("y", out var py)
&& px.TryGetDouble(out var xd) && py.TryGetDouble(out var yd))
pts.Add((xd, yd));
}
@@ -609,12 +609,12 @@ public class ChartSkill : IAgentTool
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
{
var yLabels = ParseStringArray(chart, "y_labels");
var colorFrom = chart.TryGetProperty("color_from", out var cf) ? cf.GetString() ?? "#EFF6FF" : "#EFF6FF";
var colorTo = chart.TryGetProperty("color_to", out var ct2) ? ct2.GetString() ?? "#1D4ED8" : "#1D4ED8";
var colorFrom = chart.SafeTryGetProperty("color_from", out var cf) ? cf.SafeGetString() ?? "#EFF6FF" : "#EFF6FF";
var colorTo = chart.SafeTryGetProperty("color_to", out var ct2) ? ct2.SafeGetString() ?? "#1D4ED8" : "#1D4ED8";
// Parse 2D values array
var grid = new List<List<double>>();
if (chart.TryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
{
foreach (var row in valsEl.EnumerateArray())
{
@@ -678,19 +678,19 @@ public class ChartSkill : IAgentTool
private static string RenderGauge(JsonElement chart, string unit)
{
var value = chart.TryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
var min = chart.TryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
var max = chart.TryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
var value = chart.SafeTryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
var min = chart.SafeTryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
var max = chart.SafeTryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
if (max <= min) max = min + 1;
// Determine arc color from thresholds (highest threshold below value wins)
var arcColor = Palette[0];
if (chart.TryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
{
var thresholds = thEl.EnumerateArray()
.Select(t => (
At: t.TryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
Color: t.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[0] : Palette[0]))
At: t.SafeTryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
Color: t.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[0] : Palette[0]))
.OrderBy(t => t.At)
.ToList();
foreach (var (at, color) in thresholds)
@@ -754,16 +754,16 @@ public class ChartSkill : IAgentTool
private static List<string> ParseStringArray(JsonElement parent, string prop)
{
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!parent.SafeTryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
return new();
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
return arr.EnumerateArray().Select(e => e.SafeGetString() ?? "").ToList();
}
private List<Dataset> ParseDatasets(JsonElement chart)
{
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
if (!chart.SafeTryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
{
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
if (chart.SafeTryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
{
return new()
{
@@ -782,10 +782,10 @@ public class ChartSkill : IAgentTool
int colorIdx = 0;
foreach (var ds in dsArr.EnumerateArray())
{
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
var name = ds.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
var color = ds.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
var values = new List<double>();
if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
if (ds.SafeTryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
list.Add(new Dataset { Name = name, Values = values, Color = color });
colorIdx++;

View File

@@ -66,9 +66,9 @@ public class CheckpointTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
@@ -87,7 +87,7 @@ public class CheckpointTool : IAgentTool
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed";
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "unnamed" : "unnamed";
// 이름에서 파일 시스템 비안전 문자 제거
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
@@ -206,16 +206,16 @@ public class CheckpointTool : IAgentTool
// ID 또는 이름으로 체크포인트 찾기
string? targetDir = null;
if (args.TryGetProperty("id", out var idEl))
if (args.SafeTryGetProperty("id", out var idEl))
{
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (id >= 0 && id < dirs.Count)
targetDir = dirs[id];
}
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
if (targetDir == null && args.SafeTryGetProperty("name", out var nameEl))
{
var name = nameEl.GetString() ?? "";
var name = nameEl.SafeGetString() ?? "";
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
}
@@ -276,10 +276,10 @@ public class CheckpointTool : IAgentTool
.OrderByDescending(d => d)
.ToList();
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (id < 0 || id >= dirs.Count)
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");

View File

@@ -38,7 +38,7 @@ public class ClipboardTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -79,10 +79,10 @@ public class ClipboardTool : IAgentTool
private static ToolResult WriteClipboard(JsonElement args)
{
if (!args.TryGetProperty("text", out var textProp))
if (!args.SafeTryGetProperty("text", out var textProp))
return ToolResult.Fail("'text' parameter is required for write action");
var text = textProp.GetString() ?? "";
var text = textProp.SafeGetString() ?? "";
Clipboard.SetText(text);
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
}

View File

@@ -53,9 +53,9 @@ public class CodeReviewTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var target = args.SafeTryGetProperty("target", out var t) ? t.SafeGetString() ?? "" : "";
var focus = args.SafeTryGetProperty("focus", out var f) ? f.SafeGetString() ?? "all" : "all";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");

View File

@@ -50,9 +50,9 @@ public class CodeSearchTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
var maxResults = args.SafeTryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
var reindex = args.SafeTryGetProperty("reindex", out var ri) && ri.GetBoolean();
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");

View File

@@ -21,9 +21,9 @@ public sealed class CronCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
var schedule = args.SafeTryGetProperty("schedule", out var s) ? (s.SafeGetString() ?? "").Trim() : "";
var command = args.SafeTryGetProperty("command", out var c) ? (c.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))

View File

@@ -23,8 +23,8 @@ public sealed class CronDeleteTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));

View File

@@ -42,31 +42,31 @@ public class CsvSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 검증 ──────────────────────────────────────────────
if (!args.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers' (문자열 배열)가 필요합니다.");
if (!args.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows' (배열의 배열)가 필요합니다.");
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var hint = headersEl.GetArrayLength() > 0
? headersEl[0].GetString() ?? "data"
var hint = headersEl.GetArrayLength() > 0 && headersEl[0].ValueKind == JsonValueKind.String
? headersEl[0].SafeGetString() ?? "data"
: "data";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 40) safe = safe[..40].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
}
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var delimiterKey = args.TryGetProperty("delimiter", out var delimEl) ? delimEl.GetString() ?? "comma" : "comma";
var useBom = !args.TryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
var useSummary = args.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
var delimiterKey = args.SafeTryGetProperty("delimiter", out var delimEl) ? delimEl.SafeGetString() ?? "comma" : "comma";
var useBom = !args.SafeTryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
var useSummary = args.SafeTryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
// ── 구분자 해석 ────────────────────────────────────────────────────
var delimiter = delimiterKey.ToLowerInvariant() switch
@@ -78,9 +78,9 @@ public class CsvSkill : IAgentTool
// ── 열 타입 힌트 ───────────────────────────────────────────────────
var colTypeHints = new List<string>();
if (args.TryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
foreach (var ct2 in colTypesEl.EnumerateArray())
colTypeHints.Add(ct2.GetString()?.ToLowerInvariant() ?? "text");
colTypeHints.Add(ct2.SafeGetString()?.ToLowerInvariant() ?? "text");
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -118,7 +118,7 @@ public class CsvSkill : IAgentTool
// ── 헤더 수집 ──────────────────────────────────────────────────
var headerList = new List<string>();
foreach (var h in headersEl.EnumerateArray())
headerList.Add(h.GetString() ?? "");
headerList.Add(h.SafeGetString() ?? "");
int colCount = headerList.Count;

View File

@@ -56,7 +56,7 @@ public class DataPivotTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var sourcePath = args.GetProperty("source_path").GetString() ?? "";
var sourcePath = args.GetProperty("source_path").SafeGetString() ?? "";
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -81,28 +81,28 @@ public class DataPivotTool : IAgentTool
var originalCount = data.Count;
// 필터 적용
if (args.TryGetProperty("filter", out var filterEl))
if (args.SafeTryGetProperty("filter", out var filterEl))
{
var filterStr = filterEl.GetString() ?? "";
var filterStr = filterEl.SafeGetString() ?? "";
if (!string.IsNullOrWhiteSpace(filterStr))
data = ApplyFilter(data, filterStr);
}
// 그룹화 & 집계
List<Dictionary<string, string>> result;
if (args.TryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
{
var groupCols = new List<string>();
foreach (var g in groupEl.EnumerateArray())
groupCols.Add(g.GetString() ?? "");
groupCols.Add(g.SafeGetString() ?? "");
var aggregates = new List<(string Column, string Function)>();
if (args.TryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
{
foreach (var agg in aggEl.EnumerateArray())
{
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
var col = agg.SafeTryGetProperty("column", out var c) ? c.SafeGetString() ?? "" : "";
var func = agg.SafeTryGetProperty("function", out var f) ? f.SafeGetString() ?? "count" : "count";
if (!string.IsNullOrEmpty(col))
aggregates.Add((col, func));
}
@@ -116,19 +116,19 @@ public class DataPivotTool : IAgentTool
}
// 정렬
if (args.TryGetProperty("sort_by", out var sortEl))
if (args.SafeTryGetProperty("sort_by", out var sortEl))
{
var sortBy = sortEl.GetString() ?? "";
var sortBy = sortEl.SafeGetString() ?? "";
if (!string.IsNullOrWhiteSpace(sortBy))
result = ApplySort(result, sortBy);
}
// Top N
if (args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
if (args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
result = result.Take(topN).ToList();
// 출력 포맷
var outputFormat = args.TryGetProperty("output_format", out var ofmt) ? ofmt.GetString() ?? "table" : "table";
var outputFormat = args.SafeTryGetProperty("output_format", out var ofmt) ? ofmt.SafeGetString() ?? "table" : "table";
var output = FormatOutput(result, outputFormat);
return Task.FromResult(ToolResult.Ok(
@@ -192,7 +192,7 @@ public class DataPivotTool : IAgentTool
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement
: doc.RootElement.TryGetProperty("data", out var d) ? d : doc.RootElement;
: doc.RootElement.SafeTryGetProperty("data", out var d) ? d : doc.RootElement;
if (arr.ValueKind != JsonValueKind.Array) return data;

View File

@@ -58,7 +58,7 @@ public class DateTimeTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -94,7 +94,7 @@ public class DateTimeTool : IAgentTool
private static ToolResult Parse(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var d) ? d.GetString() ?? "" : "";
var dateStr = args.SafeTryGetProperty("date", out var d) ? d.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
@@ -110,8 +110,8 @@ public class DateTimeTool : IAgentTool
private static ToolResult Diff(JsonElement args)
{
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
var d1 = args.SafeTryGetProperty("date", out var v1) ? v1.SafeGetString() ?? "" : "";
var d2 = args.SafeTryGetProperty("date2", out var v2) ? v2.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
return ToolResult.Fail("'date' and 'date2' parameters are required");
@@ -132,9 +132,9 @@ public class DateTimeTool : IAgentTool
private static ToolResult Add(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
var amountStr = args.SafeTryGetProperty("amount", out var av) ? av.SafeGetString() ?? "0" : "0";
var unit = args.SafeTryGetProperty("unit", out var uv) ? uv.SafeGetString() ?? "days" : "days";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
@@ -159,7 +159,7 @@ public class DateTimeTool : IAgentTool
private static ToolResult Epoch(JsonElement args)
{
var input = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var input = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
// 숫자면 epoch → datetime
@@ -186,8 +186,8 @@ public class DateTimeTool : IAgentTool
private static ToolResult FormatDate(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
var pattern = args.SafeTryGetProperty("pattern", out var pv) ? pv.SafeGetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");

View File

@@ -40,7 +40,7 @@ public class DevEnvDetectTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var category = args.TryGetProperty("category", out var cat) ? cat.GetString() ?? "all" : "all";
var category = args.SafeTryGetProperty("category", out var cat) ? cat.SafeGetString() ?? "all" : "all";
// 캐시 확인
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60)

View File

@@ -42,9 +42,9 @@ public class DiffPreviewTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var newContent = args.GetProperty("new_content").GetString() ?? "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var newContent = args.GetProperty("new_content").SafeGetString() ?? "";
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);

View File

@@ -50,11 +50,11 @@ public class DiffTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var left = args.GetProperty("left").GetString() ?? "";
var right = args.GetProperty("right").GetString() ?? "";
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
var left = args.GetProperty("left").SafeGetString() ?? "";
var right = args.GetProperty("right").SafeGetString() ?? "";
var leftLabel = args.SafeTryGetProperty("left_label", out var ll) ? ll.SafeGetString() ?? "left" : "left";
var rightLabel = args.SafeTryGetProperty("right_label", out var rl) ? rl.SafeGetString() ?? "right" : "right";
try
{

View File

@@ -64,24 +64,24 @@ public class DocumentAssemblerTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Document";
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var path = args.GetProperty("path").SafeGetString() ?? "";
var title = args.GetProperty("title").SafeGetString() ?? "Document";
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood();
var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null;
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("sections 배열이 필요합니다.");
var sections = new List<(string Heading, string Content, int Level)>();
foreach (var sec in sectionsEl.EnumerateArray())
{
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
var heading = sec.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
var level = sec.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
sections.Add((heading, content, level));
}

View File

@@ -84,12 +84,12 @@ public class DocumentPlannerTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var topic = args.GetProperty("topic").GetString() ?? "";
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
var topic = args.GetProperty("topic").SafeGetString() ?? "";
var docType = args.SafeTryGetProperty("document_type", out var dt) ? dt.SafeGetString() ?? "report" : "report";
var targetPages = args.SafeTryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
var sectionsHint = args.SafeTryGetProperty("sections_hint", out var sh) ? sh.SafeGetString() ?? "" : "";
var refSummary = args.SafeTryGetProperty("reference_summary", out var rs) ? rs.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(topic))
return Task.FromResult(ToolResult.Fail("topic이 비어있습니다."));

View File

@@ -49,14 +49,14 @@ public class DocumentReaderTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return ToolResult.Fail("path가 필요합니다.");
var path = pathEl.GetString() ?? "";
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
var path = pathEl.SafeGetString() ?? "";
var maxChars = args.SafeTryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
var offset = args.SafeTryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
var sheetParam = args.SafeTryGetProperty("sheet", out var sh) ? sh.SafeGetString() ?? "" : "";
var pagesParam = args.SafeTryGetProperty("pages", out var pg) ? pg.SafeGetString() ?? "" : "";
var sectionParam = args.SafeTryGetProperty("section", out var sec) ? sec.SafeGetString() ?? "" : "";
if (maxChars < 100) maxChars = DefaultMaxChars;
if (offset < 0) offset = 0;
@@ -565,7 +565,7 @@ public class DocumentReaderTool : IAgentTool
private static int GetIntValue(JsonElement el, int defaultValue)
{
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.SafeGetString(), out var v)) return v;
return defaultValue;
}
}

View File

@@ -33,9 +33,9 @@ public class DocumentReviewTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var path = pathEl.SafeGetString() ?? "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -108,11 +108,11 @@ public class DocumentReviewTool : IAgentTool
}
// 5. 기대 섹션 검사
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
{
foreach (var sec in sections.EnumerateArray())
{
var title = sec.GetString() ?? "";
var title = sec.SafeGetString() ?? "";
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
}

View File

@@ -94,12 +94,12 @@ public class DocxSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -108,12 +108,12 @@ public class DocxSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
}
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
(headerText != null || footerText != null);
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
var theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"];
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -161,7 +161,7 @@ public class DocxSkill : IAgentTool
int tableCount = 0;
foreach (var section in sections.EnumerateArray())
{
var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null;
var blockType = section.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString()?.ToLower() : null;
if (blockType == "pagebreak")
{
@@ -195,9 +195,9 @@ public class DocxSkill : IAgentTool
}
// 일반 섹션 (heading + body)
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
var level = section.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrEmpty(heading))
body.Append(CreateHeadingParagraph(heading, level, theme));
@@ -371,9 +371,9 @@ public class DocxSkill : IAgentTool
private static Table CreateTable(JsonElement section, ThemeColors theme)
{
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
var headers = section.SafeTryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.SafeTryGetProperty("rows", out var rArr) ? rArr : default;
var tableStyle = section.SafeTryGetProperty("style", out var ts) ? ts.SafeGetString() ?? "striped" : "striped";
var table = new Table();
@@ -403,7 +403,7 @@ public class DocxSkill : IAgentTool
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
};
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
var para = new Paragraph(new Run(new Text(h.SafeGetString() ?? ""))
{
RunProperties = new RunProperties
{
@@ -472,15 +472,15 @@ public class DocxSkill : IAgentTool
private static void AppendList(Body body, JsonElement section)
{
var items = section.TryGetProperty("items", out var arr) ? arr : default;
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
var items = section.SafeTryGetProperty("items", out var arr) ? arr : default;
var listStyle = section.SafeTryGetProperty("style", out var ls) ? ls.SafeGetString() ?? "bullet" : "bullet";
if (items.ValueKind != JsonValueKind.Array) return;
int idx = 1;
foreach (var item in items.EnumerateArray())
{
var rawText = item.GetString() ?? item.ToString();
var rawText = item.SafeGetString() ?? item.ToString();
// 서브불릿 감지: 앞에 공백/탭 또는 "- " 시작
bool isSub = rawText.StartsWith(" ") || rawText.StartsWith("\t") || rawText.TrimStart().StartsWith("- ");
@@ -526,9 +526,9 @@ public class DocxSkill : IAgentTool
private static void AppendCallout(Body body, JsonElement section)
{
var style = section.TryGetProperty("style", out var sv) ? sv.GetString() ?? "info" : "info";
var calloutTitle = section.TryGetProperty("title", out var tv) ? tv.GetString() ?? "" : "";
var calloutBody = section.TryGetProperty("body", out var bv) ? bv.GetString() ?? "" : "";
var style = section.SafeTryGetProperty("style", out var sv) ? sv.SafeGetString() ?? "info" : "info";
var calloutTitle = section.SafeTryGetProperty("title", out var tv) ? tv.SafeGetString() ?? "" : "";
var calloutBody = section.SafeTryGetProperty("body", out var bv) ? bv.SafeGetString() ?? "" : "";
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
? cc
@@ -588,8 +588,8 @@ public class DocxSkill : IAgentTool
private static Paragraph CreateHighlightBox(JsonElement section)
{
var text = section.TryGetProperty("text", out var tv) ? tv.GetString() ?? "" : "";
var color = section.TryGetProperty("color", out var cv) ? cv.GetString() ?? "blue" : "blue";
var text = section.SafeTryGetProperty("text", out var tv) ? tv.SafeGetString() ?? "" : "";
var color = section.SafeTryGetProperty("color", out var cv) ? cv.SafeGetString() ?? "blue" : "blue";
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
? hc

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)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
if (action == "list")
return ListEncodings();
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(rawPath))
return ToolResult.Fail("'path'가 필요합니다.");
@@ -90,7 +90,7 @@ public class EncodingTool : IAgentTool
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
{
var toName = args.TryGetProperty("to_encoding", out var te) ? te.GetString() ?? "utf-8" : "utf-8";
var toName = args.SafeTryGetProperty("to_encoding", out var te) ? te.SafeGetString() ?? "utf-8" : "utf-8";
// 쓰기 권한 확인
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
@@ -98,10 +98,10 @@ public class EncodingTool : IAgentTool
// 소스 인코딩 결정
Encoding fromEnc;
if (args.TryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.GetString()))
if (args.SafeTryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.SafeGetString()))
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
fromEnc = Encoding.GetEncoding(fe.GetString()!);
fromEnc = Encoding.GetEncoding(fe.SafeGetString()!);
}
else
{

View File

@@ -20,13 +20,13 @@ public sealed class EnterWorktreeTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var relative = args.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "").Trim() : "";
var relative = args.SafeTryGetProperty("path", out var pathEl) ? (pathEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(relative))
return Task.FromResult(ToolResult.Fail("path is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var create = !args.TryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
var create = !args.SafeTryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
var full = Path.GetFullPath(Path.Combine(root, relative));
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))

View File

@@ -47,7 +47,7 @@ public class EnvTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -68,9 +68,9 @@ public class EnvTool : IAgentTool
private static ToolResult Get(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
if (!args.SafeTryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for get action");
var name = n.GetString() ?? "";
var name = n.SafeGetString() ?? "";
var value = Environment.GetEnvironmentVariable(name);
return value != null
? ToolResult.Ok($"{name}={value}")
@@ -79,20 +79,20 @@ public class EnvTool : IAgentTool
private static ToolResult Set(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
if (!args.SafeTryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for set action");
if (!args.TryGetProperty("value", out var v))
if (!args.SafeTryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for set action");
var name = n.GetString() ?? "";
var value = v.GetString() ?? "";
var name = n.SafeGetString() ?? "";
var value = v.SafeGetString() ?? "";
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
}
private static ToolResult ListVars(JsonElement args)
{
var filter = args.TryGetProperty("filter", out var f) ? f.GetString() ?? "" : "";
var filter = args.SafeTryGetProperty("filter", out var f) ? f.SafeGetString() ?? "" : "";
var vars = Environment.GetEnvironmentVariables();
var entries = new List<string>();
@@ -118,9 +118,9 @@ public class EnvTool : IAgentTool
private static ToolResult Expand(JsonElement args)
{
if (!args.TryGetProperty("value", out var v))
if (!args.SafeTryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for expand action");
var input = v.GetString() ?? "";
var input = v.SafeGetString() ?? "";
var expanded = Environment.ExpandEnvironmentVariables(input);
return ToolResult.Ok(expanded);
}

View File

@@ -84,15 +84,15 @@ public class ExcelSkill : IAgentTool
{
// path 미제공 시 title 또는 sheet_name에서 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var hint = (args.TryGetProperty("title", out var tEl) ? tEl.GetString() : null)
?? (args.TryGetProperty("sheet_name", out var snEl) ? snEl.GetString() : null)
var hint = (args.SafeTryGetProperty("title", out var tEl) ? tEl.SafeGetString() : null)
?? (args.SafeTryGetProperty("sheet_name", out var snEl) ? snEl.SafeGetString() : null)
?? "workbook";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
@@ -116,7 +116,7 @@ public class ExcelSkill : IAgentTool
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Determine if we are in multi-sheet mode
var multiSheetMode = args.TryGetProperty("sheets", out var sheetsArr)
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
&& sheetsArr.ValueKind == JsonValueKind.Array
&& sheetsArr.GetArrayLength() > 0;
@@ -137,16 +137,16 @@ public class ExcelSkill : IAgentTool
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
{
if (!args.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers'");
if (!args.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() : null;
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
var isStyled = tableStyle != "plain";
var freezeHeader= args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var freezeHeader= args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(args, "number_formats");
@@ -168,8 +168,8 @@ public class ExcelSkill : IAgentTool
var colCount = headers.GetArrayLength();
var summaryArg = args.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
@@ -218,7 +218,7 @@ public class ExcelSkill : IAgentTool
}
// Use first sheet's theme for the shared stylesheet
var firstThemeName = sheetsArr[0].TryGetProperty("theme", out var ft) ? ft.GetString() : null;
var firstThemeName = sheetsArr[0].SafeTryGetProperty("theme", out var ft) ? ft.SafeGetString() : null;
var firstTheme = GetTheme(firstThemeName);
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
@@ -235,21 +235,21 @@ public class ExcelSkill : IAgentTool
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var sheetName = sheetDef.TryGetProperty("name", out var snEl) ? snEl.GetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
var tableStyle = sheetDef.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "styled" : "styled";
var themeName = sheetDef.TryGetProperty("theme", out var thEl) ? thEl.GetString() : firstThemeName;
var sheetName = sheetDef.SafeTryGetProperty("name", out var snEl) ? snEl.SafeGetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
var tableStyle = sheetDef.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "styled" : "styled";
var themeName = sheetDef.SafeTryGetProperty("theme", out var thEl) ? thEl.SafeGetString() : firstThemeName;
var isStyled = tableStyle != "plain";
var freezeHeader = sheetDef.TryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
var freezeHeader = sheetDef.SafeTryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
var alignments = ParseAlignments(sheetDef, "col_alignments");
if (!sheetDef.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
var colCount = headers.GetArrayLength();
var summaryArg = sheetDef.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = sheetDef.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
@@ -315,7 +315,7 @@ public class ExcelSkill : IAgentTool
{
CellReference = cellRef,
DataType = CellValues.String,
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.GetString() ?? "" : ""),
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.SafeGetString() ?? "" : ""),
StyleIndex = isStyled ? (uint)1 : 0,
};
headerRow.Append(cell);
@@ -374,7 +374,7 @@ public class ExcelSkill : IAgentTool
var mergeCells = new MergeCells();
foreach (var merge in mergesArg.EnumerateArray())
{
var range = merge.GetString();
var range = merge.SafeGetString();
if (!string.IsNullOrEmpty(range))
mergeCells.Append(new MergeCell { Reference = range });
}
@@ -678,7 +678,7 @@ public class ExcelSkill : IAgentTool
private static Columns? CreateColumns(JsonElement args, int colCount)
{
var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
var hasWidths = args.SafeTryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
var columns = new Columns();
for (int i = 0; i < colCount; i++)
@@ -706,8 +706,8 @@ public class ExcelSkill : IAgentTool
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
uint rowNum, int colCount, int dataRowCount, bool isStyled)
{
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
var label = summary.SafeTryGetProperty("label", out var lbl) ? lbl.SafeGetString() ?? "합계" : "합계";
var colFormulas = summary.SafeTryGetProperty("columns", out var cols) ? cols : default;
var summaryRow = new Row { RowIndex = rowNum };
@@ -730,9 +730,9 @@ public class ExcelSkill : IAgentTool
};
if (colFormulas.ValueKind == JsonValueKind.Object &&
colFormulas.TryGetProperty(colLetter, out var funcName))
colFormulas.SafeTryGetProperty(colLetter, out var funcName))
{
var func = funcName.GetString()?.ToUpper() ?? "SUM";
var func = funcName.SafeGetString()?.ToUpper() ?? "SUM";
var startRow = 2;
var endRow = startRow + dataRowCount - 1;
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
@@ -751,20 +751,20 @@ public class ExcelSkill : IAgentTool
private static List<string?> ParseNumberFormats(JsonElement args, string key)
{
var result = new List<string?>();
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString());
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString());
return result;
}
private static List<string?> ParseAlignments(JsonElement args, string key)
{
var result = new List<string?>();
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString()?.ToLower());
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString()?.ToLower());
return result;
}

View File

@@ -24,10 +24,10 @@ public class FileEditTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var oldStr = args.GetProperty("old_string").GetString() ?? "";
var newStr = args.GetProperty("new_string").GetString() ?? "";
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var path = args.GetProperty("path").SafeGetString() ?? "";
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -47,7 +47,11 @@ public class FileEditTool : IAgentTool
var count = CountOccurrences(content, oldStr);
if (count == 0)
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
{
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
var hint = BuildNotFoundHint(content, oldStr);
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
}
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
@@ -113,6 +117,43 @@ public class FileEditTool : IAgentTool
return sb.ToString().TrimEnd();
}
/// <summary>old_string 미발견 시 LLM에 파일 내용 힌트를 제공합니다.</summary>
private static string BuildNotFoundHint(string content, string oldStr)
{
if (string.IsNullOrWhiteSpace(content)) return " 파일이 비어있습니다.";
var sb = new StringBuilder();
// 유사 행 검색: old_string의 첫 줄로 근사 매치 시도
var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r');
if (firstLine.Length >= 8)
{
var lines = content.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].Contains(firstLine, StringComparison.OrdinalIgnoreCase)
|| (firstLine.Length >= 15 && lines[i].Trim().TrimEnd('\r').StartsWith(firstLine[..15], StringComparison.OrdinalIgnoreCase)))
{
var start = Math.Max(0, i - 1);
var end = Math.Min(lines.Length - 1, i + 3);
sb.AppendLine($"\n\n유사한 내용 발견 (줄 {i + 1}):");
for (int j = start; j <= end; j++)
sb.AppendLine($" {j + 1}: {lines[j].TrimEnd('\r')}");
break;
}
}
}
// 파일이 짧으면 전체 내용 표시
if (sb.Length == 0)
{
var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content;
sb.AppendLine($"\n\n현재 파일 내용:\n{preview}");
}
return sb.ToString();
}
private static int CountOccurrences(string text, string search)
{
if (string.IsNullOrEmpty(search)) return 0;

View File

@@ -27,7 +27,7 @@ public class FileInfoTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))

View File

@@ -41,9 +41,9 @@ public class FileManageTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var rawPath = args.GetProperty("path").GetString() ?? "";
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var dest = args.SafeTryGetProperty("destination", out var d) ? d.SafeGetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);

View File

@@ -22,11 +22,11 @@ public class FileReadTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
if (!args.SafeTryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
var path = pathEl.SafeGetString() ?? "";
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
var fullPath = ResolvePath(path, context.WorkFolder);

View File

@@ -57,12 +57,12 @@ public class FileWatchTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
var path = args.GetProperty("path").SafeGetString() ?? "";
var pattern = args.SafeTryGetProperty("pattern", out var patEl) ? patEl.SafeGetString() ?? "*" : "*";
var sinceStr = args.SafeTryGetProperty("since", out var sinceEl) ? sinceEl.SafeGetString() ?? "24h" : "24h";
var recursive = !args.SafeTryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
var includeSize = !args.SafeTryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
var topN = args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))

View File

@@ -48,11 +48,11 @@ public class FileWriteTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var encName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var append = args.TryGetProperty("append", out var appEl) && appEl.GetBoolean();
var mkDirs = !args.TryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
var content = args.GetProperty("content").SafeGetString() ?? "";
var encName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
var append = args.SafeTryGetProperty("append", out var appEl) && appEl.GetBoolean();
var mkDirs = !args.SafeTryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);

View File

@@ -21,8 +21,8 @@ public class FolderMapTool : IAgentTool
Properties = new()
{
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new()
{
@@ -52,34 +52,34 @@ public class FolderMapTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── path ──────────────────────────────────────────────────────────
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
// ── depth ─────────────────────────────────────────────────────────
var depth = 3;
if (args.TryGetProperty("depth", out var d))
var depth = 2;
if (args.SafeTryGetProperty("depth", out var d))
{
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.SafeGetString(), out var dv)) depth = dv;
}
if (depth < 1) depth = 1;
var maxDepth = Math.Min(depth, 10);
// ── include_files ─────────────────────────────────────────────────
var includeFiles = true;
if (args.TryGetProperty("include_files", out var inc))
var includeFiles = false;
if (args.SafeTryGetProperty("include_files", out var inc))
{
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
includeFiles = inc.GetBoolean();
else
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
}
// ── extensions / pattern ──────────────────────────────────────────
HashSet<string>? extSet = null;
if (args.TryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
{
var list = extsEl.EnumerateArray()
.Select(e => e.GetString() ?? "")
.Select(e => e.SafeGetString() ?? "")
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.StartsWith('.') ? s : "." + s)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -88,34 +88,34 @@ public class FolderMapTool : IAgentTool
// Fall back to single pattern if extensions not provided
string extFilter = "";
if (extSet == null)
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : "";
// ── sort_by ───────────────────────────────────────────────────────
var sortBy = args.TryGetProperty("sort_by", out var sb2) ? sb2.GetString() ?? "name" : "name";
var sortBy = args.SafeTryGetProperty("sort_by", out var sb2) ? sb2.SafeGetString() ?? "name" : "name";
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
// ── show_dir_sizes ────────────────────────────────────────────────
var showDirSizes = false;
if (args.TryGetProperty("show_dir_sizes", out var sds))
if (args.SafeTryGetProperty("show_dir_sizes", out var sds))
{
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
showDirSizes = sds.GetBoolean();
else
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase);
}
// ── modified_after ────────────────────────────────────────────────
DateTime? modifiedAfter = null;
if (args.TryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
if (args.SafeTryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(maEl.GetString(), out var mdt))
if (DateTime.TryParse(maEl.SafeGetString(), out var mdt))
modifiedAfter = mdt;
}
// ── max_file_size ─────────────────────────────────────────────────
long? maxFileSizeBytes = null;
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? "");
// ── resolve base directory ────────────────────────────────────────
var baseDir = string.IsNullOrEmpty(subPath)

View File

@@ -36,9 +36,9 @@ public class FormatConvertTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var target = args.GetProperty("target").GetString() ?? "";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var source = args.GetProperty("source").SafeGetString() ?? "";
var target = args.GetProperty("target").SafeGetString() ?? "";
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);

View File

@@ -51,10 +51,10 @@ public class GitTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "status";
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
var action = actionEl.SafeGetString() ?? "status";
var extraArgs = args.SafeTryGetProperty("args", out var a) ? a.SafeGetString() ?? "" : "";
var workDir = context.WorkFolder;
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))

View File

@@ -27,12 +27,12 @@ public class GlobTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name";
var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : "";
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder

View File

@@ -33,20 +33,20 @@ public class GrepTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var maxMatches = args.TryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
var filesOnly = args.TryGetProperty("files_only", out var fo) && fo.GetBoolean();
var wholeWord = args.TryGetProperty("whole_word", out var ww) && ww.GetBoolean();
var invert = args.TryGetProperty("invert", out var inv) && inv.GetBoolean();
var maxFileSizeKb = args.TryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
var globFilter = args.SafeTryGetProperty("glob", out var g) ? g.SafeGetString() ?? "" : "";
var caseSensitive = args.SafeTryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var maxMatches = args.SafeTryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
var filesOnly = args.SafeTryGetProperty("files_only", out var fo) && fo.GetBoolean();
var wholeWord = args.SafeTryGetProperty("whole_word", out var ww) && ww.GetBoolean();
var invert = args.SafeTryGetProperty("invert", out var inv) && inv.GetBoolean();
var maxFileSizeKb = args.SafeTryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
// context_lines is the base for both sides; before/after_lines override individually
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
var beforeLines = args.TryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
var afterLines = args.TryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
var contextLines = args.SafeTryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
var beforeLines = args.SafeTryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
var afterLines = args.SafeTryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder

View File

@@ -41,9 +41,9 @@ public class HashTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var input = args.GetProperty("input").GetString() ?? "";
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
var input = args.GetProperty("input").SafeGetString() ?? "";
var algo = args.SafeTryGetProperty("algorithm", out var a) ? a.SafeGetString() ?? "sha256" : "sha256";
try
{

View File

@@ -67,26 +67,26 @@ public class HtmlSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// 필수 파라미터 안전 추출
if (!args.TryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
if (!args.SafeTryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
return ToolResult.Fail("필수 파라미터 누락: 'title'");
// path 미제공 시 title로 자동 생성
args.TryGetProperty("path", out var pathEl);
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.GetString()))
args.SafeTryGetProperty("path", out var pathEl);
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
pathEl = default; // 아래에서 title 기반으로 생성
// body와 sections 둘 다 없으면 오류
bool hasBody = args.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
if (!hasBody && !hasSections)
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
var title = titleEl.GetString() ?? "Report";
var title = titleEl.SafeGetString() ?? "Report";
// path가 없으면 title에서 안전한 파일명 생성
string path;
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -95,14 +95,14 @@ public class HtmlSkill : IAgentTool
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
path = safeName + ".html";
}
var body = hasBody ? (bodyEl.GetString() ?? "") : "";
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.TryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.TryGetProperty("accent_color", out var accentEl) ? accentEl.GetString() : null;
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -187,8 +187,16 @@ public class HtmlSkill : IAgentTool
if (!string.IsNullOrEmpty(tocHtml))
sb.AppendLine(tocHtml);
// 본문
sb.AppendLine(body);
// 본문 — table 태그에 반응형 래퍼 자동 추가
var wrappedBody = System.Text.RegularExpressions.Regex.Replace(
body,
@"(<table\b)",
"<div style=\"overflow-x:auto;margin:0 -4px;padding:0 4px\">$1");
wrappedBody = System.Text.RegularExpressions.Regex.Replace(
wrappedBody,
@"(</table>)",
"$1</div>");
sb.AppendLine(wrappedBody);
sb.AppendLine("</div>");
sb.AppendLine("</body>");
@@ -225,8 +233,8 @@ public class HtmlSkill : IAgentTool
var sb = new StringBuilder();
foreach (var section in sections.EnumerateArray())
{
if (!section.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString() ?? "";
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString() ?? "";
switch (type.ToLowerInvariant())
{
@@ -267,22 +275,22 @@ public class HtmlSkill : IAgentTool
private static string RenderHeading(JsonElement s)
{
var level = s.TryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
return $"<h{level}>{Escape(text)}</h{level}>";
}
private static string RenderParagraph(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
return $"<p>{MarkdownToHtml(text)}</p>";
}
private static string RenderCallout(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "info" : "info";
var title = s.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var text = s.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "";
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "info" : "info";
var title = s.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "";
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "" };
var sb = new StringBuilder();
sb.AppendLine($"<div class=\"callout-{style}\">");
@@ -299,15 +307,15 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("<div style=\"overflow-x:auto\">");
sb.AppendLine("<table>");
if (s.TryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
if (s.SafeTryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<thead><tr>");
foreach (var h in headers.EnumerateArray())
sb.Append($"<th>{Escape(h.GetString() ?? "")}</th>");
sb.Append($"<th>{Escape(h.SafeGetString() ?? "")}</th>");
sb.AppendLine("</tr></thead>");
}
if (s.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
if (s.SafeTryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<tbody>");
foreach (var row in rows.EnumerateArray())
@@ -315,7 +323,7 @@ public class HtmlSkill : IAgentTool
sb.Append("<tr>");
if (row.ValueKind == JsonValueKind.Array)
foreach (var cell in row.EnumerateArray())
sb.Append($"<td>{MarkdownToHtml(cell.GetString() ?? "")}</td>");
sb.Append($"<td>{MarkdownToHtml(cell.SafeGetString() ?? "")}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
@@ -328,19 +336,19 @@ public class HtmlSkill : IAgentTool
private static string RenderChart(JsonElement s, string? accentColor)
{
var kind = s.TryGetProperty("kind", out var k) ? k.GetString() ?? "bar" : "bar";
var chartTitle = s.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var kind = s.SafeTryGetProperty("kind", out var k) ? k.SafeGetString() ?? "bar" : "bar";
var chartTitle = s.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
if (!s.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return "";
var items = new List<(string label, double value, string color)>();
double maxVal = 0;
foreach (var item in data.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
var color = item.TryGetProperty("color", out var cl) ? cl.GetString() ?? "#2E75B6" : "#2E75B6";
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
var color = item.SafeTryGetProperty("color", out var cl) ? cl.SafeGetString() ?? "#2E75B6" : "#2E75B6";
items.Add((label, value, color));
if (value > maxVal) maxVal = value;
}
@@ -399,17 +407,17 @@ public class HtmlSkill : IAgentTool
private static string RenderCards(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"cards-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin:1rem 0\">");
foreach (var item in items.EnumerateArray())
{
var cardTitle = item.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var cardBody = item.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
var badge = item.TryGetProperty("badge", out var bg) ? bg.GetString() : null;
var icon = item.TryGetProperty("icon", out var ic) ? ic.GetString() : null;
var cardTitle = item.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
var cardBody = item.SafeTryGetProperty("body", out var bd) ? bd.SafeGetString() ?? "" : "";
var badge = item.SafeTryGetProperty("badge", out var bg) ? bg.SafeGetString() : null;
var icon = item.SafeTryGetProperty("icon", out var ic) ? ic.SafeGetString() : null;
sb.AppendLine("<div class=\"card\" style=\"border:1px solid #e5e7eb;border-radius:8px;padding:1.1rem;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.07)\">");
sb.Append("<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:6px\">");
@@ -429,8 +437,8 @@ public class HtmlSkill : IAgentTool
private static string RenderList(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "bullet" : "bullet";
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "bullet" : "bullet";
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var tag = style == "number" ? "ol" : "ul";
@@ -442,7 +450,7 @@ public class HtmlSkill : IAgentTool
bool inSubList = false;
foreach (var item in items.EnumerateArray())
{
var text = item.GetString() ?? "";
var text = item.SafeGetString() ?? "";
bool isSub = text.StartsWith(" ");
if (isSub && !inSubList)
{
@@ -464,8 +472,8 @@ public class HtmlSkill : IAgentTool
private static string RenderQuote(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var a) ? a.GetString() : null;
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var author = s.SafeTryGetProperty("author", out var a) ? a.SafeGetString() : null;
var sb = new StringBuilder();
sb.AppendLine("<blockquote style=\"border-left:4px solid #d1d5db;margin:1.5rem 0;padding:.75rem 1.25rem;background:#f9fafb;border-radius:0 6px 6px 0\">");
sb.AppendLine($"<p style=\"margin:0;font-style:italic;color:#374151\">{MarkdownToHtml(text)}</p>");
@@ -477,17 +485,17 @@ public class HtmlSkill : IAgentTool
private static string RenderKpi(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"kpi-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin:1.25rem 0\">");
foreach (var item in items.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetString() ?? "" : "";
var change = item.TryGetProperty("change", out var ch) ? ch.GetString() : null;
var positive = item.TryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var vl) ? vl.SafeGetString() ?? "" : "";
var change = item.SafeTryGetProperty("change", out var ch) ? ch.SafeGetString() : null;
var positive = item.SafeTryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
var changeColor = positive ? "#16a34a" : "#dc2626";
var changeArrow = positive ? "▲" : "▼";
@@ -515,6 +523,9 @@ public class HtmlSkill : IAgentTool
{
if (string.IsNullOrEmpty(text)) return text;
// 0. LLM이 이미 삽입한 <br> / <br/> 태그를 보존 — 이스케이프 전에 플레이스홀더로 치환
text = Regex.Replace(text, @"<br\s*/?>", "\x00BR\x00");
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
text = text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
@@ -535,6 +546,9 @@ public class HtmlSkill : IAgentTool
// 6. newline → <br>
text = text.Replace("\n", "<br>");
// 7. 보존된 <br> 플레이스홀더를 복원
text = text.Replace("\x00BR\x00", "<br>");
return text;
}
@@ -685,17 +699,18 @@ public class HtmlSkill : IAgentTool
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
var gradient = cover.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
var coverTitle = cover.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? fallbackTitle : fallbackTitle;
var subtitle = cover.SafeTryGetProperty("subtitle", out var sub) ? sub.SafeGetString() ?? "" : "";
var author = cover.SafeTryGetProperty("author", out var auth) ? auth.SafeGetString() ?? "" : "";
var date = cover.SafeTryGetProperty("date", out var dt) ? dt.SafeGetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
var gradient = cover.SafeTryGetProperty("gradient", out var grad) ? grad.SafeGetString() : null;
var styleAttr = "";
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
{
var colors = gradient.Split(',');
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
if (colors.Length >= 2)
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
}
var sb = new StringBuilder();

View File

@@ -58,11 +58,11 @@ public class HttpTool : IAgentTool
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
return ToolResult.Fail("사내모드에서는 HTTP 도구 실행이 차단됩니다. operationMode=external에서만 사용할 수 있습니다.");
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
var url = args.GetProperty("url").GetString() ?? "";
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
var method = (args.SafeGetProperty("method")?.SafeGetString() ?? "GET").ToUpperInvariant();
var url = args.SafeGetProperty("url")?.SafeGetString() ?? "";
var body = args.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
var headers = args.SafeTryGetProperty("headers", out var h) ? h.SafeGetString() ?? "" : "";
var timeout = args.SafeTryGetProperty("timeout", out var t) ? Math.Min(t.SafeGetInt32(30), 120) : 30;
// 보안: 허용된 호스트만
if (!IsAllowedHost(url))
@@ -77,8 +77,8 @@ public class HttpTool : IAgentTool
if (!string.IsNullOrEmpty(headers))
{
using var headerDoc = JsonDocument.Parse(headers);
foreach (var prop in headerDoc.RootElement.EnumerateObject())
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
foreach (var prop in headerDoc.RootElement.SafeEnumerateObject())
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.SafeGetString());
}
// 본문 설정

View File

@@ -52,10 +52,10 @@ public class ImageAnalyzeTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var imagePath = args.GetProperty("image_path").GetString() ?? "";
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var imagePath = args.GetProperty("image_path").SafeGetString() ?? "";
var task = args.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "describe" : "describe";
var question = args.SafeTryGetProperty("question", out var qEl) ? qEl.SafeGetString() ?? "" : "";
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -88,9 +88,9 @@ public class ImageAnalyzeTool : IAgentTool
// 비교 모드: 두 번째 이미지
string? compareBase64 = null;
string? compareMime = null;
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
if (task == "compare" && args.SafeTryGetProperty("compare_path", out var cpEl))
{
var comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
var comparePath = FileReadTool.ResolvePath(cpEl.SafeGetString() ?? "", context.WorkFolder);
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
{
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);

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)
{
var action = args.GetProperty("action").GetString() ?? "";
var json = args.GetProperty("json").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var json = args.GetProperty("json").SafeGetString() ?? "";
try
{
return Task.FromResult(action switch
{
"validate" => Validate(json),
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
"format" => Format(json, args.SafeTryGetProperty("minify", out var m) && m.SafeGetString() == "true"),
"query" => Query(json, args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""),
"keys" => Keys(json),
"convert" => Convert(json, args.TryGetProperty("target_format", out var tf) ? tf.GetString() ?? "csv" : "csv"),
"convert" => Convert(json, args.SafeTryGetProperty("target_format", out var tf) ? tf.SafeGetString() ?? "csv" : "csv"),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
@@ -122,7 +122,7 @@ public class JsonTool : IAgentTool
}
else
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Key, out var prop))
if (current.ValueKind != JsonValueKind.Object || !current.SafeTryGetProperty(segment.Key, out var prop))
return ToolResult.Fail($"Key '{segment.Key}' not found");
current = prop;
}
@@ -130,7 +130,7 @@ public class JsonTool : IAgentTool
var value = current.ValueKind switch
{
JsonValueKind.String => current.GetString() ?? "",
JsonValueKind.String => current.SafeGetString() ?? "",
JsonValueKind.Number => current.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
@@ -192,9 +192,9 @@ public class JsonTool : IAgentTool
{
var values = allKeys.Select(k =>
{
if (!item.TryGetProperty(k, out var v)) return "\"\"";
if (!item.SafeTryGetProperty(k, out var v)) return "\"\"";
return v.ValueKind == JsonValueKind.String
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
? $"\"{v.SafeGetString()?.Replace("\"", "\"\"") ?? ""}\""
: v.GetRawText();
});
sb.AppendLine(string.Join(",", values));

View File

@@ -57,10 +57,10 @@ public class LspTool : IAgentTool, IDisposable
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
var line = args.SafeTryGetProperty("line", out var l) ? l.GetInt32() : 0;
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");

View File

@@ -57,30 +57,30 @@ public class MarkdownSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
// path 미제공 시 title에서 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
var baseTitle = (args.TryGetProperty("title", out var autoT) ? autoT.GetString() : null) ?? "document";
var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document";
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
}
var title = args.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -109,7 +109,7 @@ public class MarkdownSkill : IAgentTool
foreach (var prop in frontEl.EnumerateObject())
{
var val = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? ""
? prop.Value.SafeGetString() ?? ""
: prop.Value.ToString();
// 값에 특수 문자가 있으면 인용
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
@@ -143,8 +143,8 @@ public class MarkdownSkill : IAgentTool
// 섹션 렌더링
foreach (var section in sectionsEl.EnumerateArray())
{
if (!section.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString()?.ToLowerInvariant() ?? "";
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
switch (type)
{
@@ -187,7 +187,7 @@ public class MarkdownSkill : IAgentTool
if (useToc)
{
// content에서 헤딩 파싱하여 TOC 생성
var raw = contentEl.GetString() ?? "";
var raw = contentEl.SafeGetString() ?? "";
var headings = ParseHeadingsFromContent(raw);
if (headings.Count > 0)
{
@@ -195,7 +195,7 @@ public class MarkdownSkill : IAgentTool
sb.AppendLine();
}
}
sb.Append(contentEl.GetString() ?? "");
sb.Append(contentEl.SafeGetString() ?? "");
}
// ── 파일 쓰기 ──────────────────────────────────────────────────
@@ -221,9 +221,9 @@ public class MarkdownSkill : IAgentTool
var list = new List<(int, string)>();
foreach (var s in sections.EnumerateArray())
{
if (!s.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrEmpty(text))
list.Add((Math.Clamp(level, 1, 6), text));
}
@@ -273,15 +273,15 @@ public class MarkdownSkill : IAgentTool
private static void RenderHeading(StringBuilder sb, JsonElement s)
{
var level = s.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
sb.AppendLine($"{new string('#', level)} {text}");
sb.AppendLine();
}
private static void RenderParagraph(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
@@ -291,12 +291,12 @@ public class MarkdownSkill : IAgentTool
private static void RenderTable(StringBuilder sb, JsonElement s)
{
if (!s.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return;
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return;
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.GetString() ?? "")).ToList();
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList();
if (headers.Count == 0) return;
// 헤더 행
@@ -322,14 +322,14 @@ public class MarkdownSkill : IAgentTool
private static void RenderList(StringBuilder sb, JsonElement s)
{
if (!s.TryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
return;
var ordered = s.TryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
int idx = 1;
foreach (var item in itemsEl.EnumerateArray())
{
var text = item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : item.ToString();
var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString();
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
var prefix = ordered ? $"{idx++}. " : "- ";
if (text.StartsWith(" ") || text.StartsWith("\t"))
@@ -342,8 +342,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderCallout(StringBuilder sb, JsonElement s)
{
var style = s.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
// 표준 GitHub/Obsidian 콜아웃 형식
sb.AppendLine($"> [!{style}]");
@@ -354,8 +354,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderCode(StringBuilder sb, JsonElement s)
{
var lang = s.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
sb.AppendLine($"```{lang}");
sb.AppendLine(code);
@@ -365,8 +365,8 @@ public class MarkdownSkill : IAgentTool
private static void RenderQuote(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
foreach (var line in text.Split('\n'))
sb.AppendLine($"> {line}");

View File

@@ -32,8 +32,8 @@ public class MathTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var expression = args.GetProperty("expression").GetString() ?? "";
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
var expression = args.GetProperty("expression").SafeGetString() ?? "";
var precision = args.SafeTryGetProperty("precision", out var p) ? p.GetInt32() : 6;
if (string.IsNullOrWhiteSpace(expression))
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));

View File

@@ -29,8 +29,8 @@ public class McpListResourcesTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var serverName = args.ValueKind == JsonValueKind.Object &&
args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
args.SafeTryGetProperty("server_name", out var serverProp)
? serverProp.SafeGetString() ?? ""
: "";
var clients = _getClients()

View File

@@ -35,13 +35,13 @@ public class McpReadResourceTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (args.ValueKind != JsonValueKind.Object ||
!args.TryGetProperty("uri", out var uriProp) ||
string.IsNullOrWhiteSpace(uriProp.GetString()))
!args.SafeTryGetProperty("uri", out var uriProp) ||
string.IsNullOrWhiteSpace(uriProp.SafeGetString()))
return ToolResult.Fail("uri가 필요합니다.");
var uri = uriProp.GetString()!;
var serverName = args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
var uri = uriProp.SafeGetString()!;
var serverName = args.SafeTryGetProperty("server_name", out var serverProp)
? serverProp.SafeGetString() ?? ""
: "";
var clients = _getClients()

View File

@@ -57,7 +57,7 @@ public class McpTool : IAgentTool
{
arguments[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString()!,
JsonValueKind.String => prop.Value.SafeGetString()!,
JsonValueKind.Number => prop.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,

View File

@@ -51,9 +51,9 @@ public class MemoryTool : IAgentTool
memoryService.Load(context.WorkFolder);
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
return Task.FromResult(action switch
{
@@ -70,8 +70,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var type = args.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "fact" : "fact";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("content가 필요합니다.");
@@ -87,7 +87,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
{
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
@@ -150,7 +150,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
{
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("id가 필요합니다.");
@@ -161,8 +161,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(content))
@@ -176,8 +176,8 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(query))
@@ -191,7 +191,7 @@ public class MemoryTool : IAgentTool
private static ToolResult ExecuteShowScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");

View File

@@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent;
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
public class MultiReadTool : IAgentTool
{
private const int MaxFiles = 20;
private const int MaxFiles = 8;
private const int DefaultMaxLines = 300;
private const int HardMaxLines = 2000;
@@ -51,7 +51,7 @@ public class MultiReadTool : IAgentTool
{
// --- Parse parameters ---
var maxLines = DefaultMaxLines;
if (args.TryGetProperty("max_lines", out var mlEl))
if (args.SafeTryGetProperty("max_lines", out var mlEl))
{
maxLines = mlEl.GetInt32();
if (maxLines <= 0) maxLines = DefaultMaxLines;
@@ -60,23 +60,23 @@ public class MultiReadTool : IAgentTool
// offset is 1-based; convert to 0-based skip count
var offsetParam = 1;
if (args.TryGetProperty("offset", out var offEl))
if (args.SafeTryGetProperty("offset", out var offEl))
{
offsetParam = offEl.GetInt32();
if (offsetParam < 1) offsetParam = 1;
}
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
var showEncoding = args.TryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
// --- Validate paths array ---
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
var rawPaths = new List<string>();
foreach (var p in pathsEl.EnumerateArray())
{
var s = p.GetString();
var s = p.SafeGetString();
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
}

View File

@@ -45,9 +45,9 @@ public class NotifyTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.GetProperty("title").GetString() ?? "알림";
var message = args.GetProperty("message").GetString() ?? "";
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
var title = args.GetProperty("title").SafeGetString() ?? "알림";
var message = args.GetProperty("message").SafeGetString() ?? "";
var level = args.SafeTryGetProperty("level", out var lv) ? lv.SafeGetString() ?? "info" : "info";
try
{
@@ -140,14 +140,13 @@ public class NotifyTool : IAgentTool
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
grid.Children.Add(toast);
// 5초 후 자동 제거 (페이드 아웃)
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
timer.Tick += (_, _) =>
// 5초 후 자동 제거 — DispatcherTimer 대신 애니메이션 Completed 사용하여 타이머 누적 방지
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300))
{
timer.Stop();
grid.Children.Remove(toast);
BeginTime = TimeSpan.FromSeconds(5),
};
timer.Start();
fadeOut.Completed += (_, _) => grid.Children.Remove(toast);
toast.BeginAnimation(System.Windows.UIElement.OpacityProperty, fadeOut);
}
}
}

View File

@@ -28,7 +28,7 @@ public class OpenExternalTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
if (string.IsNullOrWhiteSpace(rawPath))
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));

View File

@@ -63,9 +63,9 @@ public class PlaybookTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
if (!args.SafeTryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
var action = actionEl.SafeGetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
@@ -84,8 +84,8 @@ public class PlaybookTool : IAgentTool
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name))
return ToolResult.Fail("플레이북 name이 필요합니다.");
@@ -94,11 +94,11 @@ public class PlaybookTool : IAgentTool
// steps 파싱
var steps = new List<string>();
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
{
foreach (var step in stepsEl.EnumerateArray())
{
var s = step.GetString();
var s = step.SafeGetString();
if (!string.IsNullOrWhiteSpace(s))
steps.Add(s);
}
@@ -109,11 +109,11 @@ public class PlaybookTool : IAgentTool
// tools_used 파싱
var toolsUsed = new List<string>();
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tool in toolsEl.EnumerateArray())
{
var t = tool.GetString();
var t = tool.SafeGetString();
if (!string.IsNullOrWhiteSpace(t))
toolsUsed.Add(t);
}
@@ -192,10 +192,10 @@ public class PlaybookTool : IAgentTool
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
var playbook = await FindPlaybookById(playbookDir, id, ct);
if (playbook == null)
@@ -222,10 +222,10 @@ public class PlaybookTool : IAgentTool
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
if (!Directory.Exists(playbookDir))
return ToolResult.Fail("저장된 플레이북이 없습니다.");

View File

@@ -298,12 +298,12 @@ public class PptxSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
var presTitle = args.SafeTryGetProperty("title", out var tt) ? tt.SafeGetString() ?? "Presentation" : "Presentation";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.GetString()!;
path = pathEl.SafeGetString()!;
}
else
{
@@ -311,10 +311,10 @@ public class PptxSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
}
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
var aspect = args.TryGetProperty("aspect", out var asp) ? asp.GetString() ?? "widescreen" : "widescreen";
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("slides 배열이 필요합니다.");
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
@@ -333,10 +333,10 @@ public class PptxSkill : IAgentTool
// ── 테마 결정 우선순위: theme_file > custom_colors > theme 이름 ──────
FullTheme fullTheme;
if (args.TryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.GetString()))
if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
{
// 기존 PPTX에서 테마 색상 추출 → default layout (professional)
var tfPath = FileReadTool.ResolvePath(tfEl.GetString()!, context.WorkFolder);
var tfPath = FileReadTool.ResolvePath(tfEl.SafeGetString()!, context.WorkFolder);
var extracted = ExtractThemeFromPptx(tfPath);
var baseLayout = FullThemes["professional"].Layout;
fullTheme = extracted != null
@@ -344,12 +344,12 @@ public class PptxSkill : IAgentTool
: FullThemes["professional"];
}
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
&& args.TryGetProperty("custom_colors", out var ccEl)
&& args.SafeTryGetProperty("custom_colors", out var ccEl)
&& ccEl.ValueKind == JsonValueKind.Object)
{
// 사용자 지정 색상 → default layout (professional)
static string Hex(JsonElement obj, string key, string fallback) =>
obj.TryGetProperty(key, out var v) ? (v.GetString() ?? fallback).TrimStart('#') : fallback;
obj.SafeTryGetProperty(key, out var v) ? (v.SafeGetString() ?? fallback).TrimStart('#') : fallback;
var customColors = new ThemeColors(
Primary: Hex(ccEl, "primary", "1F4E79"),
Accent: Hex(ccEl, "accent", "2E75B6"),
@@ -441,7 +441,7 @@ public class PptxSkill : IAgentTool
foreach (var slideEl in slidesEl.EnumerateArray())
{
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
var layout = slideEl.SafeTryGetProperty("layout", out var lay) ? lay.SafeGetString() ?? "content" : "content";
var slidePart = presPart.AddNewPart<SlidePart>();
slidePart.AddPart(layoutPart);
@@ -480,11 +480,11 @@ public class PptxSkill : IAgentTool
}
// 발표자 노트
if (slideEl.TryGetProperty("notes", out var notesEl) &&
if (slideEl.SafeTryGetProperty("notes", out var notesEl) &&
notesEl.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(notesEl.GetString()))
!string.IsNullOrWhiteSpace(notesEl.SafeGetString()))
{
AddNotesSlide(slidePart, notesEl.GetString()!);
AddNotesSlide(slidePart, notesEl.SafeGetString()!);
}
slidePart.Slide.Save();
@@ -928,11 +928,11 @@ public class PptxSkill : IAgentTool
private static void BuildTableSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
{
var title = Str(s, "title");
var headers = s.TryGetProperty("headers", out var hEl)
? hEl.EnumerateArray().Select(x => x.GetString() ?? "").ToList()
var headers = s.SafeTryGetProperty("headers", out var hEl)
? hEl.EnumerateArray().Select(x => x.SafeGetString() ?? "").ToList()
: new List<string>();
var rows = s.TryGetProperty("rows", out var rEl)
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.GetString() ?? "").ToList()).ToList()
var rows = s.SafeTryGetProperty("rows", out var rEl)
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
: new List<List<string>>();
const long M = 450000;
@@ -1233,8 +1233,8 @@ public class PptxSkill : IAgentTool
// ══════════════════════════════════════════════════════════════════════════
private static string Str(JsonElement e, string key)
=> e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? ""
=> e.SafeTryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.SafeGetString() ?? ""
: "";
private static void AddNotesSlide(SlidePart slidePart, string notes)

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)
{
if (!args.TryGetProperty("command", out var cmdEl))
if (!args.SafeTryGetProperty("command", out var cmdEl))
return ToolResult.Fail("command가 필요합니다.");
var command = cmdEl.GetString() ?? "";
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
var command = cmdEl.SafeGetString() ?? "";
var shell = args.SafeTryGetProperty("shell", out var sh) ? sh.SafeGetString() ?? "cmd" : "cmd";
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
if (string.IsNullOrWhiteSpace(command))
return ToolResult.Fail("명령이 비어 있습니다.");

View File

@@ -53,10 +53,10 @@ public class ProjectRuleTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
var section = args.SafeTryGetProperty("section", out var s) ? s.SafeGetString() ?? "" : "";
var ruleName = args.SafeTryGetProperty("rule_name", out var rn) ? rn.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");

View File

@@ -56,11 +56,11 @@ public class RegexTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var pattern = args.GetProperty("pattern").GetString() ?? "";
var text = args.GetProperty("text").GetString() ?? "";
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var text = args.GetProperty("text").SafeGetString() ?? "";
var replacement = args.SafeTryGetProperty("replacement", out var r) ? r.SafeGetString() ?? "" : "";
var flags = args.SafeTryGetProperty("flags", out var f) ? f.SafeGetString() ?? "" : "";
try
{

View File

@@ -42,8 +42,8 @@ public class SkillManagerTool : IAgentTool
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var skillName = args.SafeTryGetProperty("skill_name", out var s) ? s.SafeGetString() ?? "" : "";
return action switch
{

View File

@@ -59,9 +59,9 @@ public class SnippetRunnerTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var language = args.GetProperty("language").GetString() ?? "";
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
var language = args.GetProperty("language").SafeGetString() ?? "";
var code = args.SafeTryGetProperty("code", out var c) ? c.SafeGetString() ?? "" : "";
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
if (string.IsNullOrWhiteSpace(code))
return ToolResult.Fail("code가 비어 있습니다.");

View File

@@ -50,8 +50,8 @@ public class SqlTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var dbPath = args.GetProperty("db_path").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var dbPath = args.GetProperty("db_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(dbPath))
dbPath = Path.Combine(context.WorkFolder, dbPath);
@@ -86,10 +86,10 @@ public class SqlTool : IAgentTool
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for query action");
var sql = sqlProp.GetString() ?? "";
var sql = sqlProp.SafeGetString() ?? "";
// SELECT만 허용
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
@@ -97,8 +97,8 @@ public class SqlTool : IAgentTool
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
var maxRows = args.TryGetProperty("max_rows", out var mr) && int.TryParse(mr.GetString(), out var mrv)
? Math.Min(mrv, 1000) : 100;
var maxRows = args.SafeTryGetProperty("max_rows", out var mr)
? Math.Min(mr.SafeGetInt32(100), 1000) : 100;
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
@@ -135,10 +135,10 @@ public class SqlTool : IAgentTool
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for execute action");
var sql = sqlProp.GetString() ?? "";
var sql = sqlProp.SafeGetString() ?? "";
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
var trimmed = sql.TrimStart().ToUpperInvariant();

View File

@@ -44,8 +44,8 @@ public class SubAgentTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
@@ -72,11 +72,15 @@ public class SubAgentTool : IAgentTool
StartedAt = DateTime.Now,
};
// P2: 부모 취소 토큰 연동 — 부모 에이전트 중지 시 자식도 즉시 취소
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
subTask.Cts = cts;
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
{
try
{
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
@@ -89,6 +93,20 @@ public class SubAgentTool : IAgentTool
Timestamp = DateTime.Now,
});
}
catch (OperationCanceledException)
{
subTask.Result = "Cancelled: parent agent was stopped.";
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = $"Sub-agent '{id}' cancelled.",
Result = subTask.Result,
Timestamp = DateTime.Now,
});
}
catch (Exception ex)
{
subTask.Result = $"Error: {ex.Message}";
@@ -106,8 +124,9 @@ public class SubAgentTool : IAgentTool
finally
{
subTask.CompletedAt = DateTime.Now;
cts.Dispose();
}
}, CancellationToken.None);
}, cts.Token);
lock (_lock)
_activeTasks[id] = subTask;
@@ -125,7 +144,7 @@ public class SubAgentTool : IAgentTool
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct)
{
var settings = CreateSubAgentSettings(parentContext);
using var llm = new LlmService(settings);
@@ -150,7 +169,7 @@ public class SubAgentTool : IAgentTool
}
};
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
var finalText = await loop.RunAsync(messages, ct).ConfigureAwait(false);
var eventSummary = SummarizeEvents(loop.Events);
var sb = new StringBuilder();
@@ -451,16 +470,16 @@ public class WaitAgentsTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
List<string>? ids = null;
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = idsEl.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Select(x => x.SafeGetString() ?? "")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
var completedOnly = args.SafeTryGetProperty("completed_only", out var completedEl) &&
completedEl.ValueKind == JsonValueKind.True;
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
@@ -477,6 +496,7 @@ public class SubAgentTask
public bool Success { get; set; }
public string? Result { get; set; }
public Task? RunTask { get; set; }
public CancellationTokenSource? Cts { get; set; }
}
public enum SubAgentRunStatus

View File

@@ -40,16 +40,16 @@ public class SuggestActionsTool : IAgentTool
{
try
{
if (!args.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
if (!args.SafeTryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
var actions = new List<Dictionary<string, string>>();
foreach (var item in actionsEl.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
var label = item.SafeTryGetProperty("label", out var l) ? l.SafeGetString() ?? "" : "";
var command = item.SafeTryGetProperty("command", out var c) ? c.SafeGetString() ?? "" : "";
var icon = item.SafeTryGetProperty("icon", out var i) ? i.SafeGetString() ?? "" : "";
var priority = item.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
if (string.IsNullOrWhiteSpace(label))
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
@@ -76,7 +76,7 @@ public class SuggestActionsTool : IAgentTool
if (actions.Count < 1 || actions.Count > 5)
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
var contextSummary = args.TryGetProperty("context", out var ctx) ? ctx.GetString() ?? "" : "";
var contextSummary = args.SafeTryGetProperty("context", out var ctx) ? ctx.SafeGetString() ?? "" : "";
// 구조화된 JSON 응답 생성
var result = new Dictionary<string, object>

View File

@@ -23,14 +23,14 @@ public sealed class TaskCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.TryGetProperty("title", out var titleEl) ? (titleEl.GetString() ?? "").Trim() : "";
var title = args.SafeTryGetProperty("title", out var titleEl) ? (titleEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(title))
return Task.FromResult(ToolResult.Fail("title is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
var description = args.SafeTryGetProperty("description", out var descEl) ? (descEl.SafeGetString() ?? "").Trim() : "";
var priority = args.SafeTryGetProperty("priority", out var priEl) ? (priEl.SafeGetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
if (!TaskBoardStore.IsValidPriority(priority))
priority = "medium";

View File

@@ -22,7 +22,7 @@ public sealed class TaskGetTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));

View File

@@ -25,7 +25,7 @@ public sealed class TaskListTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var status = args.TryGetProperty("status", out var statusEl) ? (statusEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
var status = args.SafeTryGetProperty("status", out var statusEl) ? (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
if (!string.IsNullOrWhiteSpace(status))
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();

View File

@@ -24,19 +24,19 @@ public sealed class TaskOutputTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (!args.TryGetProperty("output", out var outEl))
if (!args.SafeTryGetProperty("output", out var outEl))
return Task.FromResult(ToolResult.Fail("output is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var output = (outEl.GetString() ?? "").Trim();
var output = (outEl.SafeGetString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(output))
return Task.FromResult(ToolResult.Fail("output is empty."));
var append = !args.TryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
var append = !args.SafeTryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)

View File

@@ -22,13 +22,13 @@ public sealed class TaskStopTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = idEl.GetInt32();
var reason = args.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : "";
var reason = args.SafeTryGetProperty("reason", out var reasonEl) ? (reasonEl.SafeGetString() ?? "").Trim() : "";
var tasks = TaskBoardStore.Load(context.WorkFolder);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null)

View File

@@ -54,7 +54,7 @@ public class TaskTrackerTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
try
{
@@ -75,8 +75,8 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
{
var extStr = args.TryGetProperty("extensions", out var e)
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
var extStr = args.SafeTryGetProperty("extensions", out var e)
? e.SafeGetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
: ".cs,.py,.js,.ts,.java,.cpp,.c";
var exts = new HashSet<string>(
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
@@ -128,11 +128,11 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult AddTask(JsonElement args, AgentContext context)
{
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(title))
return ToolResult.Fail("'title'이 필요합니다.");
var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
var priority = args.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
var tasks = LoadTasks(context);
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
@@ -167,7 +167,7 @@ public class TaskTrackerTool : IAgentTool
private static ToolResult MarkDone(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return ToolResult.Fail("'id'가 필요합니다.");
var id = idEl.GetInt32();

View File

@@ -26,7 +26,7 @@ public sealed class TaskUpdateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("id", out var idEl))
if (!args.SafeTryGetProperty("id", out var idEl))
return Task.FromResult(ToolResult.Fail("id is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
@@ -37,27 +37,27 @@ public sealed class TaskUpdateTool : IAgentTool
if (task == null)
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
if (args.TryGetProperty("status", out var statusEl))
if (args.SafeTryGetProperty("status", out var statusEl))
{
var status = (statusEl.GetString() ?? "").Trim().ToLowerInvariant();
var status = (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidStatus(status))
return Task.FromResult(ToolResult.Fail("Invalid status."));
task.Status = status;
}
if (args.TryGetProperty("title", out var titleEl))
if (args.SafeTryGetProperty("title", out var titleEl))
{
var title = (titleEl.GetString() ?? "").Trim();
var title = (titleEl.SafeGetString() ?? "").Trim();
if (!string.IsNullOrWhiteSpace(title))
task.Title = title;
}
if (args.TryGetProperty("description", out var descEl))
task.Description = (descEl.GetString() ?? "").Trim();
if (args.SafeTryGetProperty("description", out var descEl))
task.Description = (descEl.SafeGetString() ?? "").Trim();
if (args.TryGetProperty("priority", out var priEl))
if (args.SafeTryGetProperty("priority", out var priEl))
{
var priority = (priEl.GetString() ?? "").Trim().ToLowerInvariant();
var priority = (priEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
if (!TaskBoardStore.IsValidPriority(priority))
return Task.FromResult(ToolResult.Fail("Invalid priority."));
task.Priority = priority;

View File

@@ -20,13 +20,13 @@ public sealed class TeamCreateTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("name is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var role = args.TryGetProperty("role", out var r) ? (r.GetString() ?? "worker").Trim() : "worker";
var role = args.SafeTryGetProperty("role", out var r) ? (r.SafeGetString() ?? "worker").Trim() : "worker";
var members = TeamStore.Load(context.WorkFolder);
var member = new TeamStore.Member { Name = name, Role = role };
members.Add(member);

View File

@@ -24,8 +24,8 @@ public sealed class TeamDeleteTool : IAgentTool
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
return Task.FromResult(ToolResult.Fail("id or name is required."));

View File

@@ -51,25 +51,25 @@ public class TemplateRenderTool : IAgentTool
{
// 템플릿 텍스트 로드
string template;
if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString()))
if (args.SafeTryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.SafeGetString()))
{
var templatePath = FileReadTool.ResolvePath(tpEl.GetString()!, context.WorkFolder);
var templatePath = FileReadTool.ResolvePath(tpEl.SafeGetString()!, context.WorkFolder);
if (!context.IsPathAllowed(templatePath))
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
if (!File.Exists(templatePath))
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
}
else if (args.TryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.GetString()))
else if (args.SafeTryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.SafeGetString()))
{
template = ttEl.GetString()!;
template = ttEl.SafeGetString()!;
}
else
{
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
}
if (!args.TryGetProperty("variables", out var varsEl))
if (!args.SafeTryGetProperty("variables", out var varsEl))
return ToolResult.Fail("variables가 필요합니다.");
try
@@ -78,9 +78,9 @@ public class TemplateRenderTool : IAgentTool
var rendered = Render(template, varsEl);
// 출력
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
if (args.SafeTryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.SafeGetString()))
{
var outputPath = FileReadTool.ResolvePath(opEl.GetString()!, context.WorkFolder);
var outputPath = FileReadTool.ResolvePath(opEl.SafeGetString()!, context.WorkFolder);
if (context.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
if (!context.IsPathAllowed(outputPath))
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
@@ -117,7 +117,7 @@ public class TemplateRenderTool : IAgentTool
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return "";
if (!variables.SafeTryGetProperty(key, out var val)) return "";
if (val.ValueKind == JsonValueKind.Array)
{
@@ -167,11 +167,11 @@ public class TemplateRenderTool : IAgentTool
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return body;
if (!variables.SafeTryGetProperty(key, out var val)) return body;
if (val.ValueKind == JsonValueKind.False ||
val.ValueKind == JsonValueKind.Null ||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.GetString())))
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.SafeGetString())))
return body;
return "";
@@ -188,11 +188,11 @@ public class TemplateRenderTool : IAgentTool
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
{
var key = match.Groups[1].Value;
if (!variables.TryGetProperty(key, out var val)) return match.Value;
if (!variables.SafeTryGetProperty(key, out var val)) return match.Value;
return val.ValueKind switch
{
JsonValueKind.String => val.GetString() ?? "",
JsonValueKind.String => val.SafeGetString() ?? "",
JsonValueKind.Number => val.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",

View File

@@ -184,7 +184,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 16px; padding: 56px 52px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; }
@@ -220,7 +220,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 8px; padding: 48px;
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
border-top: 4px solid #1e3a5f; }
@@ -260,7 +260,7 @@ public static class TemplateService
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95);
.container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
h1 { font-size: 30px; font-weight: 700;
@@ -338,7 +338,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 860px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 4px; padding: 56px 52px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border: 1px solid #e8e4dd; }
@@ -377,7 +377,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #161b22;
.container { max-width: 1080px; margin: 0 auto; background: #161b22;
border-radius: 12px; padding: 52px;
border: 1px solid #30363d;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
@@ -418,7 +418,7 @@ public static class TemplateService
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 20px; padding: 52px;
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
@@ -457,7 +457,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0;
.container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
border-bottom: 3px solid #ff6600; }
@@ -499,7 +499,7 @@ public static class TemplateService
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 2px; padding: 0; overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);

View File

@@ -46,7 +46,7 @@ public class TestLoopTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
return action switch
{
@@ -60,7 +60,7 @@ public class TestLoopTool : IAgentTool
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
{
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
@@ -146,7 +146,7 @@ public class TestLoopTool : IAgentTool
private static ToolResult AnalyzeTestOutput(JsonElement args)
{
var output = args.TryGetProperty("test_output", out var o) ? o.GetString() ?? "" : "";
var output = args.SafeTryGetProperty("test_output", out var o) ? o.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(output))
return ToolResult.Fail("test_output이 필요합니다.");

View File

@@ -60,12 +60,12 @@ public class TextSummarizeTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
var style = args.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "bullet" : "bullet";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
var source = args.GetProperty("source").SafeGetString() ?? "";
var maxLength = args.SafeTryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
var style = args.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "bullet" : "bullet";
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
var focus = args.SafeTryGetProperty("focus", out var focEl) ? focEl.SafeGetString() ?? "" : "";
var bySections = args.SafeTryGetProperty("sections", out var secEl) && secEl.GetBoolean();
string text;

View File

@@ -31,7 +31,7 @@ public sealed class TodoWriteTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var actionEl) ? (actionEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
var action = args.SafeTryGetProperty("action", out var actionEl) ? (actionEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
if (string.IsNullOrWhiteSpace(action))
return Task.FromResult(ToolResult.Fail("action is required."));
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
@@ -49,7 +49,7 @@ public sealed class TodoWriteTool : IAgentTool
private static ToolResult Add(string todoPath, JsonElement args)
{
var text = args.TryGetProperty("text", out var textEl) ? (textEl.GetString() ?? "").Trim() : "";
var text = args.SafeTryGetProperty("text", out var textEl) ? (textEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("text is required for add.");
@@ -86,7 +86,7 @@ public sealed class TodoWriteTool : IAgentTool
{
if (!File.Exists(todoPath))
return ToolResult.Fail("TODO file does not exist.");
if (!args.TryGetProperty("index", out var indexEl))
if (!args.SafeTryGetProperty("index", out var indexEl))
return ToolResult.Fail("index is required for done.");
var targetIndex = indexEl.GetInt32();

View File

@@ -31,12 +31,12 @@ public sealed class ToolSearchTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var query = args.TryGetProperty("query", out var queryEl) ? (queryEl.GetString() ?? "").Trim() : "";
var query = args.SafeTryGetProperty("query", out var queryEl) ? (queryEl.SafeGetString() ?? "").Trim() : "";
if (string.IsNullOrWhiteSpace(query))
return Task.FromResult(ToolResult.Fail("query is required."));
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
var includeDescription = args.TryGetProperty("include_description", out var descEl)
var limit = args.SafeTryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
var includeDescription = args.SafeTryGetProperty("include_description", out var descEl)
&& descEl.ValueKind == JsonValueKind.True;
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)

View File

@@ -38,15 +38,15 @@ public class UserAskTool : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var question = args.GetProperty("question").GetString() ?? "";
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : "";
var question = args.GetProperty("question").SafeGetString() ?? "";
var defaultVal = args.SafeTryGetProperty("default_value", out var dv) ? dv.SafeGetString() ?? "" : "";
var options = new List<string>();
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
if (args.SafeTryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
{
foreach (var o in opts.EnumerateArray())
{
var s = o.GetString();
var s = o.SafeGetString();
if (!string.IsNullOrEmpty(s)) options.Add(s);
}
}

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)
{
var action = args.GetProperty("action").GetString() ?? "";
var xmlStr = args.TryGetProperty("xml", out var x) ? x.GetString() ?? "" : "";
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
var expression = args.TryGetProperty("expression", out var ex) ? ex.GetString() ?? "" : "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var xmlStr = args.SafeTryGetProperty("xml", out var x) ? x.SafeGetString() ?? "" : "";
var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
var expression = args.SafeTryGetProperty("expression", out var ex) ? ex.SafeGetString() ?? "" : "";
try
{

View File

@@ -49,8 +49,8 @@ public class ZipTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var zipPath = args.GetProperty("zip_path").GetString() ?? "";
var action = args.GetProperty("action").SafeGetString() ?? "";
var zipPath = args.GetProperty("zip_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(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)
{
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");
var sourcePath = sp.GetString() ?? "";
var sourcePath = sp.SafeGetString() ?? "";
if (!Path.IsPathRooted(sourcePath))
sourcePath = Path.Combine(context.WorkFolder, sourcePath);
@@ -107,8 +107,8 @@ public class ZipTool : IAgentTool
if (!File.Exists(zipPath))
return ToolResult.Fail($"Zip file not found: {zipPath}");
var destPath = args.TryGetProperty("dest_path", out var dp)
? dp.GetString() ?? "" : "";
var destPath = args.SafeTryGetProperty("dest_path", out var dp)
? dp.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(destPath))
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
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;
}

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>
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)
=> 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)
{
if (session == null)

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
@@ -114,34 +115,34 @@ public class McpClientService : IDisposable
try
{
if (result.Value.TryGetProperty("tools", out var toolsArr))
if (result.Value.SafeTryGetProperty("tools", out var toolsArr))
{
foreach (var tool in toolsArr.EnumerateArray())
{
var def = new McpToolDefinition
{
Name = tool.GetProperty("name").GetString() ?? "",
Description = tool.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
Name = tool.GetProperty("name").SafeGetString() ?? "",
Description = tool.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
ServerName = _config.Name,
};
if (tool.TryGetProperty("inputSchema", out var schema) &&
schema.TryGetProperty("properties", out var props))
if (tool.SafeTryGetProperty("inputSchema", out var schema) &&
schema.SafeTryGetProperty("properties", out var props))
{
foreach (var prop in props.EnumerateObject())
{
def.Parameters[prop.Name] = new McpParameterDef
{
Type = prop.Value.TryGetProperty("type", out var t) ? t.GetString() ?? "string" : "string",
Description = prop.Value.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "",
Type = prop.Value.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "string" : "string",
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())
{
var reqName = req.GetString();
var reqName = req.SafeGetString();
if (reqName != null && def.Parameters.TryGetValue(reqName, out var p))
p.Required = true;
}
@@ -167,16 +168,16 @@ public class McpClientService : IDisposable
try
{
if (result.Value.TryGetProperty("resources", out var resourcesArr))
if (result.Value.SafeTryGetProperty("resources", out var resourcesArr))
{
foreach (var resource in resourcesArr.EnumerateArray())
{
_resources.Add(new McpResourceDefinition
{
Uri = resource.TryGetProperty("uri", out var uri) ? uri.GetString() ?? "" : "",
Name = resource.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
Description = resource.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
MimeType = resource.TryGetProperty("mimeType", out var mime) ? mime.GetString() ?? "" : "",
Uri = resource.SafeTryGetProperty("uri", out var uri) ? uri.SafeGetString() ?? "" : "",
Name = resource.SafeTryGetProperty("name", out var name) ? name.SafeGetString() ?? "" : "",
Description = resource.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
MimeType = resource.SafeTryGetProperty("mimeType", out var mime) ? mime.SafeGetString() ?? "" : "",
ServerName = _config.Name,
});
}
@@ -201,18 +202,18 @@ public class McpClientService : IDisposable
try
{
if (result.Value.TryGetProperty("content", out var contentArr))
if (result.Value.SafeTryGetProperty("content", out var contentArr))
{
var sb = new StringBuilder();
foreach (var item in contentArr.EnumerateArray())
{
if (item.TryGetProperty("text", out var text))
sb.AppendLine(text.GetString());
if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.SafeGetString());
}
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}";
}
@@ -235,15 +236,15 @@ public class McpClientService : IDisposable
try
{
if (result.Value.TryGetProperty("contents", out var contentsArr))
if (result.Value.SafeTryGetProperty("contents", out var contentsArr))
{
var sb = new StringBuilder();
foreach (var item in contentsArr.EnumerateArray())
{
if (item.TryGetProperty("text", out var text))
sb.AppendLine(text.GetString());
else if (item.TryGetProperty("uri", out var itemUri))
sb.AppendLine($"uri: {itemUri.GetString()}");
if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.SafeGetString());
else if (item.SafeTryGetProperty("uri", out var itemUri))
sb.AppendLine($"uri: {itemUri.SafeGetString()}");
}
return sb.ToString().TrimEnd();
}
@@ -285,14 +286,14 @@ public class McpClientService : IDisposable
var root = doc.RootElement;
// 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;
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}");
return null;
}

View File

@@ -42,10 +42,30 @@ public partial class ChatWindow
/// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다.
/// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지).
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
/// </summary>
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));
}

View File

@@ -181,9 +181,9 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth();
var stack = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0),
Margin = new Thickness(48, 1, 12, 1),
};
var liveWaitingStyle = evt.Type == AgentEventType.Thinking
@@ -400,13 +400,12 @@ public partial class ChatWindow
return new Border
{
Background = cardBackground,
BorderBrush = cardBorder,
BorderThickness = new Thickness(1),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(12, 6, 12, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0, 4, 0, 4),
Margin = new Thickness(0, 1, 12, 1),
HorizontalAlignment = HorizontalAlignment.Left,
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)
UpdateProgressBar(evt);
@@ -1204,13 +1227,12 @@ public partial class ChatWindow
var bannerMaxWidth = GetMessageMaxWidth();
var banner = new Border
{
Background = hintBg,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(9, 7, 9, 7),
Margin = new Thickness(12, 3, 12, 3),
HorizontalAlignment = HorizontalAlignment.Center,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 5, 14, 5),
Margin = new Thickness(48, 1, 12, 1),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = bannerMaxWidth,
};
if (!string.IsNullOrWhiteSpace(evt.RunId))

View File

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

View File

@@ -16,6 +16,165 @@ public partial class ChatWindow
private const int ConversationPageSize = 50;
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()
{
var metas = _storage.LoadAllMeta();
@@ -66,20 +225,29 @@ public partial class ChatWindow
};
}).ToList();
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
items = items.Where(i =>
i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)
string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)
&& (i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase))
).ToList();
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
_runningConversationCount = items.Count(i => i.IsRunning);
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
// Count를 한 번의 루프로 계산 (3번 순회 → 1번)
int failedCount = 0, runningCount = 0, spotlightCount = 0;
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();
UpdateConversationRunningFilterUi();
UpdateConversationQuickStripUi();
@@ -137,6 +305,7 @@ public partial class ChatWindow
private void RenderConversationList(List<ConversationMeta> items)
{
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear();
_pendingConversations = null;
@@ -211,6 +380,8 @@ public partial class ChatWindow
Padding = new Thickness(8, 10, 8, 10),
Margin = new Thickness(6, 4, 6, 4),
HorizontalAlignment = HorizontalAlignment.Stretch,
// Tag를 설정하지 않아 이벤트 위임 대상에서 제외됨 (별도 핸들러)
Tag = new LoadMoreTag(),
};
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
stack.Children.Add(new TextBlock
@@ -238,6 +409,7 @@ public partial class ChatWindow
var all = _pendingConversations;
_pendingConversations = null;
_lastHoveredConvBorder = null;
ConversationPanel.Children.Clear();
string? lastGroup = null;
@@ -256,6 +428,9 @@ public partial class ChatWindow
ConversationPanel.Children.Add(btn);
}
/// <summary>"더 보기" 버튼의 Tag — ConversationItemTag와 구분하여 이벤트 위임에서 무시.</summary>
private sealed class LoadMoreTag;
private static string GetConversationDateGroup(DateTime updatedAt)
{
var today = DateTime.Today;
@@ -440,85 +615,15 @@ public partial class ChatWindow
border.Child = grid;
var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF));
border.MouseEnter += (_, _) =>
// A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장, 부모에서 처리
border.Tag = new ConversationItemTag
{
if (!isSelected)
border.Background = hoverBg;
catBtn.Visibility = Visibility.Visible;
};
border.MouseLeave += (_, _) =>
{
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);
Id = item.Id,
IsSelected = isSelected,
TitleBlock = title,
TitleColor = titleColor,
CatButton = catBtn,
HoverBackground = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)),
};
ConversationPanel.Children.Add(border);

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