diff --git a/README.md b/README.md index 0cc5cad..5bb7739 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,13 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 17:46 (KST) +- 도구 이름과 내부설정의 결합도를 낮추기 위해 `AgentToolCatalog` 기반의 중앙 메타데이터 레이어를 추가했습니다. 이제 레거시 도구 이름(`git`, `lsp`, `zip`, `project_rule` 등)은 canonical id로 정규화되어 런타임, 권한, 병렬 분류, 설정 화면이 같은 이름 체계를 사용합니다. +- 설정 화면도 같은 카탈로그를 보도록 정리했습니다. Agent 설정/일반 설정의 도구 카드와 훅 편집기는 canonical 이름을 기준으로 표시하고 저장하며, 기존 `disabledTools`, 훅 대상 도구, 도구 권한 키는 호환 alias를 통해 자동 흡수합니다. +- 스킬/헬프 설명도 현재 구조에 맞게 완화했습니다. 스킬은 기본 폴더와 추가 폴더를 함께 로드하는 흐름으로 설명을 갱신했고, 직접 호출 스킬과 런타임 정책 연결 스킬을 같이 보여주도록 문구를 다듬었습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_toolcat\\ -p:IntermediateOutputPath=obj\\verify_toolcat\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\\verify_toolcat_tests\\ -p:IntermediateOutputPath=obj\\verify_toolcat_tests\\` 통과 8 + - 업데이트: 2026-04-12 23:58 (KST) - Cowork 문서 생성이 단조로워지고 마지막 요약이 약해진 부분을 다시 보강했습니다. 문서 태스크 가이드에 반복적인 동일 패턴 섹션 작성을 피하고, 목적에 따라 요약/핵심 발견/비교표/타임라인/권고안/부록 같은 richer section pattern을 쓰도록 다시 유도했습니다. - Cowork 시스템 프롬프트도 같은 기준으로 조정했습니다. 새 문서 생성 시 filler paragraph 대신 bullets, tables, structured comparison을 적극적으로 쓰게 하고, 최종 응답에는 생성 후 점검 항목까지 포함한 구조화 요약을 남기도록 복원했습니다. diff --git a/build.bat b/build.bat index e0ddc32..a819f14 100644 --- a/build.bat +++ b/build.bat @@ -162,35 +162,35 @@ del /q "%~1\*.runtimeconfig.json" 2>nul exit /b 0 :fail_dist -echo [FAILED] dist ??€????밴쉐 ??쎈솭 +echo [FAILED] dist 폴더 생성 실패 goto :end_fail :fail_app -echo [FAILED] main app publish ??쎈솭 +echo [FAILED] main app publish 실패 goto :end_fail :fail_obfuscation -echo [FAILED] obfuscation ??m€???쎈솭 +echo [FAILED] obfuscation 단계 실패 goto :end_fail :fail_encryptor -echo [FAILED] AxKeyEncryptor publish ??쎈솭 +echo [FAILED] AxKeyEncryptor publish 실패 goto :end_fail :fail_payload -echo [FAILED] payload.zip ??밴쉐 ??쎈솭 +echo [FAILED] payload.zip 생성 실패 goto :end_fail :fail_installer -echo [FAILED] installer build ??쎈솭 +echo [FAILED] installer build 실패 goto :end_fail :fail_installer_copy -echo [FAILED] installer exe 癰귣벊沅???쎈솭 +echo [FAILED] installer exe 복사 실패 goto :end_fail :fail_running -echo [FAILED] running AX Copilot process could not be stopped cleanly +echo [FAILED] 실행 중인 AX Copilot 프로세스를 종료할 수 없습니다 goto :end_fail :end_fail diff --git a/dist/AxCopilot/Assets/SearchEngines/duckduckgo.png b/dist/AxCopilot/Assets/SearchEngines/duckduckgo.png deleted file mode 100644 index 64ee943..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/duckduckgo.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/github.png b/dist/AxCopilot/Assets/SearchEngines/github.png deleted file mode 100644 index 52126e0..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/github.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/google.png b/dist/AxCopilot/Assets/SearchEngines/google.png deleted file mode 100644 index 8642fc1..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/google.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/namuwiki.png b/dist/AxCopilot/Assets/SearchEngines/namuwiki.png deleted file mode 100644 index 5423adf..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/namuwiki.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/naver.png b/dist/AxCopilot/Assets/SearchEngines/naver.png deleted file mode 100644 index 020e16d..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/naver.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/navermap.png b/dist/AxCopilot/Assets/SearchEngines/navermap.png deleted file mode 100644 index 9e0bfaf..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/navermap.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/wikipedia.png b/dist/AxCopilot/Assets/SearchEngines/wikipedia.png deleted file mode 100644 index 4405b8d..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/wikipedia.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/SearchEngines/youtube.png b/dist/AxCopilot/Assets/SearchEngines/youtube.png deleted file mode 100644 index c20e1d3..0000000 Binary files a/dist/AxCopilot/Assets/SearchEngines/youtube.png and /dev/null differ diff --git a/dist/AxCopilot/Assets/guide_dev.enc b/dist/AxCopilot/Assets/guide_dev.enc deleted file mode 100644 index 86e411f..0000000 Binary files a/dist/AxCopilot/Assets/guide_dev.enc and /dev/null differ diff --git a/dist/AxCopilot/Assets/guide_user.enc b/dist/AxCopilot/Assets/guide_user.enc deleted file mode 100644 index 1f3f25b..0000000 Binary files a/dist/AxCopilot/Assets/guide_user.enc and /dev/null differ diff --git a/dist/AxCopilot/AxCopilot.exe b/dist/AxCopilot/AxCopilot.exe deleted file mode 100644 index e8bfa6b..0000000 Binary files a/dist/AxCopilot/AxCopilot.exe and /dev/null differ diff --git a/dist/AxCopilot/skills/adr-writer.skill.md b/dist/AxCopilot/skills/adr-writer.skill.md deleted file mode 100644 index 1d2feef..0000000 --- a/dist/AxCopilot/skills/adr-writer.skill.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: adr-writer -label: 아키텍처 결정 기록 (ADR) -description: 아키텍처 결정 사항을 표준 ADR 형식으로 문서화합니다. -icon: \uE82D -allowed-tools: - - file_read - - file_write - - folder_map - - grep - - search_codebase -tabs: code ---- - -아키텍처 결정 사항을 ADR(Architecture Decision Record) 형식으로 문서화하세요. - -## ADR이란? -소프트웨어 아키텍처에서 내린 중요한 결정의 배경, 대안, 근거를 기록하는 경량 문서입니다. -미래의 팀원이 "왜 이렇게 결정했는지"를 이해할 수 있게 합니다. - -## 워크플로우 - -1. **결정 사항 확인**: 사용자에게 다음을 파악 - - 어떤 결정을 내렸는가 (또는 내려야 하는가) - - 관련 코드/시스템 영역 - - 고려한 대안들 -2. **코드 분석** (선택): 관련 코드 구조를 읽어 현재 상태 파악 -3. **ADR 작성**: 표준 형식으로 문서 생성 -4. **파일 저장**: `docs/adr/` 폴더에 번호 형식으로 저장 - -## ADR 표준 형식 - -```markdown -# ADR-[번호]: [결정 제목] - -**상태**: 제안됨 | 승인됨 | 폐기됨 | 대체됨 -**날짜**: YYYY-MM-DD -**결정자**: [이름/팀] - -## 맥락 (Context) -어떤 상황에서 이 결정이 필요한가? -- 기술적 배경 -- 비즈니스 요구사항 -- 제약 조건 - -## 결정 (Decision) -무엇을 결정했는가? -- 선택한 방안의 구체적 내용 -- 적용 범위 - -## 대안 (Alternatives) - -### 대안 1: [이름] -- 장점: ... -- 단점: ... -- 비용/복잡도: ... - -### 대안 2: [이름] -- 장점: ... -- 단점: ... -- 비용/복잡도: ... - -## 근거 (Rationale) -왜 이 결정을 선택했는가? -- 대안 대비 장점 -- 트레이드오프 분석 -- 참고 자료/벤치마크 - -## 결과 (Consequences) - -### 긍정적 -- ... - -### 부정적 -- ... - -### 리스크 -- ... - -## 관련 문서 -- ADR-[관련번호]: [제목] -- [외부 참고 링크] -``` - -## 파일 명명 규칙 -- 위치: `docs/adr/` (없으면 생성) -- 파일명: `ADR-NNNN-제목-요약.md` (예: `ADR-0001-데이터베이스-선택.md`) -- 번호: 기존 ADR 파일 조회 후 자동 부여 - -## 규칙 -- 결정의 "왜"를 중심으로 작성 (코드를 읽으면 "무엇"은 알 수 있음) -- 대안은 최소 2개 이상 제시 -- 트레이드오프를 솔직하게 기록 (완벽한 선택은 없음) -- 짧고 명확하게 (1~2페이지 이내) -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/api-docs.skill.md b/dist/AxCopilot/skills/api-docs.skill.md deleted file mode 100644 index 0e72bde..0000000 --- a/dist/AxCopilot/skills/api-docs.skill.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: api-docs -label: API 문서 생성 -description: 코드에서 API 엔드포인트를 분석하여 마크다운/HTML API 문서를 자동 생성합니다. -icon: \uE8A1 -allowed-tools: - - folder_map - - grep - - file_read - - file_write - - search_codebase -tabs: code ---- - -작업 폴더의 소스 코드를 분석하여 API 문서를 생성하세요. - -## 분석 대상 -- REST API 엔드포인트 (Controller, Route 어노테이션) -- 함수/메서드 시그니처 및 주석 -- 요청/응답 모델 (DTO, Schema) -- 인증/권한 요구사항 - -## 작업 절차 -1. `folder_map` — 프로젝트 구조 파악 -2. `grep` — API 엔드포인트 패턴 검색 (`[HttpGet]`, `@GetMapping`, `router.get`, `@app.route` 등) -3. `file_read` — 컨트롤러/라우터 파일 분석 -4. `grep` — 요청/응답 모델 클래스 검색 -5. `file_read` — 모델 구조 분석 -6. `file_write` — API 문서 생성 - -## 출력 형식 -마크다운으로 작성하되 다음 구조를 따르세요: - -``` -# API 문서 - -## 개요 -- Base URL, 인증 방식, 공통 헤더 - -## 엔드포인트 - -### [POST] /api/users -- **설명**: 사용자 생성 -- **인증**: Bearer Token 필요 -- **요청 본문**: - | 필드 | 타입 | 필수 | 설명 | - |------|------|------|------| - | name | string | ✓ | 사용자 이름 | -- **응답**: 201 Created - ```json - { "id": 1, "name": "..." } - ``` -- **에러 코드**: 400, 401, 409 -``` - -## 주의사항 -- 코드에서 실제 확인된 내용만 문서화하세요. 추측하지 마세요. -- 주석이나 Swagger/OpenAPI 어노테이션이 있으면 우선 활용하세요. -- 인증, 페이징, 에러 처리 등 공통 패턴은 별도 섹션으로 정리하세요. diff --git a/dist/AxCopilot/skills/batch-rename.skill.md b/dist/AxCopilot/skills/batch-rename.skill.md deleted file mode 100644 index b81115e..0000000 --- a/dist/AxCopilot/skills/batch-rename.skill.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: batch-rename -label: 파일 일괄 이름 변경 -description: 패턴 매칭, 번호 붙이기, 날짜 추가 등 파일 이름을 일괄 변경합니다. -icon: \uE8AC -tabs: cowork -allowed-tools: - - folder_map - - file_read - - file_manage - - clipboard_tool ---- - -파일 이름을 규칙에 따라 일괄 변경하세요. 변경 전 반드시 미리보기를 제공합니다. - -## 작업 절차 - -1. **대상 파일 스캔**: folder_map으로 작업 폴더의 파일 목록을 수집 -2. **이름 변경 규칙 확인**: 사용자에게 다음 옵션을 확인 - - 대상 파일 필터 (확장자, 이름 패턴) - - 변경 규칙 (아래 지원 규칙 참조) - - 적용 순서 (이름순, 날짜순, 크기순) -3. **변경 미리보기 생성**: 변경 전/후 이름을 표로 표시 - ``` - | # | 현재 이름 | 변경 후 이름 | - |---|----------------------|----------------------| - | 1 | IMG_20260101_001.jpg | 2026-01-01_001.jpg | - | 2 | IMG_20260101_002.jpg | 2026-01-01_002.jpg | - ``` -4. **사용자 확인**: 미리보기를 보여주고 진행 여부를 확인 -5. **일괄 변경 실행**: file_manage로 파일 이름을 순차 변경 -6. **결과 보고**: 변경 성공/실패 건수와 상세 내역을 안내 - -## 지원 이름 변경 규칙 - -### 패턴 치환 -- **문자열 치환**: "IMG_" → "사진_" -- **정규식 치환**: `(\d{4})(\d{2})(\d{2})` → `$1-$2-$3` -- **대소문자 변환**: 소문자, 대문자, 타이틀 케이스 - -### 번호 붙이기 -- **순번 추가**: `문서_001.pdf`, `문서_002.pdf`, ... -- **시작 번호**: 사용자 지정 (기본: 1) -- **자릿수**: 자동 계산 (파일 수 기준) -- **위치**: 접두사 또는 접미사 - -### 날짜 추가 -- **오늘 날짜**: `보고서_2026-03-30.docx` -- **파일 수정일**: 파일의 실제 수정 날짜 사용 -- **날짜 형식**: YYYY-MM-DD, YYYYMMDD, YY.MM.DD - -### 정리 -- **공백 처리**: 공백 → 언더스코어/하이픈 -- **특수문자 제거**: 파일명에서 특수문자 제거 -- **확장자 변경**: `.jpeg` → `.jpg` -- **접두사/접미사 추가 또는 제거** - -## 충돌 처리 -- 변경 후 이름이 이미 존재하면 자동으로 번호 추가 (`_1`, `_2`) -- 충돌 건은 미리보기에서 ⚠️ 표시로 경고 -- 원본 파일 덮어쓰기 절대 금지 - -## 규칙 -- **미리보기 없이 직접 변경하지 않음** — 반드시 미리보기 후 사용자 확인 -- 하위 폴더 포함 여부는 사용자에게 확인 -- 숨김 파일(.으로 시작)은 기본 제외 -- 변경 실패 시 이미 변경된 파일은 원복하지 않으므로, 중요한 경우 백업 권장 -- 한 번에 1,000개 이상의 파일 변경 시 경고 - -한국어로 안내하세요. diff --git a/dist/AxCopilot/skills/changelog.skill.md b/dist/AxCopilot/skills/changelog.skill.md deleted file mode 100644 index bcd4195..0000000 --- a/dist/AxCopilot/skills/changelog.skill.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: changelog -label: 변경 이력 / 릴리즈 노트 -description: Git 커밋 이력에서 자동으로 변경 이력과 릴리즈 노트를 생성합니다. -icon: \uE81C -allowed-tools: - - git_tool - - file_read - - file_write - - html_create - - text_summarize -tabs: code ---- - -Git 커밋 이력을 분석하여 변경 이력(CHANGELOG) 또는 릴리즈 노트를 생성하세요. - -## 워크플로우 - -1. **이력 조회**: git_tool로 커밋 로그 수집 - - 지정 기간 또는 태그 간 커밋 - - 커밋 메시지 + 변경 파일 목록 -2. **분류**: 커밋을 Conventional Commits 기준으로 분류 -3. **Breaking Change 감지**: 시그니처 변경, API 삭제 등 감지 -4. **문서 생성**: Markdown 또는 HTML로 출력 - -## Conventional Commits 분류 - -| 접두사 | 분류 | 설명 | -|--------|------|------| -| feat | ✨ 신기능 | 새로운 기능 추가 | -| fix | 🐛 버그 수정 | 버그 수정 | -| docs | 📝 문서 | 문서 변경 | -| style | 💄 스타일 | 코드 포맷팅 (동작 변경 없음) | -| refactor | ♻️ 리팩토링 | 코드 리팩토링 | -| perf | ⚡ 성능 | 성능 개선 | -| test | ✅ 테스트 | 테스트 추가/수정 | -| chore | 🔧 기타 | 빌드, 설정 변경 | -| BREAKING | 💥 Breaking | 하위 호환성 깨지는 변경 | - -## 출력 형식 - -### CHANGELOG.md -```markdown -# Changelog - -## [1.6.0] - 2026-03-30 - -### ✨ 신기능 -- 멀티패스 문서 생성 엔진 (#123) -- PPT 네이티브 생성 도구 - -### 🐛 버그 수정 -- 탭 전환 시 대화 유실 문제 해결 - -### 💥 Breaking Changes -- 없음 - -### 📝 문서 -- 개발자 가이드 v1.6.0 업데이트 -``` - -### 릴리즈 노트 (HTML) -사용자 친화적인 형식: -- 주요 변경사항 (스크린샷 포함 가능) -- 개선 사항 -- 알려진 이슈 -- 업그레이드 가이드 - -## 규칙 -- Conventional Commits 형식이 아닌 커밋도 내용 분석으로 분류 -- 중복/사소한 커밋은 병합하여 요약 -- Breaking Change는 반드시 별도 섹션으로 강조 -- 이슈 번호가 있으면 링크 포함 -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/code-scaffold.skill.md b/dist/AxCopilot/skills/code-scaffold.skill.md deleted file mode 100644 index d5e12e0..0000000 --- a/dist/AxCopilot/skills/code-scaffold.skill.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: code-scaffold -label: 코드 스캐폴딩 -description: 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 자동 생성합니다. -icon: \uE943 -allowed-tools: - - folder_map - - file_read - - grep - - file_write - - search_codebase -tabs: code ---- - -작업 폴더의 프로젝트 구조를 분석하고 새 기능의 코드 뼈대를 생성하세요. - -다음 도구를 사용하세요: -1. folder_map — 프로젝트 구조 파악 -2. file_read — 기존 코드 패턴 분석 -3. grep — 코딩 컨벤션 확인 -4. file_write — 새 파일 생성 - -작업 순서: -1. 프로젝트 타입 감지 (언어, 프레임워크, 빌드 시스템) -2. 기존 코드 패턴 분석 (네이밍, 폴더 구조, 임포트 스타일) -3. 사용자 요청에 맞는 코드 뼈대 생성 - -생성 항목: -- 클래스/모듈 파일 (프로젝트 컨벤션에 맞춰) -- 인터페이스/타입 정의 -- 단위 테스트 파일 -- 필요한 설정/구성 파일 - -규칙: -- 기존 프로젝트의 코딩 스타일을 따르세요 -- TODO 주석으로 구현이 필요한 부분을 표시하세요 -- 한국어 주석을 추가하세요 diff --git a/dist/AxCopilot/skills/commit-review.skill.md b/dist/AxCopilot/skills/commit-review.skill.md deleted file mode 100644 index ce3f9c5..0000000 --- a/dist/AxCopilot/skills/commit-review.skill.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: commit-review -label: 커밋 메시지 리뷰 -description: Git 커밋 메시지를 Conventional Commits 기준으로 검토하고 개선을 제안합니다. -icon: \uE8CB -allowed-tools: - - process - - git_tool - - file_read - - text_summarize -tabs: code ---- - -작업 폴더의 최근 Git 커밋 메시지를 검토하고 개선안을 제시하세요. - -## 작업 절차 -1. `process` — `git log --oneline -20` 으로 최근 커밋 목록 확인 -2. `process` — `git log --format="%H%n%s%n%b%n---" -10` 으로 상세 메시지 확인 -3. `process` — `git diff HEAD~1` 으로 최신 커밋 변경 내용 확인 (필요 시) -4. 각 커밋을 Conventional Commits 기준으로 분석 -5. 결과를 정리하여 출력 - -## Conventional Commits 규칙 -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -### type 종류 -- `feat`: 새 기능 -- `fix`: 버그 수정 -- `docs`: 문서 변경 -- `style`: 포맷팅 (코드 동작 변경 없음) -- `refactor`: 리팩토링 -- `perf`: 성능 개선 -- `test`: 테스트 추가/수정 -- `chore`: 빌드/도구 설정 -- `ci`: CI 설정 변경 - -## 검토 항목 -1. **type 적절성**: 변경 내용과 type이 일치하는가 -2. **설명 품질**: 50자 이내, 명령형, 명확한 내용 -3. **본문 유무**: 복잡한 변경에 "왜" 설명이 있는가 -4. **일관성**: 팀 내 커밋 스타일이 통일되어 있는가 -5. **Breaking Change**: BREAKING CHANGE 푸터 또는 ! 표기 - -## 출력 형식 -``` -## 커밋 메시지 리뷰 결과 - -### 전체 요약 -- 검토 커밋: N개 -- 규칙 준수: N개 ✓ / 위반: N개 ✗ - -### 개별 리뷰 - -#### abc1234 "Fix login bug" -- ❌ type 없음 → `fix: resolve login authentication failure` -- ❌ 본문 없음 → 원인과 해결 방법 추가 권장 - -#### def5678 "feat: add user profile page" -- ✅ Conventional Commits 준수 -- 💡 scope 추가 권장: `feat(profile): add user profile page` -``` - -## 주의사항 -- 비판이 아닌 건설적 제안을 하세요. -- 팀의 기존 컨벤션이 있으면 그것을 우선 존중하세요. -- 개선된 메시지 예시를 항상 함께 제시하세요. diff --git a/dist/AxCopilot/skills/compare.skill.md b/dist/AxCopilot/skills/compare.skill.md deleted file mode 100644 index 1143dac..0000000 --- a/dist/AxCopilot/skills/compare.skill.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: compare -label: 비교 분석표 -description: 2개 이상 항목의 비교 분석 매트릭스를 생성합니다. -icon: \uE9D5 -allowed-tools: - - html_create - - excel_create - - file_read - - document_read - - chart_create -tabs: all ---- - -사용자가 요청한 항목들을 체계적으로 비교 분석하는 매트릭스를 생성하세요. - -## 워크플로우 - -1. **비교 대상 확인**: 어떤 항목들을 비교할지 파악 - - 제품, 기술, 방안, 서비스, 도구, 프레임워크 등 -2. **비교 기준 설정**: 적절한 비교 축을 설계 - - 기능, 가격, 성능, 사용성, 확장성, 지원, 보안 등 -3. **데이터 수집**: 참고 파일이 있으면 읽어서 반영 -4. **분석표 생성**: HTML 또는 Excel로 비교 매트릭스 생성 -5. **종합 평가**: 총평 + 추천 의견 제시 - -## 비교표 구성 - -### 기본 매트릭스 -| 기준 | 항목 A | 항목 B | 항목 C | -|------|--------|--------|--------| -| 기능1 | ✅ 지원 | ⚠ 일부 | ❌ 미지원 | -| 기능2 | ... | ... | ... | - -### 점수 비교 (radar 차트 활용) -- 각 항목을 1~10점으로 정량화 -- chart_create로 레이더 차트 또는 바 차트 시각화 - -### 종합 평가 -- 장점/단점 요약 -- 상황별 추천 (예: "예산이 제한적이면 A, 확장성이 중요하면 B") - -## 출력 형식 -- **HTML** (권장): 색상 배지, 차트 포함 비주얼 보고서 -- **Excel**: 정량 데이터 + 수식 기반 점수표 - -## 규칙 -- 객관적 사실 기반으로 비교 (주관적 판단은 별도 섹션) -- 각 항목의 강점과 약점을 균형 있게 서술 -- 출처/근거가 있으면 명시 -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/csv-to-xlsx.skill.md b/dist/AxCopilot/skills/csv-to-xlsx.skill.md deleted file mode 100644 index 49a401e..0000000 --- a/dist/AxCopilot/skills/csv-to-xlsx.skill.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: csv-to-xlsx -label: CSV → Excel 변환 -description: CSV 파일을 서식이 완성된 Excel(.xlsx)로 변환합니다. 헤더 고정, 필터, 조건부 서식, 자동 열 너비를 적용합니다. -icon: \uE9F9 -allowed-tools: - - folder_map - - file_read - - file_write - - process - - format_convert - - data_pivot - - template_render -tabs: cowork ---- - -CSV 파일을 전문적인 서식이 적용된 Excel 파일로 변환하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 Python 스크립트 경로로 변환/서식 자동화를 수행하세요. -- Python 불가: `format_convert`로 CSV를 XLSX로 변환하고, `data_pivot`으로 핵심 요약 시트를 구성한 뒤 `file_write`로 사용 안내를 남기세요. - - -## 사전 준비 -먼저 필요한 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요: -``` -process: pip install openpyxl pandas -``` - -## 작업 절차 - -1. **파일 확인**: folder_map으로 작업 폴더에서 CSV 파일을 탐색 -2. **CSV 분석**: file_read로 CSV 파일의 구조(컬럼, 행 수, 인코딩, 구분자) 파악 -3. **변환 옵션 확인**: 사용자에게 다음 옵션을 확인 - - 헤더 행 고정 여부 (기본: 활성) - - 자동 필터 적용 여부 (기본: 활성) - - 조건부 서식 대상 컬럼 (숫자 컬럼 자동 감지) - - 시트 이름 (기본: 파일명) -4. **Python 스크립트 작성**: file_write로 변환 스크립트 생성 -5. **스크립트 실행**: `process`로 Python 스크립트 실행 -6. **결과 확인**: 생성된 .xlsx 파일 경로와 요약 정보를 안내 - -## Python 스크립트 템플릿 -```python -import pandas as pd -from openpyxl import load_workbook -from openpyxl.styles import Font, PatternFill, Alignment, Border, Side -from openpyxl.utils import get_column_letter -from openpyxl.formatting.rule import CellIsRule - -# CSV 읽기 (인코딩 자동 감지) -for enc in ['utf-8', 'cp949', 'euc-kr', 'utf-8-sig']: - try: - df = pd.read_csv('input.csv', encoding=enc) - break - except (UnicodeDecodeError, Exception): - continue - -# Excel 저장 -output_path = 'output.xlsx' -df.to_excel(output_path, index=False, sheet_name='Sheet1') - -# 서식 적용 -wb = load_workbook(output_path) -ws = wb.active - -# 헤더 스타일 -header_font = Font(bold=True, color='FFFFFF', size=11) -header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') -header_align = Alignment(horizontal='center', vertical='center', wrap_text=True) -thin_border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') -) - -for col_idx, cell in enumerate(ws[1], 1): - cell.font = header_font - cell.fill = header_fill - cell.alignment = header_align - cell.border = thin_border - -# 자동 열 너비 -for col_idx in range(1, ws.max_column + 1): - max_length = 0 - col_letter = get_column_letter(col_idx) - for row in ws.iter_rows(min_col=col_idx, max_col=col_idx): - for cell in row: - if cell.value: - max_length = max(max_length, len(str(cell.value))) - ws.column_dimensions[col_letter].width = min(max_length + 4, 50) - -# 헤더 행 고정 (Freeze Panes) -ws.freeze_panes = 'A2' - -# 자동 필터 -ws.auto_filter.ref = ws.dimensions - -# 숫자 컬럼 조건부 서식 (음수 빨강) -for col_idx in range(1, ws.max_column + 1): - col_letter = get_column_letter(col_idx) - sample_values = [ws.cell(row=r, column=col_idx).value for r in range(2, min(ws.max_row + 1, 12))] - if any(isinstance(v, (int, float)) for v in sample_values if v is not None): - cell_range = f'{col_letter}2:{col_letter}{ws.max_row}' - ws.conditional_formatting.add(cell_range, - CellIsRule(operator='lessThan', formula=['0'], - font=Font(color='FF0000'))) - -# 데이터 행 줄무늬 (가독성) -light_fill = PatternFill(start_color='D9E2F3', end_color='D9E2F3', fill_type='solid') -for row_idx in range(2, ws.max_row + 1): - for col_idx in range(1, ws.max_column + 1): - cell = ws.cell(row=row_idx, column=col_idx) - cell.border = thin_border - if row_idx % 2 == 0: - cell.fill = light_fill - -wb.save(output_path) -print(f'변환 완료: {output_path} ({ws.max_row - 1}행 × {ws.max_column}열)') -``` - -## 서식 옵션 -- **헤더 스타일**: 파란 배경 + 흰색 굵은 글씨 + 가운데 정렬 -- **줄무늬**: 짝수 행 연한 파랑 배경 (가독성 향상) -- **열 너비**: 내용 기준 자동 조정 (최대 50) -- **조건부 서식**: 숫자 컬럼 음수 빨강 표시 -- **Freeze Panes**: 헤더 행 고정 -- **Auto Filter**: 전체 컬럼 필터 활성화 - -## 규칙 -- 원본 CSV 파일은 수정하지 않음 -- 인코딩 자동 감지 (UTF-8 → CP949 → EUC-KR 순) -- 대용량 파일 (100,000행 이상) 경고 후 진행 -- 출력 파일명: 원본 파일명 기준 (.csv → .xlsx) - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/data-convert.skill.md b/dist/AxCopilot/skills/data-convert.skill.md deleted file mode 100644 index 43a46bb..0000000 --- a/dist/AxCopilot/skills/data-convert.skill.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: data-convert -label: 데이터 변환기 -description: 데이터 포맷 간 변환, 정제, 필터링, 통계 요약을 수행합니다. -icon: \uE8AB -allowed-tools: - - file_read - - file_write - - json_tool - - csv_create - - data_pivot - - excel_create -tabs: cowork ---- - -데이터 파일의 포맷 변환, 정제, 필터링, 통계 요약을 수행하세요. - -## 지원 변환 - -| 입력 → | JSON | CSV | Excel | Markdown | -|--------|------|-----|-------|----------| -| JSON | — | ✅ | ✅ | ✅ | -| CSV | ✅ | — | ✅ | ✅ | -| Excel | ✅ | ✅ | — | ✅ | -| TSV | ✅ | ✅ | ✅ | ✅ | - -## 워크플로우 - -1. **파일 분석**: 입력 파일의 포맷, 인코딩, 구조 파악 -2. **데이터 정제** (선택): - - 빈 행/열 제거 - - 중복 제거 - - 데이터 타입 정리 (숫자 문자열 → 숫자) - - 결측치 처리 (제거 또는 기본값) -3. **필터링** (선택): - - 조건 기반 행 필터링 - - 필요 컬럼만 추출 -4. **변환**: 대상 포맷으로 변환 -5. **통계 요약**: 기본 통계 제공 - -## 정제 옵션 -- `remove_empty`: 빈 행 제거 -- `remove_duplicates`: 중복 행 제거 -- `trim`: 공백 제거 -- `fill_na`: 결측치 채우기 (값 지정) - -## 출력 -- 변환된 파일 저장 -- 변환 통계 (원본 행 수, 변환 후 행 수, 제거 행 수) -- 컬럼별 기본 통계 (수치 컬럼: 합계, 평균, 최소, 최대) - -## 규칙 -- 원본 파일은 수정하지 않음 (새 파일로 저장) -- 인코딩: UTF-8 기본 (EUC-KR 옵션) -- 대용량 파일 (10MB 이상) 경고 -- 한국어로 안내 diff --git a/dist/AxCopilot/skills/data-visualize-adv.skill.md b/dist/AxCopilot/skills/data-visualize-adv.skill.md deleted file mode 100644 index 17b39ba..0000000 --- a/dist/AxCopilot/skills/data-visualize-adv.skill.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: data-visualize-adv -label: 고급 데이터 시각화 -description: Python matplotlib/seaborn을 사용하여 히트맵, 산점도, 상관관계 등 고급 시각화를 생성합니다. -icon: \uE9D9 -allowed-tools: - - folder_map - - file_read - - file_write - - process - - data_pivot - - chart_create - - template_render -tabs: cowork ---- - -데이터를 고급 시각화 차트로 변환하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 matplotlib/seaborn 경로를 사용하세요. -- Python 불가: `data_pivot`으로 통계를 계산하고 `chart_create` + `template_render` + `file_write`로 HTML/SVG 리포트를 생성하세요. - - -## 사전 준비 -필요한 패키지를 설치하세요: -``` -process: pip install matplotlib seaborn pandas numpy -``` - -## 작업 절차 -1. **데이터 확인**: 사용자가 제공한 CSV/JSON/Excel 데이터 파일 확인 -2. **데이터 로드**: pandas로 데이터 읽기 -3. **Python 스크립트 작성**: file_write로 시각화 스크립트 생성 -4. **스크립트 실행**: `process`로 실행 -5. **결과 확인**: 생성된 차트 이미지 경로를 사용자에게 안내 - -## 시각화 유형별 템플릿 - -### 공통 설정 -```python -import matplotlib.pyplot as plt -import seaborn as sns -import pandas as pd -import numpy as np - -# 한글 폰트 설정 -plt.rcParams['font.family'] = 'Malgun Gothic' -plt.rcParams['axes.unicode_minus'] = False -sns.set_theme(style='whitegrid', font='Malgun Gothic') -``` - -### 히트맵 (상관관계 행렬) -```python -df = pd.read_csv('data.csv') -corr = df.select_dtypes(include=[np.number]).corr() - -fig, ax = plt.subplots(figsize=(10, 8)) -sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', - center=0, square=True, linewidths=0.5, ax=ax) -ax.set_title('상관관계 히트맵') -plt.tight_layout() -plt.savefig('heatmap.png', dpi=150) -``` - -### 산점도 (Scatter Plot) -```python -df = pd.read_csv('data.csv') - -fig, ax = plt.subplots(figsize=(10, 8)) -sns.scatterplot(data=df, x='col_x', y='col_y', hue='category', - size='value', sizes=(20, 200), alpha=0.7, ax=ax) -ax.set_title('산점도') -plt.tight_layout() -plt.savefig('scatter.png', dpi=150) -``` - -### 시계열 분석 -```python -df = pd.read_csv('data.csv', parse_dates=['date']) - -fig, ax = plt.subplots(figsize=(12, 6)) -sns.lineplot(data=df, x='date', y='value', hue='category', ax=ax) -ax.set_title('시계열 트렌드') -plt.xticks(rotation=45) -plt.tight_layout() -plt.savefig('timeseries.png', dpi=150) -``` - -### 분포 비교 (박스플롯 + 바이올린) -```python -fig, axes = plt.subplots(1, 2, figsize=(14, 6)) - -sns.boxplot(data=df, x='group', y='value', ax=axes[0]) -axes[0].set_title('박스플롯') - -sns.violinplot(data=df, x='group', y='value', ax=axes[1]) -axes[1].set_title('바이올린 플롯') - -plt.tight_layout() -plt.savefig('distribution.png', dpi=150) -``` - -### 다중 차트 대시보드 -```python -fig, axes = plt.subplots(2, 2, figsize=(14, 12)) - -# 좌상: 히트맵 -sns.heatmap(corr, annot=True, fmt='.1f', ax=axes[0,0]) - -# 우상: 산점도 -sns.scatterplot(data=df, x='x', y='y', ax=axes[0,1]) - -# 좌하: 히스토그램 -sns.histplot(data=df, x='value', kde=True, ax=axes[1,0]) - -# 우하: 박스플롯 -sns.boxplot(data=df, x='group', y='value', ax=axes[1,1]) - -fig.suptitle('데이터 분석 대시보드', fontsize=16, fontweight='bold') -plt.tight_layout() -plt.savefig('dashboard.png', dpi=150) -``` - -### 페어플롯 (변수 간 관계 전체) -```python -g = sns.pairplot(df, hue='category', diag_kind='kde') -g.fig.suptitle('변수 간 관계', y=1.02) -plt.savefig('pairplot.png', dpi=150) -``` - -## 스타일 옵션 -- seaborn 테마: `whitegrid`, `darkgrid`, `white`, `dark`, `ticks` -- 컬러 팔레트: `Set2`, `husl`, `coolwarm`, `RdBu_r`, `viridis` -- 출력 형식: png, svg, pdf -- 해상도: `dpi=150` (기본), `dpi=300` (인쇄용) - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/data-visualize.skill.md b/dist/AxCopilot/skills/data-visualize.skill.md deleted file mode 100644 index be65f8e..0000000 --- a/dist/AxCopilot/skills/data-visualize.skill.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: data-visualize -label: 데이터 시각화 -description: CSV/Excel 데이터를 분석하여 차트가 포함된 HTML 보고서를 생성합니다. -icon: \uE9D9 -allowed-tools: - - folder_map - - file_read - - file_write - - data_pivot - - chart_create - - template_render -tabs: cowork ---- - -작업 폴더의 데이터 파일을 분석하고 시각화 보고서를 생성하세요. - -다음 도구를 사용하세요: -1. folder_map — 데이터 파일 탐색 -2. file_read — CSV/Excel 데이터 읽기 -3. file_write — HTML 시각화 보고서 생성 - -���각화 전략: -1. **데이터 파악**: 컬럼 타입, 결측치, 기본 통계량 확인 -2. **적절한 차트 선택**: - - 시계열 → 라인 차트 - - 비교 → 바 차트 - - 비율 → 파이/도넛 차트 - - 분포 → 히스토그램 - - 상관관계 → 산점도 -3. **HTML 보고서 생성**: 인라인 SVG 또는 CSS 기반 차트 (외부 라이브러리 없이) - -보고서 구성: -## 데이터 요약 -- 기본 통계 테이블 - -## 시각화 -- 데이터 특성에 맞는 2~4개 차트 -- 각 차트에 대한 해석 - -## 인사이트 -- 데이터에서 발견한 주요 패턴 -- 이상치 또는 주목할 포인트 - -한국어로 작성하세요. 차트는 CSS/SVG 기반으로 외부 의존성 없이 생성하세요. diff --git a/dist/AxCopilot/skills/db-schema.skill.md b/dist/AxCopilot/skills/db-schema.skill.md deleted file mode 100644 index e94ab56..0000000 --- a/dist/AxCopilot/skills/db-schema.skill.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: db-schema -label: DB 스키마 분석 -description: 데이터베이스 스키마를 분석하여 ERD 다이어그램과 테이블 문서를 생성합니다. -icon: \uE968 -allowed-tools: - - folder_map - - grep - - file_read - - file_write - - search_codebase -tabs: code ---- - -작업 폴더의 코드에서 데이터베이스 스키마를 분석하고 문서화하세요. - -## 분석 대상 -- ORM 모델/엔티티 클래스 (Entity Framework, SQLAlchemy, Sequelize, JPA 등) -- 마이그레이션 파일 -- SQL DDL 스크립트 (CREATE TABLE) -- 관계 정의 (FK, Navigation Property) - -## 작업 절차 -1. `folder_map` — 프로젝트 구조 파악 -2. `grep` — 엔티티/모델 클래스 검색 (`DbSet`, `@Entity`, `Model.define`, `CREATE TABLE` 등) -3. `file_read` — 모델 파일 분석 (컬럼, 타입, 관계) -4. `grep` — 인덱스, 제약 조건 검색 -5. `file_write` — 스키마 문서 + Mermaid ERD 생성 - -## 출력 형식 - -### 테이블 문서 -각 테이블에 대해: -``` -## Users 테이블 -| 컬럼 | 타입 | Null | 기본값 | 설명 | -|------|------|------|--------|------| -| Id | int | NO | AUTO_INCREMENT | PK | -| Name | nvarchar(100) | NO | - | 사용자 이름 | -| CreatedAt | datetime | NO | GETDATE() | 생성일 | - -- **인덱스**: IX_Users_Name (Name) -- **관계**: Orders (1:N), Profile (1:1) -``` - -### Mermaid ERD -```mermaid -erDiagram - Users ||--o{ Orders : "has" - Users ||--|| Profile : "has" - Users { - int Id PK - string Name - datetime CreatedAt - } - Orders { - int Id PK - int UserId FK - decimal Amount - } -``` - -## 주의사항 -- 코드에서 실제 확인된 스키마만 문서화하세요. -- 관계(1:1, 1:N, N:M)를 정확히 파악하세요. -- 마이그레이션이 있으면 최종 상태를 기준으로 작성하세요. diff --git a/dist/AxCopilot/skills/dependency-audit.skill.md b/dist/AxCopilot/skills/dependency-audit.skill.md deleted file mode 100644 index d62e416..0000000 --- a/dist/AxCopilot/skills/dependency-audit.skill.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: dependency-audit -label: 의존성 분석 -description: 프로젝트 의존성 그래프를 분석하고 보안 취약점, 라이선스, 업데이트 현황을 보고합니다. -icon: \uE964 -allowed-tools: - - file_read - - grep - - folder_map - - glob - - html_create - - process -tabs: code ---- - -프로젝트의 패키지 의존성을 분석하여 보안, 라이선스, 업데이트 보고서를 생성하세요. - -## 워크플로우 - -1. **패키지 파일 탐지**: glob으로 의존성 파일 검색 - - .NET: `*.csproj`, `packages.config`, `Directory.Build.props` - - Node: `package.json`, `package-lock.json`, `yarn.lock` - - Python: `requirements.txt`, `Pipfile`, `pyproject.toml` - - Java: `pom.xml`, `build.gradle` -2. **의존성 목록 추출**: file_read로 파일 파싱 -3. **분석 수행**: 각 의존성에 대해 검사 -4. **보고서 생성**: html_create로 분석 보고서 생성 - -## 분석 항목 - -### 1. 직접 의존성 목록 -| 패키지 | 현재 버전 | 최신 버전 | 업데이트 필요 | -|--------|----------|----------|-------------| -| ... | ... | ... | ✅/⚠/❌ | - -### 2. 보안 취약점 (알려진 패턴) -- 알려진 취약 버전 패턴 탐지 -- 폐기된(deprecated) 패키지 식별 -- 유지보수 중단된 패키지 경고 - -### 3. 라이선스 검사 -| 라이선스 | 호환성 | 패키지 | -|---------|--------|--------| -| MIT | ✅ 허용 | lib-a, lib-b | -| GPL-3.0 | ⚠ 주의 | lib-c | -| 상용 | ❌ 검토 필요 | lib-d | - -### 4. 의존성 크기 분석 -- 패키지별 예상 크기 -- 전체 node_modules / NuGet 캐시 크기 -- 불필요하게 큰 패키지 식별 - -### 5. 중복/충돌 검사 -- 동일 기능 중복 패키지 (예: lodash + underscore) -- 버전 충돌 가능성 - -## 프레임워크별 검사 명령 - -### .NET -``` -dotnet list package --outdated -dotnet list package --vulnerable -``` - -### Node.js -``` -npm audit -npm outdated -``` - -### Python -``` -pip list --outdated -pip-audit -``` - -## 출력 -- HTML 보고서: 위험도별 색상 구분, 차트 포함 -- 요약: 총 패키지 수, 업데이트 필요 수, 보안 이슈 수, 라이선스 경고 수 - -## 규칙 -- 외부 서버 접속 없이 로컬 파일 분석만 수행 -- process 도구 사용 시 `dotnet list` / `npm audit` 등 읽기 전용 명령만 -- 패키지를 직접 업데이트하지 않음 (보고서만) -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/diagram-generator.skill.md b/dist/AxCopilot/skills/diagram-generator.skill.md deleted file mode 100644 index 9c09021..0000000 --- a/dist/AxCopilot/skills/diagram-generator.skill.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: diagram-generator -label: 다이어그램 생성 -description: Python matplotlib/graphviz를 사용하여 플로차트, 시퀀스, ER 다이어그램 등을 생성합니다. -icon: \uE9D9 -allowed-tools: - - file_read - - file_write - - process - - chart_create - - template_render -tabs: cowork ---- - -사용자의 요구에 맞는 다이어그램을 Python으로 생성하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 Graphviz/matplotlib 경로를 사용하세요. -- Python 불가: `template_render`와 `file_write`로 Mermaid 기반 다이어그램 문서를 생성하고, 필요 시 `chart_create`로 대체 시각화를 제공하세요. - - -## 사전 준비 -필요한 패키지를 설치하세요: -``` -process: pip install matplotlib graphviz -``` -시스템에 Graphviz가 설치되어 있어야 합니다 (https://graphviz.org/download/). - -## 작업 절차 -1. **요구사항 파악**: 다이어그램 유형, 노드/관계, 스타일 확인 -2. **Python 스크립트 작성**: file_write로 .py 파일 생성 -3. **스크립트 실행**: `process`로 Python 스크립트 실행 -4. **결과 확인**: 생성된 이미지 파일 경로를 사용자에게 안내 - -## 다이어그램 유형별 템플릿 - -### 플로차트 (Graphviz) -```python -from graphviz import Digraph - -dot = Digraph(comment='Flowchart', format='png') -dot.attr(rankdir='TB', fontname='Malgun Gothic') -dot.attr('node', shape='box', style='rounded,filled', fillcolor='#E8F0FE') - -dot.node('start', '시작', shape='ellipse', fillcolor='#34A853', fontcolor='white') -dot.node('process1', '데이터 수집') -dot.node('decision', '조건 확인?', shape='diamond', fillcolor='#FBBC04') -dot.node('process2', '처리') -dot.node('end', '종료', shape='ellipse', fillcolor='#EA4335', fontcolor='white') - -dot.edge('start', 'process1') -dot.edge('process1', 'decision') -dot.edge('decision', 'process2', label='예') -dot.edge('decision', 'end', label='아니오') -dot.edge('process2', 'end') - -dot.render('flowchart', cleanup=True) -``` - -### 시퀀스 다이어그램 (matplotlib) -```python -import matplotlib.pyplot as plt -import matplotlib.patches as patches - -fig, ax = plt.subplots(1, 1, figsize=(10, 8)) -# 액터 라이프라인, 메시지 화살표 등을 matplotlib으로 직접 그리기 -ax.set_xlim(0, 10) -ax.set_ylim(0, 10) -ax.invert_yaxis() -ax.axis('off') -plt.savefig('sequence.png', dpi=150, bbox_inches='tight') -``` - -### ER 다이어그램 (Graphviz) -```python -from graphviz import Graph - -er = Graph('ER', format='png', engine='neato') -er.attr('node', shape='box', style='filled', fillcolor='#E8F0FE') - -er.node('user', 'User\n─────\nid (PK)\nname\nemail') -er.node('order', 'Order\n─────\nid (PK)\nuser_id (FK)\ntotal') - -er.edge('user', 'order', label='1:N') -er.render('er_diagram', cleanup=True) -``` - -### 조직도 (Graphviz) -```python -from graphviz import Digraph - -org = Digraph(format='png') -org.attr(rankdir='TB') -org.attr('node', shape='box', style='rounded,filled', fillcolor='#E3F2FD') - -org.node('ceo', 'CEO') -org.node('cto', 'CTO') -org.node('cfo', 'CFO') -org.edges([('ceo', 'cto'), ('ceo', 'cfo')]) -org.render('org_chart', cleanup=True) -``` - -## 스타일 옵션 -- 폰트: `fontname='Malgun Gothic'` (한글 지원) -- 색상: HTML 컬러 코드 지원 -- 출력 형식: png, svg, pdf -- 레이아웃 엔진: dot(계층), neato(스프링), circo(원형), fdp(포스) - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/docx-creator.skill.md b/dist/AxCopilot/skills/docx-creator.skill.md deleted file mode 100644 index 4bd15fd..0000000 --- a/dist/AxCopilot/skills/docx-creator.skill.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: docx-creator -label: Word 문서 생성 -description: Python을 사용하여 전문적인 Word 문서(.docx)를 생성합니다. 작업 폴더의 양식 파일을 자동 활용합니다. -icon: \uE8A5 -allowed-tools: - - folder_map - - document_read - - file_read - - file_write - - process - - document_assemble - - format_convert -tabs: cowork ---- - -사용자의 요구에 맞는 전문적인 Word 문서를 Python으로 생성하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 python-docx 경로를 사용하세요. -- Python 불가: `document_assemble`로 문서 본문을 구성하고 `format_convert`로 docx 산출을 시도하세요. 실패 시 Markdown/HTML 결과와 변환 가이드를 함께 제공하세요. - - -## 사전 준비 -먼저 python-docx 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요: -``` -process: pip install python-docx -``` - -## 양식 활용 (템플릿 모드) -작업 폴더에 양식 파일이 있으면 **반드시** 활용하세요: - -1. **양식 탐색**: `folder_map`으로 작업 폴더를 스캔하여 `.docx` 파일 확인 -2. **양식 후보 판별**: - - 파일명에 "양식", "template", "서식", "표준", "기본" 포함 - - 또는 사용자가 명시적으로 "XX 양식으로 작성해줘" 요청 - - 또는 사용자가 특정 .docx 파일명을 언급 -3. **양식 구조 파악**: `document_read`로 양식 파일의 구조(스타일, 헤더, 섹션)를 먼저 확인 -4. **양식 기반 생성**: - ```python - doc = Document('양식_보고서.docx') # ← 빈 Document() 대신 양식 로드 - # 양식의 스타일, 머리글/바닥글, 로고, 페이지 설정이 자동 상속됨 - # 기존 본문 내용을 지우고 새 내용만 추가 - for paragraph in doc.paragraphs: - paragraph.clear() # 기존 내용 제거 - # 또는 필요에 따라 특정 섹션만 교체 - ``` -5. **양식이 없으면**: 아래 기본 템플릿으로 새 문서 생성 - -## 작업 절차 -1. **요구사항 파악**: 사용자가 원하는 문서의 종류, 구조, 내용을 확인 -2. **양식 확인**: folder_map으로 작업 폴더에 양식 .docx 파일이 있는지 확인 -3. **Python 스크립트 작성**: file_write로 .py 파일 생성 -4. **스크립트 실행**: `process`로 Python 스크립트 실행 -5. **결과 확인**: 생성된 .docx 파일 경로를 사용자에게 안내 - -## Python 스크립트 템플릿 -```python -from docx import Document -from docx.shared import Inches, Pt, Cm -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.enum.style import WD_STYLE_TYPE -import os - -# 양식 파일 자동 감지 -template_keywords = ['양식', 'template', '서식', '표준', '기본'] -template_file = None -for f in os.listdir('.'): - if f.endswith('.docx') and any(kw in f.lower() for kw in template_keywords): - template_file = f - break - -# 양식이 있으면 활용, 없으면 새 문서 -if template_file: - doc = Document(template_file) - print(f'양식 활용: {template_file}') -else: - doc = Document() - # 스타일 설정 (양식이 없을 때만) - style = doc.styles['Normal'] - font = style.font - font.name = '맑은 고딕' - font.size = Pt(11) - -# 제목 -doc.add_heading('문서 제목', level=0) - -# 본문 -doc.add_paragraph('내용을 여기에 작성합니다.') - -# 표 -table = doc.add_table(rows=2, cols=3) -table.style = 'Light Grid Accent 1' - -# 저장 -doc.save('output.docx') -``` - -## 지원 기능 -- 제목/소제목 계층 구조 -- 표 (스타일, 병합, 서식) -- 이미지 삽입 -- 머리글/바닥글 -- 페이지 번호 -- 목차 -- 글머리 기호/번호 목록 -- **양식 파일 기반 스타일 상속** (로고, 헤더, 페이지 설정 자동 유지) - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/email-draft.skill.md b/dist/AxCopilot/skills/email-draft.skill.md deleted file mode 100644 index a5c1be2..0000000 --- a/dist/AxCopilot/skills/email-draft.skill.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: email-draft -label: 비즈니스 이메일 작성 -description: 상황과 톤에 맞는 전문적인 비즈니스 이메일 초안을 생성합니다. -icon: \uE715 -allowed-tools: - - clipboard_tool - - file_write -tabs: all ---- - -사용자의 요청에 맞는 전문적인 비즈니스 이메일을 작성하세요. - -## 워크플로우 - -1. 사용자에게 다음 정보를 확인하세요: - - **수신자**: 이름, 직급, 부서 (알고 있다면) - - **목적**: 요청 / 보고 / 안내 / 감사 / 사과 / 협조 / 초대 / 회신 - - **핵심 내용**: 전달할 주요 메시지 (1~3가지) - - **톤**: 공식(格式) / 반공식 / 친근 - - **언어**: 한국어 / 영어 / 일본어 - -2. 이메일 초안을 작성합니다: - - 적절한 인사말로 시작 - - 핵심 내용은 **굵게** 강조 - - 요청 사항은 명확하고 구체적으로 - - 마무리 인사와 서명 포함 - -3. 사용자에게 초안을 보여주고 수정 요청을 받습니다. - -4. 최종본을 clipboard_tool로 클립보드에 복사합니다. - -## 이메일 구조 - -``` -제목: [간결하고 명확한 제목] - -[수신자] 님께, - -[인사말] - -[본문 - 목적과 핵심 내용] - -[요청 사항 또는 다음 단계] - -[마무리 인사] - -[서명] -``` - -## 규칙 -- 한 문단은 3~4문장을 넘지 않도록 간결하게 -- 수동적 표현보다 능동적 표현 선호 -- 약어는 처음 사용 시 풀어 쓰기 -- 긴급도에 따라 제목에 [긴급], [참고] 등 태그 사용 diff --git a/dist/AxCopilot/skills/env-setup.skill.md b/dist/AxCopilot/skills/env-setup.skill.md deleted file mode 100644 index a6445ed..0000000 --- a/dist/AxCopilot/skills/env-setup.skill.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: env-setup -label: 프로젝트 환경 설정 -description: .gitignore, requirements.txt, .editorconfig 등 프로젝트 환경 설정 파일을 자동 생성합니다. -icon: \uE835 -tabs: code -allowed-tools: - - folder_map - - file_read - - file_write - - process ---- - -프로젝트 유형에 맞는 환경 설정 파일을 자동으로 생성하세요. - -## 작업 절차 - -1. **프로젝트 분석**: folder_map으로 프로젝트 구조를 파악하고 유형 판별 - - `.py` 파일 → Python 프로젝트 - - `package.json` 또는 `.js/.ts` 파일 → Node.js 프로젝트 - - `.csproj` 또는 `.sln` 파일 → .NET 프로젝트 - - `pom.xml` 또는 `.java` 파일 → Java 프로젝트 - - 복합 프로젝트인 경우 모든 유형을 병합 -2. **기존 설정 확인**: 이미 존재하는 설정 파일이 있는지 확인 - - 있으면: 내용을 분석하여 누락된 항목만 추가 제안 - - 없으면: 새로 생성 -3. **생성할 파일 목록 제안**: 사용자에게 생성할 파일 목록을 보여주고 확인 -4. **파일 생성**: file_write로 각 설정 파일 생성 -5. **결과 안내**: 생성된 파일 목록과 주요 설정 내용 요약 - -## 프로젝트별 템플릿 - -### Python 프로젝트 -생성 파일: `.gitignore`, `requirements.txt`, `.editorconfig`, `setup.cfg`, `.flake8` - -**.gitignore (Python)**: -``` -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -.venv/ -*.egg-info/ -dist/ -build/ -.eggs/ -*.egg -.mypy_cache/ -.pytest_cache/ -.coverage -htmlcov/ -.env -.idea/ -.vscode/ -*.log -``` - -**requirements.txt**: 프로젝트에서 import 문을 스캔하여 자동 생성 - -### Node.js 프로젝트 -생성 파일: `.gitignore`, `.editorconfig`, `.nvmrc`, `.prettierrc` - -**.gitignore (Node)**: -``` -node_modules/ -dist/ -build/ -.env -.env.local -*.log -npm-debug.log* -.DS_Store -coverage/ -.nyc_output/ -.idea/ -.vscode/ -*.tgz -``` - -### .NET 프로젝트 -생성 파일: `.gitignore`, `.editorconfig`, `Directory.Build.props` - -**.gitignore (.NET)**: -``` -bin/ -obj/ -.vs/ -*.user -*.suo -*.cache -packages/ -*.nupkg -TestResults/ -.idea/ -*.DotSettings.user -``` - -### Java 프로젝트 -생성 파일: `.gitignore`, `.editorconfig` - -**.gitignore (Java)**: -``` -*.class -*.jar -*.war -*.ear -target/ -.gradle/ -build/ -.idea/ -*.iml -.settings/ -.classpath -.project -out/ -``` - -## 공통 .editorconfig -```ini -root = true - -[*] -indent_style = space -indent_size = 4 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false - -[*.{yml,yaml}] -indent_size = 2 - -[*.{json,js,ts,jsx,tsx}] -indent_size = 2 - -[Makefile] -indent_style = tab -``` - -## 규칙 -- 기존 설정 파일이 있으면 덮어쓰지 않고, 누락 항목만 제안 -- .env 파일은 생성하지 않음 (보안 — 사용자가 직접 생성) -- 생성 전 파일 목록을 반드시 사용자에게 확인 -- 프로젝트 루트에 생성 (하위 폴더에 생성하지 않음) - -한국어로 안내하세요. diff --git a/dist/AxCopilot/skills/gen-test.skill.md b/dist/AxCopilot/skills/gen-test.skill.md deleted file mode 100644 index 7440869..0000000 --- a/dist/AxCopilot/skills/gen-test.skill.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: gen-test -label: 테스트 생성기 -description: 지정 파일의 단위 테스트를 자동 생성하고 실행하여 커버리지를 분석합니다. -icon: \uE9D5 -allowed-tools: - - file_read - - file_write - - test_loop - - grep - - folder_map - - dev_env_detect - - build_run -tabs: code ---- - -지정된 소스 파일의 단위 테스트를 자동 생성하고 실행하세요. - -## 워크플로우 - -1. **환경 감지**: dev_env_detect로 프로젝트 타입, 테스트 프레임워크 확인 -2. **대상 분석**: file_read로 테스트 대상 파일 분석 - - 공개 메서드/함수 목록 추출 - - 메서드별 입출력 타입 파악 - - 의존성 확인 (Mock 필요 여부) -3. **테스트 생성**: 각 메서드에 대해 테스트 케이스 작성 - - 정상 케이스 (Happy Path) - - 경계값 (Boundary) - - 예외/에러 케이스 - - null/empty 입력 -4. **실행 및 검증**: test_loop으로 테스트 실행 -5. **결과 보고**: 성공/실패 요약, 커버리지 추정 - -## 테스트 작성 원칙 - -### 명명 규칙 -- C#: `[메서드명]_[시나리오]_[기대결과]` -- Python: `test_[메서드명]_[시나리오]` -- JavaScript: `should [기대 동작] when [조건]` - -### 테스트 구조 (AAA 패턴) -``` -Arrange — 테스트 데이터 준비 -Act — 대상 메서드 실행 -Assert — 결과 검증 -``` - -### 프레임워크별 지원 -- C#: xUnit, NUnit, MSTest -- Python: pytest, unittest -- JavaScript: Jest, Mocha, Vitest -- Java: JUnit 5 - -## 출력 -- 테스트 파일 생성 (프로젝트 컨벤션에 맞는 위치) -- 테스트 실행 결과 요약 -- 커버리지 추정 (메서드별 테스트 유무) - -## 규칙 -- 기존 테스트가 있으면 스타일을 따르기 -- 외부 의존성은 Mock/Stub 사용 -- 테스트 간 독립성 보장 (상태 공유 금지) -- 한국어 주석으로 테스트 의도 설명 diff --git a/dist/AxCopilot/skills/hook-policy-demo.skill.md b/dist/AxCopilot/skills/hook-policy-demo.skill.md deleted file mode 100644 index cfc8d62..0000000 --- a/dist/AxCopilot/skills/hook-policy-demo.skill.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: hook-policy-demo -label: Hook Policy Demo -description: hooks/hook_filters 런타임 정책 적용 예시 스킬입니다. -icon: \uE943 -tabs: code -allowed-tools: - - file_read - - file_edit - - grep - - build_run -when_to_use: 코드 수정 후 특정 훅만 pre/post로 선택 적용하고 싶을 때 -argument-hint: 대상 파일 경로 또는 변경 목적 -context: fork -agent: worker -effort: medium -sample: true -hooks: - file_edit: - pre: - - lint-pre - post: - - verify-post - build_run: - post: - - verify-post -hook_filters: lint-pre@pre@file_edit, verify-post@post@file_edit, verify-post@post@build_run ---- - -이 스킬은 `hooks`/`hook_filters` 정책을 함께 사용하는 예시입니다. - -## 목표 -- 수정 전에는 `lint-pre` 훅만 실행 -- 수정 후/빌드 후에는 `verify-post` 훅만 실행 - -## 실행 가이드 -1. `grep` + `file_read`로 대상 코드와 관련 호출부를 확인합니다. -2. 필요한 최소 범위만 `file_edit`로 수정합니다. -3. 수정 후 `build_run`으로 빌드/테스트를 실행해 검증합니다. -4. 변경 내용/검증 결과/남은 리스크를 간단히 보고합니다. - -## 제약 -- `allowed-tools` 목록 외 도구 호출 금지 -- 불필요한 전면 리팩토링 금지 -- 동일 실패 재시도 전에 원인/대안 먼저 제시 diff --git a/dist/AxCopilot/skills/image-processor.skill.md b/dist/AxCopilot/skills/image-processor.skill.md deleted file mode 100644 index d73f60b..0000000 --- a/dist/AxCopilot/skills/image-processor.skill.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -name: image-processor -label: 이미지 처리 -description: Python Pillow를 사용하여 이미지 리사이즈, 크롭, 워터마크, 포맷 변환을 수행합니다. -icon: \uEB9F -allowed-tools: - - folder_map - - file_read - - file_write - - process - - image_analyze - - format_convert - - file_manage -tabs: cowork ---- - -사용자의 요구에 맞게 이미지를 처리하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 Pillow 경로를 사용하세요. -- Python 불가: `image_analyze`로 이미지 상태를 점검하고, `file_manage`/`format_convert`/`file_write`로 가능한 변환 및 수동 처리 지침을 제공하세요. - - -## 사전 준비 -먼저 Pillow 패키지가 설치되어 있��지 확인하고, 없으면 설���하세요: -``` -process: pip install Pillow -``` - -## 작업 절�� -1. **요구사항 파악**: 처리할 이미지 파일과 원하는 작업 확인 -2. **Python 스크립트 작성**: file_write로 .py 파일 생성 -3. **스크립트 실행**: `process`로 Python 스크립�� 실행 -4. **결과 확인**: 처리된 이미지 파일 경로를 사용자에게 안내 - -## 지원 기능 - -### 리사이즈 -```python -from PIL import Image -img = Image.open('input.png') -img_resized = img.resize((800, 600)) # 고정 크기 -# 또는 비율 유지 -img.thumbnail((800, 800)) -img.save('output.png') -``` - -### 크롭 -```python -img = Image.open('input.png') -cropped = img.crop((left, top, right, bottom)) -cropped.save('output.png') -``` - -### 워터마크 -```python -from PIL import Image, ImageDraw, ImageFont -img = Image.open('input.png') -draw = ImageDraw.Draw(img) -draw.text((10, 10), "Watermark", fill=(255, 255, 255, 128)) -img.save('output.png') -``` - -### 포맷 변환 -```python -img = Image.open('input.png') -img.save('output.jpg', 'JPEG', quality=85) -img.save('output.webp', 'WEBP', quality=80) -``` - -### 배치 처리 -```python -import glob -for path in glob.glob('*.png'): - img = Image.open(path) - img.thumbnail((800, 800)) - img.save(f'resized_{path}') -``` - -## 추가 기능 -- 회전/뒤집기 (rotate, transpose) -- 밝기/대비/선명도 조절 (ImageEnhance) -- 필터 적용 (ImageFilter: BLUR, SHARPEN, CONTOUR) -- 이미지 합성 (Image.paste, Image.alpha_composite) -- EXIF 정보 읽기 - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/impact.skill.md b/dist/AxCopilot/skills/impact.skill.md deleted file mode 100644 index 89e17bc..0000000 --- a/dist/AxCopilot/skills/impact.skill.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: impact -label: 변경 영향 분석 -description: 코드 변경 시 영향받는 파일, 함수, 테스트를 식별합니다. -icon: \uE946 -allowed-tools: - - lsp_code_intel - - grep - - search_codebase - - git_tool - - file_read - - folder_map -tabs: code ---- - -코드 변경이 미치는 영향 범위를 분석하여 안전한 수정을 도와주세요. - -## 워크플로우 - -1. **변경 대상 확인**: 어떤 파일/함수/클래스를 변경할 예정인지 파악 -2. **직접 참조 분석**: lsp_code_intel의 find_references로 직접 호출처 확인 -3. **간접 영향 분석**: - - grep으로 문자열 기반 참조 검색 - - search_codebase로 의미적 유사 코드 탐색 - - 상속/인터페이스 체인 추적 -4. **테스트 영향**: 관련 테스트 파일 식별 -5. **위험도 평가**: 변경 영향 범위와 위험도 매트릭스 생성 -6. **보고서 작성**: 영향 분석 보고서 생성 - -## 분석 항목 - -### 직접 영향 -- 해당 함수/클래스를 직접 호출하는 코드 -- import/using 하는 파일 -- 상속받는 클래스 - -### 간접 영향 -- 인터페이스 구현체를 통한 호출 -- 리플렉션/동적 호출 -- 설정 파일 참조 -- UI 바인딩 - -### 테스트 영향 -- 직접 테스트하는 테스트 파일 -- 관련 통합 테스트 -- 테스트 더블(Mock) 대상 여부 - -## 출력 형식 - -### 영향 분석 보고서 - -| 영향 수준 | 파일 | 관련 함수 | 위험도 | 비고 | -|----------|------|----------|--------|------| -| 🔴 직접 | A.cs | MethodX() | 높음 | 시그니처 변경 시 컴파일 오류 | -| 🟡 간접 | B.cs | MethodY() | 중간 | 동적 호출, 런타임 오류 가능 | -| 🟢 테스트 | A.Tests.cs | Test1() | 낮음 | 테스트 수정 필요 | - -### 변경 체크리스트 -- [ ] 직접 참조 N개 확인 및 수정 -- [ ] 테스트 M개 업데이트 -- [ ] 관련 문서 갱신 - -## 규칙 -- 코드를 직접 수정하지 않음 (분석만 수행) -- 가능하면 LSP 기반 정확한 참조 분석 우선 -- LSP 불가 시 grep 기반 텍스트 검색으로 대체 -- 위험도는 보수적으로 평가 (의심스러우면 높음) diff --git a/dist/AxCopilot/skills/json-schema.skill.md b/dist/AxCopilot/skills/json-schema.skill.md deleted file mode 100644 index 55b4fae..0000000 --- a/dist/AxCopilot/skills/json-schema.skill.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: json-schema -label: JSON/YAML 스키마 도구 -description: JSON 또는 YAML 데이터에서 스키마를 생성하고, 데이터의 유효성을 검증합니다. -icon: \uE943 -tabs: code -allowed-tools: - - json_tool - - file_read - - file_write - - clipboard_tool ---- - -JSON/YAML 샘플 데이터에서 스키마를 추출하거나, 기존 스키마로 데이터를 검증하세요. - -## 작업 절차 - -1. **요청 유형 판별**: - - **스키마 생성**: 샘플 JSON/YAML → JSON Schema 추출 - - **스키마 검증**: 데이터 + 스키마 → 유효성 검사 - - **스키마 문서화**: 기존 스키마 → 사람이 읽을 수 있는 설명 생성 -2. **입력 데이터 확인**: file_read 또는 사용자 입력으로 데이터 로드 -3. **스키마 생성 또는 검증 수행**: json_tool로 처리 -4. **결과 출력**: 생성된 스키마 또는 검증 결과를 표시 -5. **파일 저장**: file_write로 결과를 저장하거나 clipboard_tool로 복사 - -## 스키마 생성 규칙 - -### 타입 추론 -| JSON 값 | JSON Schema 타입 | 추가 속성 | -|---------|-----------------|----------| -| `"text"` | `string` | — | -| `123` | `integer` | — | -| `1.5` | `number` | — | -| `true` | `boolean` | — | -| `null` | `null` | nullable 처리 | -| `[]` | `array` | items 스키마 | -| `{}` | `object` | properties 스키마 | - -### 추론 강화 -- **패턴 감지**: 이메일, URL, 날짜, UUID 등은 `format` 속성 추가 -- **열거형 감지**: 값의 종류가 적으면 `enum` 으로 제안 -- **필수 필드**: 모든 샘플에 존재하는 필드는 `required`로 표시 -- **배열 항목**: 배열 내 모든 항목을 분석하여 통합 스키마 생성 - -### 출력 형식 (JSON Schema Draft 7) -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "사용자 정보", - "description": "사용자 프로필 데이터 스키마", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "사용자 이름" - }, - "email": { - "type": "string", - "format": "email", - "description": "이메일 주소" - }, - "age": { - "type": "integer", - "minimum": 0, - "maximum": 150, - "description": "나이" - } - }, - "required": ["name", "email"], - "additionalProperties": false -} -``` - -## 검증 결과 형식 -검증 결과는 다음과 같이 표시하세요: -``` -검증 결과: ❌ 실패 (3건의 오류) - -| # | 경로 | 오류 | 기대값 | -|---|------|------|--------| -| 1 | $.email | 필수 필드 누락 | string (required) | -| 2 | $.age | 타입 불일치 | integer (실제: string) | -| 3 | $.tags[2] | 열거형 불일치 | "A", "B", "C" 중 하나 | -``` - -## 스키마 문서화 형식 -기존 스키마를 분석하여 사람이 읽기 쉬운 문서를 생성: -``` -## 사용자 정보 스키마 - -| 필드 | 타입 | 필수 | 설명 | 제약 조건 | -|------|------|------|------|----------| -| name | string | ✅ | 사용자 이름 | — | -| email | string | ✅ | 이메일 주소 | format: email | -| age | integer | — | 나이 | 0~150 | -| tags | array | — | 태그 목록 | items: string | -``` - -## 규칙 -- JSON Schema Draft 7 형식 사용 -- 스키마 생성 시 description 필드를 한국어로 작성 -- 복수 샘플이 제공되면 모든 샘플을 분석하여 통합 스키마 생성 -- 중첩 객체는 재귀적으로 스키마 추출 -- 결과는 파일 저장과 클립보드 복사 모두 제공 - -한국어로 안내하세요. diff --git a/dist/AxCopilot/skills/log-analyze.skill.md b/dist/AxCopilot/skills/log-analyze.skill.md deleted file mode 100644 index cba086b..0000000 --- a/dist/AxCopilot/skills/log-analyze.skill.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: log-analyze -label: 로그 분석기 -description: 로그 파일의 패턴을 분석하고 에러를 요약하며 타임라인을 시각화합니다. -icon: \uE9D9 -allowed-tools: - - file_read - - grep - - chart_create - - html_create - - data_pivot - - text_summarize -tabs: cowork ---- - -로그 파일을 분석하여 패턴, 에러, 추세를 파악하고 보고서를 생성하세요. - -## 워크플로우 - -1. **로그 파일 로드**: file_read로 로그 파일 읽기 -2. **패턴 분석**: - - grep으로 에러/경고/예외 패턴 검색 - - 시간대별 이벤트 빈도 파악 - - 반복되는 에러 패턴 식별 -3. **통계 생성**: data_pivot으로 집계 -4. **시각화**: chart_create로 타임라인 차트 생성 -5. **보고서**: html_create로 분석 보고서 생성 - -## 분석 항목 - -### 에러 분석 -- ERROR, WARN, FATAL, Exception 키워드 추출 -- 에러 유형별 발생 빈도 -- 최초 발생 시점 및 최근 발생 시점 -- 에러 메시지 클러스터링 (유사 에러 그룹화) - -### 시간대 분석 -- 시간대별 로그 발생 빈도 -- 피크 시간대 식별 -- 에러 집중 시간대 - -### 패턴 분석 -- 반복 패턴 (주기적 에러) -- 연쇄 에러 (A 에러 후 B 에러 발생 패턴) -- 비정상 패턴 (평소와 다른 로그량) - -## 출력 형식 - -``` -## 로그 분석 보고서 -- 분석 기간: [시작] ~ [끝] -- 총 로그: N줄 - -### 에러 요약 (상위 10건) -| 순위 | 에러 유형 | 발생 횟수 | 최근 발생 | -|------|----------|----------|----------| - -### 타임라인 차트 -[시간대별 이벤트 빈도 차트] - -### 상세 분석 -[에러별 상세 내용 및 권장 조치] -``` - -## 지원 로그 형식 -- 일반 텍스트 로그 (타임스탬프 자동 감지) -- JSON 로그 (각 줄이 JSON 객체) -- CSV 로그 (헤더 포함) -- syslog 형식 diff --git a/dist/AxCopilot/skills/markdown-to-doc.skill.md b/dist/AxCopilot/skills/markdown-to-doc.skill.md deleted file mode 100644 index 94004f3..0000000 --- a/dist/AxCopilot/skills/markdown-to-doc.skill.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: markdown-to-doc -label: Markdown → 문서 변환 -description: Markdown 파일을 서식이 적용된 Word(.docx) 또는 PDF 문서로 변환합니다. -icon: \uE8A5 -allowed-tools: - - file_read - - file_write - - process - - format_convert - - document_assemble -tabs: cowork ---- - -Markdown 파일을 전문적인 Word 또는 PDF 문서로 변환하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 python-docx 경로를 사용하세요. -- Python 불가: `format_convert`를 우선 사용해 Markdown을 docx/pdf로 변환하고, 변환 제한 시 `file_write`로 보정 가이드를 생성하세요. - - -## 사전 준비 -먼저 필요한 패키지가 설치되어 있는지 확인하고, 없으면 설치하세요: -``` -process: pip install python-docx markdown -``` - -## 작업 절차 - -1. **Markdown 파일 확인**: file_read로 변환할 Markdown 파일의 내용과 구조를 파악 -2. **변환 옵션 확인**: 사용자에게 다음 옵션을 확인 - - 출력 형식: Word(.docx) 또는 PDF - - 폰트: 맑은 고딕 (기본) / 사용자 지정 - - 여백, 페이지 크기 설정 - - 머리글/바닥글 포함 여부 -3. **Python 스크립트 작성**: file_write로 변환 스크립트 생성 -4. **스크립트 실행**: `process`로 Python 스크립트 실행 -5. **결과 확인**: 생성된 문서 파일 경로와 페이지 수를 안내 - -## 스타일 매핑 - -| Markdown | Word 스타일 | 설명 | -|----------|------------|------| -| `# 제목` | Heading 1 | 16pt, 굵게 | -| `## 소제목` | Heading 2 | 14pt, 굵게 | -| `### 항목` | Heading 3 | 12pt, 굵게 | -| 본문 텍스트 | Normal | 11pt | -| `**굵게**` | Bold run | 굵게 | -| `*기울임*` | Italic run | 기울임 | -| `` `코드` `` | 코드 스타일 | Consolas, 배경색 | -| `> 인용` | Quote | 들여쓰기 + 왼쪽 테두리 | -| `- 목록` | List Bullet | 글머리 기호 | -| `1. 번호` | List Number | 번호 목록 | -| 표 | Table Grid | 테두리 표 | -| `---` | 페이지 구분 | 가로선 → 페이지 나누기 | -| 코드 블록 | 코드 단락 | Consolas, 회색 배경 | - -## Python 스크립트 템플릿 -```python -import re -from docx import Document -from docx.shared import Inches, Pt, Cm, RGBColor -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.oxml.ns import qn -import markdown - -# Markdown 파일 읽기 -with open('input.md', 'r', encoding='utf-8') as f: - md_content = f.read() - -doc = Document() - -# 기본 스타일 설정 -style = doc.styles['Normal'] -font = style.font -font.name = '맑은 고딕' -font.size = Pt(11) -style.element.rPr.rFonts.set(qn('w:eastAsia'), '맑은 고딕') - -# 여백 설정 -for section in doc.sections: - section.top_margin = Cm(2.54) - section.bottom_margin = Cm(2.54) - section.left_margin = Cm(3.17) - section.right_margin = Cm(3.17) - -# Markdown 파싱 및 변환 -lines = md_content.split('\n') -i = 0 -while i < len(lines): - line = lines[i] - - # 제목 (Heading) - if line.startswith('#'): - level = len(line) - len(line.lstrip('#')) - text = line.lstrip('#').strip() - doc.add_heading(text, level=min(level, 4)) - - # 코드 블록 - elif line.startswith('```'): - code_lines = [] - i += 1 - while i < len(lines) and not lines[i].startswith('```'): - code_lines.append(lines[i]) - i += 1 - p = doc.add_paragraph() - run = p.add_run('\n'.join(code_lines)) - run.font.name = 'Consolas' - run.font.size = Pt(9) - - # 인용 - elif line.startswith('>'): - text = line.lstrip('>').strip() - p = doc.add_paragraph(text) - p.paragraph_format.left_indent = Cm(1.27) - - # 글머리 기호 - elif line.startswith('- ') or line.startswith('* '): - text = line[2:].strip() - doc.add_paragraph(text, style='List Bullet') - - # 번호 목록 - elif re.match(r'^\d+\.\s', line): - text = re.sub(r'^\d+\.\s', '', line).strip() - doc.add_paragraph(text, style='List Number') - - # 가로선 → 페이지 나누기 - elif line.strip() in ('---', '***', '___'): - doc.add_page_break() - - # 빈 줄 - elif line.strip() == '': - pass - - # 일반 텍스트 - else: - p = doc.add_paragraph() - # 굵게, 기울임 처리 - parts = re.split(r'(\*\*.*?\*\*|\*.*?\*|`.*?`)', line) - for part in parts: - if part.startswith('**') and part.endswith('**'): - run = p.add_run(part[2:-2]) - run.bold = True - elif part.startswith('*') and part.endswith('*'): - run = p.add_run(part[1:-1]) - run.italic = True - elif part.startswith('`') and part.endswith('`'): - run = p.add_run(part[1:-1]) - run.font.name = 'Consolas' - run.font.size = Pt(10) - else: - p.add_run(part) - - i += 1 - -# 저장 -doc.save('output.docx') -print(f'변환 완료: output.docx ({len(doc.paragraphs)}개 단락)') -``` - -## 표 변환 -Markdown 표가 있으면 Word 표로 변환합니다: -- 헤더 행: 굵게, 배경색 적용 -- 셀 정렬: Markdown의 `:---`, `:---:`, `---:` 구문 반영 -- 테두리: 전체 셀에 얇은 테두리 - -## 규칙 -- 원본 Markdown 파일은 수정하지 않음 -- 인코딩: UTF-8 기본 -- 이미지 링크(`![](path)`)는 로컬 파일이면 삽입, URL이면 경로만 표시 -- 복잡한 Markdown(수식, 다이어그램)은 지원 범위와 한계를 안내 -- 출력 파일명: 원본 파일명 기준 (.md → .docx) - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/meeting-minutes.skill.md b/dist/AxCopilot/skills/meeting-minutes.skill.md deleted file mode 100644 index 9ba9439..0000000 --- a/dist/AxCopilot/skills/meeting-minutes.skill.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: meeting-minutes -label: 회의록 정리 -description: 회의 내용을 체계적으로 정리하여 회의록을 생성합니다. -icon: \uE771 -allowed-tools: - - file_read - - file_write - - text_summarize - - template_render -tabs: cowork ---- - -사용자가 제공한 회의 내용(텍스트, 메모, 파일)을 정리하여 체계적인 회의록을 작성하세요. - -다음 도구를 사용하세요: -1. file_read — 회의 관련 파일 읽기 (필요 시) -2. file_write — 회의록 파일 생성 - -회의록 형식: -## 회의 정보 -- 일시: -- 참석자: -- 장소/방법: -- 주제: - -## 안건 및 논의 내용 -각 안건별로: -- **안건**: 주제 -- **논의 내용**: 주요 발언 및 의견 정리 -- **결정 사항**: 합의된 내용 - -## 액션 아이템 -| 번호 | 담당자 | 내용 | 기한 | 비고 | -|------|--------|------|------|------| -| 1 | - | - | - | - | - -## 다음 회의 -- 예정일: -- 주요 안건: - -규칙: -- 핵심 내용 위주로 간결하게 정리 -- 결정 사항과 액션 아이템은 명확하게 기술 -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/ocr-extract.skill.md b/dist/AxCopilot/skills/ocr-extract.skill.md deleted file mode 100644 index 31e1876..0000000 --- a/dist/AxCopilot/skills/ocr-extract.skill.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: ocr-extract -label: OCR 텍스트 추출 -description: Python pytesseract를 사용하여 이미지/스캔 문서에서 텍스트를 추출합니다. -icon: \uE8D4 -allowed-tools: - - file_read - - file_write - - process - - image_analyze - - text_summarize -tabs: cowork ---- - -이미지 또는 스캔된 문서에서 텍스트를 추출하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 pytesseract 경로를 사용하세요. -- Python 불가: `image_analyze`로 텍스트 후보를 추출하고 `text_summarize` + `file_write`로 정제본을 제공하세요. - - -## 사전 준비 -1. Tesseract OCR 엔진이 시스템에 설치되어 있어야 합니다. - - Windows: https://github.com/UB-Mannheim/tesseract/wiki 에서 설치 - - 한국어 지원: 설치 시 "Korean" 언어 데이터 선택 -2. Python 패키지 설치: -``` -process: pip install pytesseract Pillow -``` - -## 작업 절차 -1. **이미지 확인**: 사용자가 제공한 이미지 파일 확인 -2. **전처리 (선택)**: 이미지 품질이 낮으면 전처리 스크립트 적용 -3. **OCR 실행**: pytesseract로 텍스트 추출 -4. **결과 저장**: 추출된 텍스트를 파일로 저장하고 사용자에게 안내 - -## OCR 스크립트 템플릿 - -### 기본 텍스트 추출 -```python -import pytesseract -from PIL import Image - -# Windows에서 Tesseract 경로 지정 (필요 시) -# pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe' - -img = Image.open('scan.png') -text = pytesseract.image_to_string(img, lang='kor+eng') -print(text) - -with open('extracted.txt', 'w', encoding='utf-8') as f: - f.write(text) -``` - -### 이미지 전처리 (품질 개선) -```python -from PIL import Image, ImageFilter, ImageEnhance - -img = Image.open('scan.png') - -# 그레이스케일 변환 -img = img.convert('L') - -# 대비 향상 -enhancer = ImageEnhance.Contrast(img) -img = enhancer.enhance(2.0) - -# 선명도 향상 -img = img.filter(ImageFilter.SHARPEN) - -# 이진화 (흑백) -threshold = 128 -img = img.point(lambda x: 255 if x > threshold else 0) - -img.save('preprocessed.png') -text = pytesseract.image_to_string(img, lang='kor+eng') -``` - -### 배치 OCR (여러 이미지) -```python -import glob -import pytesseract -from PIL import Image - -results = [] -for path in sorted(glob.glob('*.png')): - img = Image.open(path) - text = pytesseract.image_to_string(img, lang='kor+eng') - results.append(f'--- {path} ---\n{text}\n') - -with open('all_extracted.txt', 'w', encoding='utf-8') as f: - f.write('\n'.join(results)) -``` - -### 표 영역 추출 -```python -import pytesseract -from PIL import Image - -img = Image.open('table.png') -# TSV 형식으로 추출 (표 구조 보존) -tsv_data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DATAFRAME) -print(tsv_data) -``` - -## 지원 언어 -- `kor` — 한국어 -- `eng` — 영어 -- `kor+eng` — 한국어+영어 혼합 (권장) -- `jpn` — 일본어 -- `chi_sim` — 중국어 간체 - -## 팁 -- 스캔 해상도 300dpi 이상이면 인식률이 높습니다 -- 기울어진 이미지는 `img.rotate()` 로 보정 후 추출하세요 -- 손글씨는 인식률이 낮을 수 있습니다 - -한국어로 안내하세요. 작업 폴더에 Python 스크립트와 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/paper-review.skill.md b/dist/AxCopilot/skills/paper-review.skill.md deleted file mode 100644 index 440fb26..0000000 --- a/dist/AxCopilot/skills/paper-review.skill.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: paper-review -label: 논문 분석 -description: 논문 또는 기술 문서를 체계적으로 분석하고 핵심 내용을 정리합니다. -icon: \uE736 -allowed-tools: - - document_read - - file_read - - file_write - - text_summarize -tabs: cowork ---- - -사용자가 제공한 논문 또는 기술 문서를 체계적으로 분석하세요. - -## 파일 읽기 전략 -- **PDF 파일**: 반드시 `document_read` 도구를 사용하세요. 페이지 범위 지정이 가능합니다. - - 먼저 전체 페이지 수를 확인한 후, 초록(1-2페이지)→본문→참고문헌 순으로 읽으세요. - - `file_read`로 PDF를 읽으면 텍스트가 깨질 수 있으므로 사용하지 마세요. -- **텍스트 파일** (.txt, .md, .html 등): `file_read` 도구를 사용하세요. - -## 사용 도구 -1. document_read — PDF 논문 파일 읽기 (페이지 범위 지정, 초록/참고문헌 추출) -2. file_read — 텍스트 기반 문서 파일 읽기 -3. file_write — 분석 보고서 생성 - -## 분석 항목 -## 논문 개요 -- 제목, 저자, 발표 연도/학회 -- 연구 분야 및 키워드 - -## 연구 목적 및 배경 -- 연구 문제 정의 -- 기존 연구의 한계점 - -## 방법론 -- 제안 방법의 핵심 아이디어 -- 실험 설계 및 데이터셋 - -## 주요 결과 -- 핵심 실험 결과 (표/수치 인용) -- 기존 방법 대비 개선점 - -## 한계점 및 향후 연구 -- 저자가 인정한 한계 -- 발전 가능성 - -## 실무 적용 가능성 -- 우리 업무에 적용할 수 있는 포인트 -- 기술 도입 시 고려사항 - -## 작업 절차 -1. 사용자가 파일명을 언급하면 작업 폴더에서 해당 파일을 찾아 읽기 -2. PDF인 경우 `document_read`로 초록(1-2p) 먼저 읽어 전체 구조 파악 -3. 본문을 페이지 범위별로 나누어 순차 읽기 -4. 위 분석 항목에 따라 체계적으로 정리 -5. `file_write`로 분석 보고서를 마크다운 파일로 저장 - -한국어로 작성하고, 전문 용어는 원문과 함께 표기하세요. diff --git a/dist/AxCopilot/skills/pdf-processor.skill.md b/dist/AxCopilot/skills/pdf-processor.skill.md deleted file mode 100644 index 631e7ee..0000000 --- a/dist/AxCopilot/skills/pdf-processor.skill.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: pdf-processor -label: PDF 처리 -description: Python을 사용하여 PDF에서 텍스트/표를 추출하거나 PDF를 생성합니다. -icon: \uE9F9 -allowed-tools: - - folder_map - - document_read - - file_read - - file_write - - process - - format_convert -tabs: cowork ---- - -PDF 파일을 읽거나 새 PDF를 생성하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 pypdf/pdfplumber/reportlab 경로를 사용하세요. -- Python 불가: `document_read`로 텍스트/구조를 추출하고, 생성 작업은 `format_convert` + `file_write` 기반으로 대체하세요. - - -## 사전 준비 -필요한 패키지를 확인하고 설치하세요: -``` -process: pip install pypdf pdfplumber reportlab -``` - -## 작업 절차 - -### PDF 텍스트 추출 -1. **파일 확인**: folder_map으로 PDF 파일 위치 확인 -2. **추출 스크립트 작성**: file_write로 Python 스크립트 생성 -3. **실행**: `process`로 실행 -4. **결과 전달**: 추출된 텍스트를 사용자에게 전달 - -### PDF 생성 -1. **내용 파악**: 사용자가 원하는 문서 내용 확인 -2. **생성 스크립트 작성**: file_write로 Python 스크립트 생성 -3. **실행 및 확인**: `process`로 실행 - -## 텍스트 추출 템플릿 -```python -import pdfplumber -import json - -results = [] -with pdfplumber.open('input.pdf') as pdf: - for i, page in enumerate(pdf.pages): - text = page.extract_text() or '' - tables = page.extract_tables() or [] - results.append({ - 'page': i + 1, - 'text': text, - 'tables': tables, - }) - -with open('pdf_extracted.json', 'w', encoding='utf-8') as f: - json.dump(results, f, ensure_ascii=False, indent=2) - -for r in results: - print(f"--- 페이지 {r['page']} ---") - print(r['text'][:500]) -``` - -## 지원 기능 -- 텍스트 추출 (페이지별) -- 표 추출 (구조 보존) -- PDF 병합 / 분할 -- PDF 생성 (reportlab) -- 페이지 회전 -- 메타데이터 읽기 - -한국어로 안내하세요. 원본 PDF는 수정하지 마세요. diff --git a/dist/AxCopilot/skills/perf-audit.skill.md b/dist/AxCopilot/skills/perf-audit.skill.md deleted file mode 100644 index 6c74697..0000000 --- a/dist/AxCopilot/skills/perf-audit.skill.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: perf-audit -label: 성능 감사 -description: 코드 복잡도, 성능 병목, 메모리 이슈를 분석하고 최적화 방안을 제시합니다. -icon: \uE9D9 -allowed-tools: - - file_read - - grep - - folder_map - - search_codebase - - glob - - html_create -tabs: code ---- - -코드베이스의 성능 관련 이슈를 분석하고 최적화 보고서를 생성하세요. - -## 워크플로우 - -1. **프로젝트 분석**: folder_map으로 구조 파악, 언어/프레임워크 식별 -2. **복잡도 분석**: 파일별 줄 수, 메서드 크기, 중첩 깊이 측정 -3. **성능 안티패턴 탐지**: grep으로 알려진 성능 이슈 패턴 검색 -4. **메모리 이슈 탐지**: 리소스 해제 누락, 대용량 할당 패턴 -5. **보고서 생성**: html_create로 성능 감사 보고서 - -## 분석 항목 - -### 코드 복잡도 지표 -- **파일 크기**: 500줄 이상 파일 식별 -- **메서드 크기**: 50줄 이상 메서드 식별 -- **중첩 깊이**: 4단계 이상 들여쓰기 -- **매개변수 수**: 5개 이상 파라미터 메서드 - -### 성능 안티패턴 - -#### 데이터베이스 -- N+1 쿼리 패턴 (루프 내 DB 호출) -- SELECT * 사용 (불필요한 컬럼 로드) -- 인덱스 미사용 쿼리 힌트 - -#### 메모리 -- IDisposable 미해제 (using 미사용) -- 대용량 문자열 결합 (StringBuilder 미사용) -- 정적 컬렉션 무한 증가 -- 이벤트 핸들러 미해제 (메모리 누수) - -#### I/O -- 동기 파일 I/O (async 미사용) -- 동기 네트워크 호출 -- 불필요한 직렬화/역직렬화 - -#### 알고리즘 -- O(n²) 이상 루프 (중첩 foreach/for) -- 반복 계산 (캐싱 미적용) -- LINQ 체인의 불필요한 ToList() - -#### 프론트엔드 -- 불필요한 리렌더링 패턴 -- 대용량 번들 임포트 -- 이미지 최적화 미적용 - -### .NET 전용 패턴 -``` -탐지 대상: -- Task.Result / .Wait() (데드락 위험) -- lock 내부 async 호출 -- GC.Collect() 직접 호출 -- Reflection 반복 사용 -- string + string 반복 (루프 내) -``` - -## 출력 형식 - -### 성능 감사 보고서 - -**요약 대시보드** -| 지표 | 값 | 상태 | -|------|-----|------| -| 총 파일 수 | ... | — | -| 대형 파일 (500줄+) | ... | ⚠ | -| 대형 메서드 (50줄+) | ... | ⚠ | -| 성능 안티패턴 | ... | 🔴 | -| 메모리 이슈 | ... | 🟡 | - -**상세 이슈 목록** -| 우선순위 | 파일 | 라인 | 이슈 | 영향 | 권장 조치 | -|---------|------|------|------|------|----------| -| 🔴 높음 | ... | ... | N+1 쿼리 | 응답 지연 | 일괄 로드 | - -## 규칙 -- 코드를 수정하지 않음 (분석 + 보고서만) -- 성능 이슈는 영향도와 수정 난이도를 함께 평가 -- 추측보다 패턴 기반 탐지 우선 -- 한국어로 작성 diff --git a/dist/AxCopilot/skills/pptx-creator.skill.md b/dist/AxCopilot/skills/pptx-creator.skill.md deleted file mode 100644 index a8670e8..0000000 --- a/dist/AxCopilot/skills/pptx-creator.skill.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: pptx-creator -label: PPT 프레젠테이션 생성 -description: Python을 사용하여 전문적인 PowerPoint 프레젠테이션을 생성합니다. 작업 폴더의 양식 파일을 자동 활용합니다. -icon: \uE7BE -allowed-tools: - - folder_map - - document_read - - file_read - - file_write - - process - - pptx_create - - template_render -tabs: cowork ---- - -사용자의 요구에 맞는 PowerPoint 프레젠테이션을 Python으로 생성하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 python-pptx 경로를 사용하세요. -- Python 불가: `pptx_create`로 슬라이드 초안을 생성하고 `template_render` + `file_write`로 발표자료 구조를 보강하세요. - - -## 사전 준비 -필요한 패키지를 확인하고 설치하세요: -``` -process: pip install python-pptx -``` - -## 양식 활용 (마스터 슬라이드 템플릿) -작업 폴더에 PPT 양식이 있으면 **반드시** 활용하세요: - -1. **양식 탐색**: `folder_map`으로 작업 폴더를 스캔하여 `.pptx` 파일 확인 -2. **양식 후보 판별**: - - 파일명에 "양식", "template", "서식", "표준", "기본" 포함 - - 또는 사용자가 명시적으로 "XX 양식으로 작성해줘" 요청 - - 또는 사용자가 특정 .pptx 파일명을 언급 -3. **양식 구조 파악**: `document_read`로 양식의 슬라이드 레이아웃 목록 확인 -4. **양식 기반 생성**: - ```python - prs = Presentation('양식_발표.pptx') - # 마스터 슬라이드의 배경, 로고, 색 테마, 폰트가 자동 상속 - # 기존 슬라이드 제거 후 새 내용 추가 - while len(prs.slides) > 0: - rId = prs.slides._sldIdLst[0].rId - prs.part.drop_rel(rId) - del prs.slides._sldIdLst[0] - ``` -5. **레이아웃 확인** (양식마다 다를 수 있음): - ```python - for i, layout in enumerate(prs.slide_layouts): - print(f'{i}: {layout.name}') - ``` -6. **양식이 없으면**: 아래 기본 템플릿으로 새 프레젠테이션 생성 - -## 작업 절차 -1. **요구사항 파악**: 발표 주제, 슬라이드 수, 스타일 확인 -2. **양식 확인**: folder_map으로 작업 폴더에 양식 .pptx 파일이 있는지 확인 -3. **스크립트 작성**: file_write로 Python 스크립트 생성 -4. **실행**: `process`로 스크립트 실행 -5. **결과 안내**: 생성된 .pptx 파일 경로를 사용자에게 전달 - -## 스크립트 템플릿 -```python -from pptx import Presentation -from pptx.util import Inches, Pt, Emu -from pptx.dml.color import RGBColor -from pptx.enum.text import PP_ALIGN -import os - -# 양식 파일 자동 감지 -template_keywords = ['양식', 'template', '서식', '표준', '기본'] -template_file = None -for f in os.listdir('.'): - if f.endswith('.pptx') and any(kw in f.lower() for kw in template_keywords): - template_file = f - break - -# 양식이 있으면 활용, 없으면 새 프레젠테이션 -if template_file: - prs = Presentation(template_file) - # 기존 슬라이드 제거 (마스터/레이아웃은 유지) - while len(prs.slides) > 0: - rId = prs.slides._sldIdLst[0].rId - prs.part.drop_rel(rId) - del prs.slides._sldIdLst[0] - print(f'양식 활용: {template_file}') - print(f'사용 가능한 레이아웃: {[l.name for l in prs.slide_layouts]}') -else: - prs = Presentation() - prs.slide_width = Inches(13.333) - prs.slide_height = Inches(7.5) - -# 제목 슬라이드 -slide = prs.slides.add_slide(prs.slide_layouts[0]) -slide.shapes.title.text = '프레젠테이션 제목' -if len(slide.placeholders) > 1: - slide.placeholders[1].text = '부제목' - -# 내용 슬라이드 -slide = prs.slides.add_slide(prs.slide_layouts[1]) -slide.shapes.title.text = '섹션 제목' -body = slide.placeholders[1] -body.text = '첫 번째 포인트' -p = body.text_frame.add_paragraph() -p.text = '두 번째 포인트' - -prs.save('presentation.pptx') -print('프레젠테이션 생성 완료: presentation.pptx') -``` - -## 지원 기능 -- 제목/내용/빈 슬라이드 레이아웃 -- 텍스트 서식 (글꼴, 크기, 색상, 정렬) -- 표 삽입 -- 이미지 삽입 -- 도형 (사각형, 원, 화살표) -- 차트 (막대, 선, 원형) -- 슬라이드 번호 -- 마스터 슬라이드 커스터마이징 -- **양식 파일 기반 마스터/레이아웃 상속** (배경, 로고, 색 테마, 폰트 자동 유지) - -한국어로 안내하세요. 작업 폴더에 결과 파일을 저장하세요. diff --git a/dist/AxCopilot/skills/prd-generator.skill.md b/dist/AxCopilot/skills/prd-generator.skill.md deleted file mode 100644 index e284096..0000000 --- a/dist/AxCopilot/skills/prd-generator.skill.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: prd-generator -label: 요구사항 정의서 (PRD) -description: 제품 요구사항 정의서, 유저 스토리, 수용 기준을 체계적으로 생성합니다. -icon: \uE8A5 -allowed-tools: - - file_read - - file_write - - html_create - - docx_create - - document_plan - - document_assemble -tabs: cowork ---- - -제품/기능의 요구사항 정의서(PRD)를 체계적으로 작성하세요. - -## 워크플로우 - -1. **요구사항 수집**: 사용자에게 다음을 확인 - - 제품/기능 이름 - - 목적과 배경 - - 대상 사용자 - - 핵심 기능 목록 - - 제약 조건 (기술, 일정, 예산) - -2. **구조화**: document_plan으로 PRD 개요 설계 - -3. **상세 작성**: 섹션별 상세 내용 작성 - - 유저 스토리 (As a... I want... So that...) - - 수용 기준 (Given... When... Then...) - - 기능 우선순위 (MoSCoW) - -4. **문서 생성**: document_assemble 또는 html_create로 최종 문서 - -## PRD 구조 - -### 1. 개요 -- 제품/기능 이름 -- 버전 / 작성일 / 작성자 -- 문서 목적 - -### 2. 배경 및 목적 -- 비즈니스 배경 -- 해결하려는 문제 -- 기대 효과 (정량적 KPI) - -### 3. 대상 사용자 -- 사용자 페르소나 -- 사용 시나리오 -- 사용자 여정 맵 - -### 4. 기능 요구사항 - -#### 유저 스토리 형식 -``` -US-001: [기능명] -As a [역할], -I want [기능], -So that [가치]. - -수용 기준: -- Given [사전 조건], When [행동], Then [기대 결과] -- Given ..., When ..., Then ... - -우선순위: Must Have / Should Have / Could Have / Won't Have -``` - -### 5. 비기능 요구사항 -- 성능 (응답 시간, 처리량) -- 보안 (인증, 권한, 암호화) -- 접근성 (WCAG 수준) -- 호환성 (브라우저, OS, 디바이스) - -### 6. 기술 제약 -- 기술 스택 제한 -- 연동 시스템 -- 데이터 마이그레이션 - -### 7. 일정 및 마일스톤 -| 마일스톤 | 예정일 | 산출물 | -|---------|--------|--------| -| 설계 완료 | ... | 상세 설계서 | -| 개발 완료 | ... | 릴리즈 빌드 | -| QA 완료 | ... | 테스트 보고서 | - -### 8. 성공 지표 -- 핵심 KPI 및 측정 방법 -- 목표 수치 - -### 9. 리스크 및 대안 -| 리스크 | 영향 | 대안 | -|--------|------|------| -| ... | 높음 | ... | - -## 규칙 -- 사용자 관점에서 작성 (기술 용어 최소화) -- 유저 스토리는 INVEST 원칙 준수 (Independent, Negotiable, Valuable, Estimable, Small, Testable) -- 수용 기준은 테스트 가능하도록 구체적으로 -- 한국어로 작성 (영어 용어 병기 가능) diff --git a/dist/AxCopilot/skills/refactor.skill.md b/dist/AxCopilot/skills/refactor.skill.md deleted file mode 100644 index 529cb97..0000000 --- a/dist/AxCopilot/skills/refactor.skill.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: refactor -label: 리팩토링 가이드 -description: 코드베이스를 분석하여 리팩토링 포인트를 식별하고 실행 계획을 생성합니다. -icon: \uE90F -allowed-tools: - - search_codebase - - grep - - file_read - - code_review - - folder_map - - lsp_code_intel -tabs: code -# hooks / hook_filters 예시 (옵션): -# hooks: -# file_edit: -# pre: -# - lint-pre -# post: verify-post -# hook_filters: lint-pre@pre@file_edit, verify-post@post@* ---- - -코드베이스를 분석하여 리팩토링이 필요한 부분을 식별하고 개선 계획을 수립하세요. - -## 워크플로우 - -1. **구조 파악**: folder_map + grep으로 프로젝트 전체 구조 분석 -2. **코드 스멜 탐지**: - - 중복 코드 (grep으로 유사 패턴 검색) - - 긴 메서드/클래스 (file_read로 크기 확인) - - 복잡한 조건문 (중첩 if/switch) - - 미사용 코드 (lsp_code_intel로 참조 확인) -3. **의존성 분석**: lsp_code_intel로 참조 관계 파악 -4. **우선순위 결정**: 영향도 × 난이도 매트릭스 -5. **리팩토링 계획 생성**: 단계별 실행 계획 - -## 분석 항목 - -### 코드 스멜 (Code Smells) -- **중복 코드**: 3곳 이상 반복되는 유사 코드 -- **거대 클래스**: 500줄 이상의 클래스 -- **긴 메서드**: 50줄 이상의 메서드 -- **매개변수 과다**: 5개 이상 파라미터 -- **의존성 순환**: 상호 참조 관계 -- **매직 넘버**: 하드코딩된 숫자/문자열 -- **깊은 중첩**: 4단계 이상 들여쓰기 - -### 리팩토링 기법 (제안) -- Extract Method / Extract Class -- Rename (변수, 메서드, 클래스) -- Move Method / Move Field -- Replace Conditional with Polymorphism -- Introduce Parameter Object -- Remove Dead Code - -## 출력 형식 - -### 리팩토링 보고서 -| 우선순위 | 파일 | 이슈 | 제안 | 영향도 | 난이도 | -|---------|------|------|------|--------|--------| -| 🔴 높음 | ... | ... | ... | ★★★ | ★☆☆ | -| 🟡 중간 | ... | ... | ... | ★★☆ | ★★☆ | - -### 실행 계획 -1. [안전한 변경부터] ... -2. [테스트 추가 후] ... -3. [구조 변경] ... - -## 규칙 -- 코드를 직접 수정하지 않음 (분석 + 계획만) -- 기존 테스트가 있으면 테스트 커버리지 확인 -- 팀 컨벤션/스타일 가이드 존중 diff --git a/dist/AxCopilot/skills/regex-helper.skill.md b/dist/AxCopilot/skills/regex-helper.skill.md deleted file mode 100644 index 5d5442e..0000000 --- a/dist/AxCopilot/skills/regex-helper.skill.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: regex-helper -label: 정규식 도우미 -description: 정규식 패턴을 생성하고, 기존 패턴을 해석하며, 테스트 케이스로 검증합니다. -icon: \uE8FD -tabs: code -allowed-tools: - - regex_tool - - clipboard_tool - - file_read ---- - -사용자의 요구에 맞는 정규식 패턴을 작성하거나, 기존 패턴을 해석하고 테스트하세요. - -## 작업 절차 - -1. **요구사항 파악**: 사용자의 요청 유형을 판별 - - **패턴 생성**: "이메일 주소를 찾는 정규식 만들어줘" - - **패턴 해석**: "이 정규식이 무슨 뜻이야? `^[\w.-]+@[\w.-]+\.\w+$`" - - **패턴 테스트**: "이 패턴이 이 문자열에 매칭되는지 확인해줘" -2. **패턴 작성 또는 분석**: - - 생성: 요구사항을 분석하여 정규식 패턴 작성 - - 해석: 패턴을 구성 요소별로 분해하여 설명 -3. **테스트 수행**: regex_tool로 패턴을 테스트 케이스에 적용 - - 매칭되어야 할 문자열 (positive cases) - - 매칭되지 않아야 할 문자열 (negative cases) -4. **결과 설명**: 매칭 결과와 캡처 그룹을 상세히 설명 -5. **최적화 제안**: 성능 또는 가독성 개선이 가능하면 대안 제시 - -## 패턴 생성 가이드 - -### 자주 사용되는 패턴 -| 용도 | 패턴 | 설명 | -|------|------|------| -| 이메일 | `[\w.-]+@[\w.-]+\.\w{2,}` | 기본 이메일 형식 | -| 전화번호 (한국) | `0\d{1,2}-\d{3,4}-\d{4}` | 010-1234-5678 형식 | -| 날짜 (YYYY-MM-DD) | `\d{4}-(?:0[1-9]\|1[0-2])-(?:0[1-9]\|[12]\d\|3[01])` | ISO 날짜 형식 | -| IP 주소 | `(?:\d{1,3}\.){3}\d{1,3}` | IPv4 기본 | -| URL | `https?://[\w.-]+(?:/[\w./?#&=-]*)?` | HTTP/HTTPS URL | -| 한글만 | `[가-힣]+` | 한글 문자 | -| 사업자등록번호 | `\d{3}-\d{2}-\d{5}` | 123-45-67890 형식 | - -### 패턴 해석 형식 -패턴을 해석할 때는 다음 구조로 설명하세요: -``` -패턴: ^(\d{3})-(\d{2})-(\d{5})$ -해석: - ^ → 문자열 시작 - (\d{3}) → 캡처 그룹 1: 숫자 3자리 - - → 하이픈 (리터럴) - (\d{2}) → 캡처 그룹 2: 숫자 2자리 - - → 하이픈 (리터럴) - (\d{5}) → 캡처 그룹 3: 숫자 5자리 - $ → 문자열 끝 -``` - -## 테스트 형식 -테스트 결과는 다음 형식으로 표시하세요: -``` -패턴: \d{3}-\d{2}-\d{5} - -✅ 매칭 성공: - "123-45-67890" → 전체 매칭: "123-45-67890" - "사업자번호: 123-45-67890입니다" → 부분 매칭: "123-45-67890" - -❌ 매칭 실패: - "12-345-67890" → 형식 불일치 - "abc-de-fghij" → 숫자가 아닌 문자 -``` - -## 플래그 안내 -| 플래그 | 설명 | -|--------|------| -| `i` | 대소문자 무시 | -| `m` | 멀티라인 (^$가 각 줄에 적용) | -| `s` | 점(.)이 줄바꿈도 매칭 | -| `g` | 전역 검색 (모든 매칭) | - -## 규칙 -- 패턴 생성 시 positive/negative 테스트 케이스를 반드시 포함 -- 복잡한 패턴은 주석이 포함된 확장 모드(`x`)로 설명 -- 캡처 그룹이 있으면 각 그룹의 의미를 설명 -- 성능에 민감한 경우 탐욕적/게으른 수량자 선택 이유를 설명 -- 최종 패턴은 clipboard_tool로 클립보드에 복사 - -한국어로 안내하세요. diff --git a/dist/AxCopilot/skills/release-note.skill.md b/dist/AxCopilot/skills/release-note.skill.md deleted file mode 100644 index e4a2f59..0000000 --- a/dist/AxCopilot/skills/release-note.skill.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: release-note -label: 릴리즈 노트 생성 -description: 비개발자 대상의 사용자 친화적인 릴리즈 노트를 자동 생성합니다. -icon: \uE70B -tabs: code -allowed-tools: - - file_read - - file_write - - clipboard_tool - - html_create ---- - -사용자(비개발자) 대상의 읽기 쉬운 릴리즈 노트를 작성하세요. - -## 작업 절차 - -1. **변경 정보 수집**: 사용자에게 다음 정보를 확인 - - 버전 번호 (예: v1.6.0) - - 릴리즈 날짜 - - 주요 변경 내용 (기능 추가, 개선, 버그 수정) - - 참고 자료: CHANGELOG, 커밋 이력, 개발 문서 등 -2. **내용 분류**: 수집된 변경 사항을 사용자 관점으로 분류 - - 기술 용어 → 사용자 언어로 변환 - - 내부 리팩토링 등 사용자에게 무관한 항목은 제외 -3. **릴리즈 노트 초안 작성**: 아래 템플릿 기반으로 작성 -4. **사용자 검토**: 초안을 보여주고 수정 요청 반영 -5. **최종 출력**: Markdown, HTML, 또는 텍스트 형식으로 저장 - -## 릴리즈 노트 템플릿 - -```markdown -# [제품명] v[버전] 업데이트 안내 - -안녕하세요. [제품명] v[버전] 업데이트 소식을 안내드립니다. - -## 🎉 새로운 기능 - -- **[기능 이름]**: [사용자가 얻는 혜택 중심으로 1~2문장 설명] -- **[기능 이름]**: [사용자가 얻는 혜택 중심으로 1~2문장 설명] - -## ✨ 개선 사항 - -- **[개선 영역]**: [무엇이 어떻게 좋아졌는지 설명] -- **[개선 영역]**: [무엇이 어떻게 좋아졌는지 설명] - -## 🐛 문제 해결 - -- [사용자가 겪었던 문제]를 해결했습니다. -- [사용자가 겪었던 문제]를 해결했습니다. - -## ⚠️ 알려진 이슈 - -- [현재 알려진 제한 사항이나 이슈] -- [해결 예정 시기 또는 임시 해결 방법] - ---- - -문의 사항이 있으시면 [연락처/채널]로 알려주세요. -감사합니다. -``` - -## 작성 원칙 - -### 사용자 언어로 변환 -| 개발 용어 (사용 금지) | 사용자 표현 (사용) | -|---------------------|-------------------| -| API 엔드포인트 추가 | 새로운 연동 기능 추가 | -| 메모리 누수 수정 | 장시간 사용 시 느려지는 문제 해결 | -| UI 리팩토링 | 화면 디자인 개선 | -| 캐시 최적화 | 실행 속도 개선 | -| null 참조 오류 수정 | 예기치 않은 오류로 종료되는 문제 해결 | -| 인코딩 이슈 수정 | 한글이 깨져 보이는 문제 해결 | -| 동시성 버그 수정 | 여러 작업 동시 실행 시 오류 발생 문제 해결 | - -### 작성 규칙 -- **혜택 중심**: "무엇을 했다"가 아닌 "사용자에게 어떤 도움이 되는지" 설명 -- **간결**: 한 항목당 1~2문장 이내 -- **구체적**: "성능 개선" → "파일 열기 속도가 약 2배 빨라졌습니다" -- **긍정적 톤**: 문제를 "해결했습니다", 기능을 "추가했습니다" -- **이모지 활용**: 섹션별 시각적 구분 (새 기능: 🎉, 개선: ✨, 수정: 🐛, 주의: ⚠️) - -## 출력 형식 -- **Markdown**: 기본 출력 형식 (.md) -- **HTML**: html_create로 스타일이 적용된 웹 페이지 생성 -- **텍스트**: 이메일 본문용 서식 없는 텍스트 -- 사용자가 원하는 형식으로 제공 - -## 규칙 -- 내부 기술 구현 세부사항은 노출하지 않음 -- 사용자에게 무관한 변경(코드 리팩토링, 테스트 추가 등)은 제외 -- Breaking Change가 있으면 "이전 버전과 달라진 점" 섹션 추가 -- 업데이트 방법 안내를 포함 (인스톨러 경로, 주의사항) -- 최종 결과는 파일 저장과 클립보드 복사 모두 제공 - -한국어로 안내하세요. diff --git a/dist/AxCopilot/skills/report-writer.skill.md b/dist/AxCopilot/skills/report-writer.skill.md deleted file mode 100644 index 664802b..0000000 --- a/dist/AxCopilot/skills/report-writer.skill.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: report-writer -label: 보고서 작성 -description: 작업 폴더의 데이터를 분석하여 체계적인 업무 보고서를 생성합��다. -icon: \uE9F9 -allowed-tools: - - folder_map - - file_read - - file_write - - data_pivot - - chart_create - - template_render - - text_summarize -tabs: cowork ---- - -작업 폴더의 파일과 데이터를 분석하여 업무 보고서를 작성하세요. - -다음 도구를 사용하세요: -1. folder_map — 작업 폴더의 파일 구조 파악 -2. file_read — 관련 데이터 파일 읽기 (CSV, Excel, 텍스트) -3. file_write — 보고서 파일 생성 (HTML 또는 Markdown) - -보고서 구성: -## 제목 -- 작성 일시, 작성자 (요청 시) - -## 요약 (Executive Summary) -- 핵심 내용을 3줄 이내로 요약 - -## 본문 -- 데이터 기반 분석 결과 -- 표/차트를 활용한 시각적 정리 -- 주요 발견 사항 - -## 결론 및 제안 -- 결론 요약 -- 향후 조치 사항 - -HTML 보고서 생성 시 현재 적용된 디자인 무드를 반영하세요. -한국어로 작성하세요. diff --git a/dist/AxCopilot/skills/security-audit.skill.md b/dist/AxCopilot/skills/security-audit.skill.md deleted file mode 100644 index 0fb9144..0000000 --- a/dist/AxCopilot/skills/security-audit.skill.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: security-audit -label: 보안 코드 감사 -description: 코드베이스의 보안 취약점을 점검하고 OWASP 기반 보안 보고서를 생성합니다. -icon: \uE72E -allowed-tools: - - file_read - - grep - - folder_map - - search_codebase - - html_create - - glob -tabs: code ---- - -코드베이스의 보안 취약점을 체계적으로 점검하고 보고서를 생성하세요. - -## 워크플로우 - -1. **스캔 범위 확인**: folder_map으로 프로젝트 구조 파악, 언어/프레임워크 식별 -2. **취약점 패턴 탐지**: grep으로 위험 패턴 검색 -3. **의존성 분석**: 패키지 파일(package.json, *.csproj, requirements.txt) 읽기 -4. **비밀 정보 노출 검사**: API 키, 토큰, 비밀번호 하드코딩 탐지 -5. **보고서 생성**: html_create로 보안 감사 보고서 생성 - -## OWASP Top 10 점검 항목 - -### A01 — 접근 제어 취약 -- 인증 없는 API 엔드포인트 -- 하드코딩된 권한 체크 -- 관리자 경로 노출 - -### A02 — 암호화 실패 -- 평문 비밀번호 저장 -- 약한 해시 알고리즘 (MD5, SHA1) -- HTTP (비HTTPS) 통신 - -### A03 — 인젝션 -- SQL 인젝션 (문자열 결합 쿼리) -- XSS (innerHTML, dangerouslySetInnerHTML) -- 명령 인젝션 (Process.Start, exec, system) -- 경로 순회 (../ 미검증) - -### A04 — 불안전한 설계 -- 비즈니스 로직 검증 누락 -- 레이트 리밋 미적용 - -### A05 — 보안 설정 오류 -- 디버그 모드 활성화 상태 -- 기본 자격증명 사용 -- 불필요한 포트/서비스 노출 - -### A07 — 인증 실패 -- 약한 비밀번호 정책 -- 세션 만료 미설정 -- 브루트포스 방어 부재 - -### A09 — 로깅/모니터링 부족 -- 보안 이벤트 미기록 -- 민감 정보 로그 출력 - -## 비밀 정보 탐지 패턴 - -``` -grep 대상 패턴: -- password\s*=\s*["'][^"']+["'] -- api[_-]?key\s*=\s*["'][^"']+["'] -- secret\s*=\s*["'][^"']+["'] -- token\s*=\s*["'][A-Za-z0-9+/=]{20,}["'] -- -----BEGIN (RSA |EC )?PRIVATE KEY----- -- AWS_ACCESS_KEY_ID -- AKIA[0-9A-Z]{16} -``` - -## 출력 형식 - -### 보안 감사 보고서 -| 위험도 | 카테고리 | 파일 | 라인 | 내용 | 권장 조치 | -|--------|---------|------|------|------|----------| -| 🔴 심각 | A03 인젝션 | ... | ... | SQL 문자열 결합 | 파라미터 바인딩 사용 | -| 🟡 경고 | A02 암호화 | ... | ... | MD5 해시 사용 | SHA-256 이상 전환 | -| 🟢 참고 | A09 로깅 | ... | ... | 에러 로깅 미흡 | 보안 이벤트 로깅 추가 | - -### 요약 통계 -- 심각/경고/참고 건수 -- OWASP 카테고리별 분포 -- 우선 조치 항목 Top 5 - -## 규칙 -- 코드를 수정하지 않음 (분석 + 보고서만) -- 발견된 비밀 정보는 마스킹하여 보고 (앞 4자만 표시) -- 위험도는 보수적으로 평가 -- 한국어로 보고서 작성 diff --git a/dist/AxCopilot/skills/sql-report.skill.md b/dist/AxCopilot/skills/sql-report.skill.md deleted file mode 100644 index c385378..0000000 --- a/dist/AxCopilot/skills/sql-report.skill.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: sql-report -label: SQL 리포트 -description: DB 쿼리를 실행하고 결과를 차트화하여 HTML/Excel 보고서로 생성합니다. -icon: \uE968 -allowed-tools: - - sql_tool - - chart_create - - excel_create - - html_create - - data_pivot -tabs: cowork ---- - -데이터베이스에서 쿼리를 실행하고 결과를 시각화된 보고서로 생성하세요. - -## 워크플로우 - -1. **DB 확인**: sql_tool로 테이블 목록 및 스키마 확인 -2. **쿼리 작성**: 사용자 요청에 맞는 SQL 쿼리 작성 -3. **데이터 조회**: sql_tool로 쿼리 실행 -4. **데이터 가공**: data_pivot으로 집계/피벗 (필요 시) -5. **시각화**: chart_create로 차트 생성 -6. **보고서**: html_create 또는 excel_create로 최종 보고서 생성 - -## 쿼리 작성 원칙 -- SELECT 쿼리만 실행 (데이터 변경 금지) -- 결과 행 수 제한: LIMIT 1000 (대량 데이터 방지) -- 인덱스 활용 쿼리 작성 -- 한글 컬럼명은 alias로 변환 - -## 보고서 구성 - -### 1. 데이터 요약 -- 조회 조건, 기간, 데이터 건수 -- 기본 통계 (합계, 평균, 최대/최소) - -### 2. 시각화 -- 적절한 차트 유형 자동 선택 -- 비교 → 바 차트 -- 추세 → 라인 차트 -- 비율 → 파이/도넛 차트 - -### 3. 상세 데이터 -- Excel: 원본 데이터 + 서식 + 수식 -- HTML: 테이블 + 정렬/필터 - -## 규칙 -- DB 경로는 사용자에게 확인 -- 민감 데이터(개인정보) 마스킹 권고 -- 쿼리 실행 전 사용자 승인 -- 한국어로 보고서 작성 diff --git a/dist/AxCopilot/skills/translate.skill.md b/dist/AxCopilot/skills/translate.skill.md deleted file mode 100644 index 1ec0f4d..0000000 --- a/dist/AxCopilot/skills/translate.skill.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: translate -label: 번역 + 교정 -description: 문서나 텍스트를 번역하고 전문 용어를 보존하며 교정합니다. -icon: \uE8C1 -allowed-tools: - - file_read - - file_write - - document_read - - clipboard_tool -tabs: all ---- - -사용자가 제공한 텍스트 또는 문서를 번역하고 교정하세요. - -## 워크플로우 - -1. **입력 확인**: 텍스트 직접 입력 또는 파일 경로 확인 -2. **언어 감지**: 원문 언어를 자동 감지 -3. **번역 실행**: - - 원문의 뉘앙스와 문맥을 살려 번역 - - 전문 용어는 원어를 괄호 안에 병기: "수율(Yield)" - - 문화적 차이를 고려한 자연스러운 표현 사용 -4. **교정**: 번역 결과의 문법, 맞춤법, 어색한 표현 수정 -5. **결과 제공**: 번역문 + 주요 용어 대조표 - -## 번역 원칙 - -### 정확성 -- 원문의 의미를 정확하게 전달 -- 숫자, 날짜, 고유명사는 원문 그대로 유지 -- 약어는 처음 등장 시 풀어 번역 - -### 가독성 -- 목표 언어의 자연스러운 문체 사용 -- 긴 문장은 적절히 분리 -- 수동태 → 능동태 변환 (한국어의 경우) - -### 전문성 -- 분야별 전문 용어 일관 사용 -- 동일 용어는 문서 전체에서 통일 -- 번역 불가능한 용어는 원어 유지 - -## 출력 형식 - -``` -## 번역 결과 - -[번역된 텍스트] - ---- - -## 용어 대조표 -| 원문 | 번역 | 비고 | -|------|------|------| -| Yield | 수율 | 반도체 공정 용어 | -``` - -## 지원 언어 -한국어 ↔ 영어 / 일본어 / 중국어 (간/번체) / 독일어 / 프랑스어 / 스페인어 diff --git a/dist/AxCopilot/skills/weekly-report.skill.md b/dist/AxCopilot/skills/weekly-report.skill.md deleted file mode 100644 index 304d257..0000000 --- a/dist/AxCopilot/skills/weekly-report.skill.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: weekly-report -label: 주간 보고서 -description: 작업 폴더의 변경 이력을 기반으로 자동 주간보고 초안을 생성합니다. -icon: \uE787 -allowed-tools: - - git_tool - - folder_map - - file_read - - file_watch - - html_create - - docx_create - - text_summarize -tabs: cowork ---- - -작업 폴더의 최근 활동을 분석하여 주간 보고서 초안을 자동 생성하세요. - -## 워크플로우 - -1. **활동 수집**: - - git_tool로 최근 7일간 커밋 이력 조회 - - file_watch로 최근 변경/생성된 파일 목록 확인 - - folder_map으로 프로젝트 구조 파악 - -2. **내용 분석**: - - 커밋 메시지 분류 (기능 추가, 버그 수정, 리팩토링 등) - - 변경 파일 유형별 분류 - - 주요 변경사항 요약 - -3. **보고서 작성**: - - HTML 또는 Word 형식으로 보고서 생성 - - 차트/표로 활동 통계 시각화 - -## 보고서 구성 - -### 1. 주간 요약 -- 보고 기간: yyyy-MM-dd ~ yyyy-MM-dd -- 주요 성과 (3줄 이내) - -### 2. 완료 항목 -| 번호 | 구분 | 내용 | 비고 | -|------|------|------|------| -| 1 | 기능 | ... | 커밋 참조 | - -### 3. 진행 중 항목 -- 현재 작업 중인 사항 -- 진척률 (가능하면) - -### 4. 이슈 및 리스크 -- 지연 사항 -- 블로킹 이슈 - -### 5. 다음 주 계획 -- 예정 작업 항목 - -### 6. 활동 통계 (차트) -- 일별 커밋 수 -- 파일 유형별 변경 비율 - -## 규칙 -- 사실 기반으로 작성 (추측 금지) -- Git 이력이 없으면 파일 변경 이력만으로 작성 -- 한국어로 작성 -- 보고서 톤: 간결하고 전문적 diff --git a/dist/AxCopilot/skills/xlsx-analyzer.skill.md b/dist/AxCopilot/skills/xlsx-analyzer.skill.md deleted file mode 100644 index 2ede30f..0000000 --- a/dist/AxCopilot/skills/xlsx-analyzer.skill.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: xlsx-analyzer -label: Excel 데이터 분석 -description: Python을 사용하여 Excel/CSV 데이터를 분석하고 보고서를 생성합니다. 작업 폴더의 양식 파일을 자동 활용합니다. -icon: \uE9F9 -allowed-tools: - - folder_map - - document_read - - file_read - - file_write - - process - - data_pivot - - chart_create - - template_render -tabs: cowork ---- - -작업 폴더의 Excel 또는 CSV 데이터를 Python으로 분석하세요. -## 실행 경로 선택 (Python 가능/불가) -- 먼저 `process`로 `python --version`을 확인하세요. -- Python 가능: 기존 pandas/openpyxl 경로를 사용하세요. -- Python 불가: `data_pivot`으로 분석 요약을 만들고 `chart_create` + `file_write`로 결과 리포트를 생성하세요. - - -## 사전 준비 -필요한 패키지를 확인하고 설치하세요: -``` -process: pip install pandas openpyxl -``` - -## 양식 활용 (Excel 보고서 템플릿) -작업 폴더에 Excel 양식 파일이 있으면 **반드시** 활용하세요: - -1. **양식 탐색**: `folder_map`으로 작업 폴더에서 `.xlsx` 파일 목록 확인 -2. **양식 후보 판별**: - - 파일명에 "양식", "template", "서식", "표준", "기본", "보고서양식" 포함 - - 또는 사용자가 "XX 양식에 맞춰서 작성해줘" 요청 - - 또는 사용자가 특정 .xlsx 파일명을 양식으로 지정 -3. **양식 구조 파악**: `document_read`로 양식의 시트 구조, 셀 레이아웃 확인 -4. **양식 기반 데이터 삽입**: - ```python - from openpyxl import load_workbook - wb = load_workbook('양식_보고서.xlsx') - ws = wb.active - # 양식의 서식(셀 병합, 테두리, 글꼴, 색상, 열 너비)이 그대로 유지됨 - # 데이터 영역에만 새 값 삽입 - ws['B3'] = '분석 결과값' - wb.save('결과_보고서.xlsx') - ``` -5. **양식이 없으면**: 아래 기본 방식으로 분석 결과 생성 - -## 작업 절차 -1. **데이터 파일 탐색**: folder_map으로 작업 폴더에서 .xlsx, .csv 파일 확인 -2. **양식 확인**: 양식 .xlsx 파일이 있는지 확인 (데이터 파일과 양식 파일 구분) -3. **데이터 읽기**: file_read 또는 document_read로 파일 구조 파악 -4. **분석 스크립트 작성**: file_write로 Python 분석 스크립트 생성 -5. **실행**: `process`로 스크립트 실행 -6. **결과 보고**: 분석 결과를 사용자에게 정리하여 전달 - -## 분석 스크립트 템플릿 -```python -import pandas as pd -import json -import os - -df = pd.read_excel('data.xlsx') # 또는 pd.read_csv('data.csv') - -report = { - 'shape': list(df.shape), - 'columns': list(df.columns), - 'dtypes': {col: str(dtype) for col, dtype in df.dtypes.items()}, - 'missing': df.isnull().sum().to_dict(), - 'describe': df.describe().to_dict(), -} - -# 양식 파일로 결과 내보내기 -template_keywords = ['양식', 'template', '서식', '표준', '기본'] -template_file = None -for f in os.listdir('.'): - if f.endswith('.xlsx') and f != 'data.xlsx' and any(kw in f.lower() for kw in template_keywords): - template_file = f - break - -if template_file: - from openpyxl import load_workbook - wb = load_workbook(template_file) - ws = wb.active - # 양식 서식 유지하면서 데이터 삽입 - print(f'양식 활용: {template_file}') - # TODO: 양식 구조에 맞게 데이터 삽입 로직 작성 - wb.save('결과_보고서.xlsx') -else: - # 양식 없으면 JSON으로 저장 - with open('analysis_result.json', 'w', encoding='utf-8') as f: - json.dump(report, f, ensure_ascii=False, indent=2, default=str) - -print(json.dumps(report, ensure_ascii=False, indent=2, default=str)) -``` - -## 지원 분석 -- 기본 통계 (평균, 중앙값, 표준편차, 분위수) -- 결측치 분석 -- 컬럼별 고유값 분포 -- 피벗 테이블 / 그룹별 집계 -- 시트 간 비교 분석 -- 필터링 및 조건부 추출 -- 분석 결과를 새 Excel로 내보내기 -- **양식 파일 기반 보고서 생성** (셀 서식, 병합, 테두리, 차트 영역 유지) - -한국어로 안내하세요. 원본 파일은 수정하지 마세요. diff --git a/dist/AxCopilot/skills/yield-analysis.skill.md b/dist/AxCopilot/skills/yield-analysis.skill.md deleted file mode 100644 index a9b6038..0000000 --- a/dist/AxCopilot/skills/yield-analysis.skill.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: yield-analysis -label: 수율 분석 -description: 제조/연구 데이터의 수율을 분석하고 개선 포인트를 도출합니다. -icon: \uE9D2 -allowed-tools: - - folder_map - - file_read - - file_write - - data_pivot - - chart_create - - text_summarize - - template_render -tabs: cowork ---- - -작업 폴더의 데이터를 활���하여 수율 분석을 수행하세요. - -다음 도구를 사용하세요: -1. folder_map — 데이터 파일 탐색 -2. file_read — CSV/Excel 데이터 읽기 -3. file_write — 분석 보고서 생성 - -분석 프로세스: -## 데이터 개요 -- 분석 대상 데이터셋 설명 -- 데이터 기간, 샘플 수, 주요 변수 - -## 수율 현황 -- 전체 수율 통계 (평균, 중앙값, 표준편차) -- 기간별/로트별/공정별 수율 추이 -- 목표 수율 대비 달성률 - -## 불량 분석 -- 불량 유형별 분류 및 비율 -- 파레토 분석 (상위 불량 원인) -- 시간대/조건별 불량 패턴 - -## 상관관계 분석 -- 주요 공정 변수와 수율의 관계 -- 이상치 탐지 및 원인 추정 - -## 개선 제안 -- 수율 향상을 위한 구체적 조치 사항 -- 우선순위별 개선 로드맵 -- 예상 개선 효과 - -표와 수치를 적극 활용하세요. 한국어로 작성하세요. diff --git a/dist/AxCopilot/system_prompt.txt b/dist/AxCopilot/system_prompt.txt deleted file mode 100644 index 527f32d..0000000 --- a/dist/AxCopilot/system_prompt.txt +++ /dev/null @@ -1 +0,0 @@ -[역할] 당신은 회사 업무의 도움을 주는 매우 유능한 비서입니다. 말투는 정중하게 하며, 욕을 해서는 안됩니다. \ No newline at end of file diff --git a/dist/AxCopilot_Setup.exe b/dist/AxCopilot_Setup.exe deleted file mode 100644 index 3c5d52b..0000000 Binary files a/dist/AxCopilot_Setup.exe and /dev/null differ diff --git a/dist/AxKeyEncryptor/AxKeyEncryptor.dll b/dist/AxKeyEncryptor/AxKeyEncryptor.dll deleted file mode 100644 index 66bc8b2..0000000 Binary files a/dist/AxKeyEncryptor/AxKeyEncryptor.dll and /dev/null differ diff --git a/dist/AxKeyEncryptor/AxKeyEncryptor.exe b/dist/AxKeyEncryptor/AxKeyEncryptor.exe deleted file mode 100644 index 371b976..0000000 Binary files a/dist/AxKeyEncryptor/AxKeyEncryptor.exe and /dev/null differ diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 031d1fc..b1ac98c 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -9,13 +9,13 @@ - v0.7.2: 컨텍스트 압축/복원 안정화, UX/성능 보완. ## 2. 재작성 기준 -- 기준 레퍼런스: `claw-code/claw-code-f5a40b86dede580f6543bf8926c9af017eea9409/src`. +- 기준 레퍼런스: `OpenCode/OpenCode-f5a40b86dede580f6543bf8926c9af017eea9409/src`. - 목표: 동작 순서, 예외/검증 흐름, 세션 내구성에서 동일 품질 달성. - 원칙: AX 코드 스타일로 재구성(표현/구조 변경), 기능적 동등성 유지. ## 3. 2026 실행 계획 (v0.7.3 ~ v0.8.0) -| Phase | 참조 대상 (`claw-code`) | AX 적용 위치 | 완료 조건 | 품질 판정 시나리오 | +| Phase | 참조 대상 (`OpenCode`) | AX 적용 위치 | 완료 조건 | 품질 판정 시나리오 | |---|---|---|---|---| | A (v0.7.3) Hook/Permission 계약 고도화 | `src/utils/hooks.ts`, `src/utils/hooks/hookEvents.ts`, `src/utils/permissions/PermissionUpdate.ts`, `src/utils/permissions/permissionSetup.ts` | `src/AxCopilot/Services/Agent/AgentHookRunner.cs`, `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml` | Hook JSON 출력(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 완료. Plain text 훅 하위호환 유지. 훅 실패 비차단 유지. | Pre-hook 입력 변형 적용 시 도구 입력이 실제 변경되는지 검증. 권한 업데이트 후 동일 run 내 권한 판정이 즉시 반영되는지 검증. | | B (v0.7.4) Plan/Run 상태 내구성 강화 | `src/utils/plans.ts`, `src/utils/sessionStorage.ts` | `src/AxCopilot/Services/ChatSessionStateService.cs`, `src/AxCopilot/Services/TaskRunService.cs`, `src/AxCopilot/Services/TaskRunStore.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs` | Plan 단계 승인/수정 이력 영속화. 앱 재시작 후 run 재개 시 마지막 실행 지점 정확 복원. 중복 실행/중복 이벤트 0건. | 실행 중 앱 종료 → 재실행 → 같은 탭/대화에서 run 상태와 최근 실행 이력이 일치하는지 검증. | @@ -65,7 +65,7 @@ - 2026-04-04(추가3): 권한 UX 통합(/permissions·/allowed-tools·/settings permissions), 복구 혼합 테스트 보강, 좌측 패널 실패 필터 노출 정책 rich 전용으로 정렬. -- 2026-04-04 20:27 (KST): Added a dedicated AX Agent theme layer and separated in-chat theme selection into style (`claw/codex/slate`) and mode (`system/light/dark`). +- 2026-04-04 20:27 (KST): Added a dedicated AX Agent theme layer and separated in-chat theme selection into style (`claude/codex/slate`) and mode (`system/light/dark`). - 2026-04-04 18:03 (KST): Added a Codex-style context usage ring to the composer, exposed direct `/compact` execution from the footer, and surfaced Git branch/change status in the Cowork/Code footer when a repository is connected. - 2026-04-04 18:03 (KST): Simplified the Cowork footer by removing document format/design selectors and moved those defaults into the AX Agent settings page, with output format centered on AI automatic selection. - 2026-04-04 18:03 (KST): Minimized AX Agent message headers, changed model quick controls to a popup panel, flattened slash command rows into a Codex-style list, and aligned model/permission/template controls to the same dropdown panel language. @@ -79,12 +79,12 @@ - 2026-04-04 19:07 (KST): Refined message actions into custom pill buttons with visible labels, fixed the custom action template so border visuals render correctly, and tightened composer/footer density so model, preset, token, permission, and Git controls feel like one system. - 2026-04-04 19:11 (KST): Reworked the AX Agent timeline so execution events render like thin Codex-style inline logs instead of heavy banners, softened older-history loading into an outline pill, and aligned planning cards to the same message-width rhythm. - 2026-04-04 19:25 (KST): Tightened user/assistant/streaming cards under one density rule by compressing vertical spacing, card padding, header typography, and message meta spacing to a more Codex-like rhythm. -- 2026-04-04 19:25 (KST): Flattened the composer further by reducing input, model, preset, token, and footer control heights so the bottom work area reads more like a single claw-code-style strip. +- 2026-04-04 19:25 (KST): Flattened the composer further by reducing input, model, preset, token, and footer control heights so the bottom work area reads more like a single OpenCode-style strip. - 2026-04-04 19:25 (KST): Matched AX Agent overlay quick settings to inline quick settings with the same `label · value` structure, lighter explanatory copy, and unified section naming. - 2026-04-04 19:48 (KST): Fixed Chat sidebar topic exploration so clicking the left topic entry surfaces the preset cards again, and made preset selection immediately sync category label, conversation metadata, and saved state. - 2026-04-04 19:48 (KST): Reduced permission/data-usage UI lag by synchronizing conversation settings before persistence, switching popup rows to immediate mouse-down activation, and refreshing overlay status labels in the same turn. - 2026-04-04 19:48 (KST): Widened the AX Agent message lane, softened the yellow cast of the Claude-style theme preset, aligned the data-usage button with the permission button language, and routed the tray settings entry directly into AX Agent settings when AI is enabled. -- 2026-04-04 20:41 (KST): Permission surface re-aligned to the four claw-code public modes, legacy `질문 없이 진행` exposure removed from AX Agent popups/slash guidance, and the in-chat settings/sidebar information hierarchy simplified to match the lighter Claude/Codex-style layout direction. +- 2026-04-04 20:41 (KST): Permission surface re-aligned to the four OpenCode public modes, legacy `질문 없이 진행` exposure removed from AX Agent popups/slash guidance, and the in-chat settings/sidebar information hierarchy simplified to match the lighter Claude/Codex-style layout direction. - 업데이트: 2026-04-04 22:05 (KST) - AX Agent 고급 설정 오버레이를 설명형 row + 커스텀 On/Off 선택 구조로 개편하고, 내부 기능명을 사용자 친화 문구로 정리함. - 업데이트: 2026-04-04 20:58 (KST) @@ -104,3 +104,127 @@ - ????: 2026-04-04 23:32 (KST) - AX Agent ???? ??? ?? tool-result ?? -> microcompact ?? ?? ?? -> ?? ?? ?? 3?? ??? ???, claude-code? staged compact ??? ? ??? ? ?? ??? ?? ????? ???. - ??? ?? ??, tool_result, ?? ???, ???? ? ???? LLM ?? ?? microcompact_boundary? ?? ??? ?? ?? ?? ?? ?? ?? ??? ??? ???? ?? ??. + +--- + +## 8. 지능형 에이전트 고도화 로드맵 (oh-my-openagent 참조) + +> 기준: oh-my-openagent 아키텍처 분석 결과에서 단일 모델 환경에서도 효과적인 기능을 우선 채택. +> 원칙: AX 기존 인프라(`ClassifyTaskType`, `SubAgentTool`, `IntentDetector`, `TaskTypePolicy`, `ContextCondenser`) 위에 점진적으로 확장. + +### 8-1. 즉시 개발 (P1~P5) + +#### P1. IntentGate — 고급 의도 분류기 +- **목적**: 사용자 입력을 분석하여 최적 실행 프로파일(system prompt + temperature + tool 권한 + 최대 반복)을 자동 선택 +- **현재 상태**: `ClassifyTaskType()` (키워드 6분류) + `IntentDetector` (키워드 7분류, UI 힌트용) 존재하나 실행 프로파일 연동 없음 +- **구현 계획**: + 1. `IntentGateService.cs` 신규 — 2단계 분류기 + - Stage 1: 키워드 기반 빠른 분류 (기존 `ClassifyTaskType` + `IntentDetector` 통합) + - Stage 2: LLM 1-shot 분류 (복합 요청 시만 발동, 토큰 최소화) + 2. `ExecutionProfile` 확장 — 분류 결과별 프로파일 매핑 + - `code_edit`: temperature 0.3, 코드 도구 우선, 최대 반복 25 + - `document_create`: temperature 0.7, 문서 도구 우선, 최대 반복 15 + - `analysis`: temperature 0.4, 검색/읽기 도구 우선, 최대 반복 20 + - `review`: temperature 0.2, 읽기 전용 도구, 최대 반복 15 + - `general_chat`: 도구 없음, temperature 0.8 + - `complex_task`: 전체 도구, temperature 0.5, SubAgent 활성화 + 3. `AgentLoopService.RunAsync()` 진입부에서 `IntentGateService.Classify()` 호출 → 프로파일 적용 +- **수정 파일**: `IntentGateService.cs`(신규), `AgentLoopService.cs`, `AppSettings.cs`, `TaskTypePolicy.cs` +- **검증**: 코드수정/문서생성/분석/리뷰/잡담 5개 시나리오에서 올바른 프로파일 선택 확인 + +#### P2. 카테고리 기반 서브에이전트 실행 프로파일 +- **목적**: 같은 모델이지만 작업 유형별로 다른 system prompt + tool 권한 + temperature를 적용하는 "가상 멀티에이전트" +- **현재 상태**: `SubAgentTool`은 고정 system prompt + 읽기 전용 도구만 사용 +- **구현 계획**: + 1. `SubAgentProfile.cs` 신규 — 프로파일 정의 클래스 + - `researcher`: 읽기 전용, 검색 집중, temperature 0.3 + - `coder`: 파일 편집 가능, 빌드/테스트 도구 포함, temperature 0.2 + - `writer`: 문서 생성 도구 활성, temperature 0.7 + - `reviewer`: 코드리뷰 도구 + diff, temperature 0.2 + - `planner`: 계획/분해 도구, temperature 0.5 + 2. `SubAgentTool` 확장 — `profile` 파라미터 추가 + - 프로파일별로 system prompt, 도구 레지스트리, temperature를 자동 구성 + - 기존 동작(profile 미지정)은 `researcher`로 폴백 + 3. `IntentGateService`가 복합 요청 감지 시 자동으로 서브에이전트 프로파일 추천 +- **수정 파일**: `SubAgentProfile.cs`(신규), `SubAgentTool.cs`, `AgentLoopService.cs` +- **검증**: "코드 수정하고 문서도 만들어줘" → coder + writer 프로파일 분리 실행 확인 + +#### P3. 누적 학습 (Accumulated Learnings) +- **목적**: 세션 내 발견사항(에러 패턴, 파일 구조, 성공 전략)을 자동 수집하여 후속 반복에 주입 +- **현재 상태**: `AgentMemoryService`에 장기 메모리 존재하나, 세션 내 단기 학습 전파 없음 +- **구현 계획**: + 1. `SessionLearningCollector.cs` 신규 + - 도구 실행 결과에서 자동 학습 포인트 추출: + - 빌드 에러 → "이 프로젝트는 .NET 8, nullable 활성" + - grep 결과 → "이 패턴은 Services/ 폴더에 집중" + - 파일 구조 → "테스트는 Tests/ 폴더, 네이밍 규칙 *Tests.cs" + - 최대 10개 항목, FIFO 관리 + 2. `AgentLoopService` — 각 반복 시작 시 누적 학습을 컨텍스트에 주입 + - `[System:SessionLearnings]` 메시지로 삽입 (압축 시 보존) + 3. `ContextCondenser` — 학습 메시지는 압축 대상에서 제외 +- **수정 파일**: `SessionLearningCollector.cs`(신규), `AgentLoopService.cs`, `ContextCondenser.cs` +- **검증**: 빌드 에러 발생 후 다음 반복에서 동일 실수 반복 안 하는지 확인 + +#### P4. 워크스페이스 컨텍스트 자동 생성 (.ax-context) +- **목적**: 작업 폴더의 구조/기술스택/컨벤션을 파일로 자동 생성 → 서브에이전트 컨텍스트 효율화 +- **현재 상태**: `ProjectRuleTool`이 `.ax-rules` 파일을 읽지만 자동 생성 없음 +- **구현 계획**: + 1. `WorkspaceContextGenerator.cs` 신규 + - 작업 폴더 최초 접근 시 자동 분석: + - 파일 트리 (깊이 3), 주요 확장자 분포, 빌드 시스템 감지 + - README/package.json/csproj 등에서 프로젝트 메타 추출 + - 기존 `.ax-rules`, `.clinerules`, `CLAUDE.md` 존재 여부 + - 결과를 `.ax-context.md`로 저장 (1회 생성, 수동 갱신) + 2. `SubAgentTool` — 서브에이전트 생성 시 `.ax-context.md` 내용을 system prompt에 자동 주입 + 3. `AgentLoopService` — 메인 에이전트도 `.ax-context.md` 존재 시 컨텍스트에 포함 +- **수정 파일**: `WorkspaceContextGenerator.cs`(신규), `SubAgentTool.cs`, `AgentLoopService.cs`, `ProjectRuleTool.cs` +- **검증**: 새 작업 폴더 선택 → `.ax-context.md` 자동 생성 → 서브에이전트가 프로젝트 구조 인지 확인 + +#### P5. 병렬 서브에이전트 실행 확장 +- **목적**: LLM이 한 번의 응답에서 여러 서브에이전트를 동시 생성/실행 +- **현재 상태**: `SubAgentTool`은 개별 호출 가능하나 LLM이 연속 호출해야 함. `AgentLoopParallelExecution`에서 병렬 도구 실행 지원 +- **구현 계획**: + 1. `spawn_agents` (복수형) 도구 신규 — 배열로 여러 서브에이전트를 한 번에 생성 + - `tasks: [{id, task, profile}, ...]` 형태 + 2. `IntentGateService` — 복합 요청 감지 시 자동 분해 + - "A 분석하고 B 코드 수정해줘" → 두 개의 서브에이전트로 자동 분해 제안 + 3. `SubAgentTool` — 동시 실행 상한을 설정에서 조정 가능 (기본 3 → 5) + 4. UI: 서브에이전트 진행 상태를 타임라인에 병렬로 표시 +- **수정 파일**: `SpawnAgentsTool.cs`(신규), `SubAgentTool.cs`, `AgentLoopService.cs`, `AppSettings.cs` +- **검증**: 3개 서브에이전트 동시 실행 → 결과 수집 → 통합 응답 확인 + +### 8-2. 추후 개발 (P6~P7) + +#### P6. 폴백 체인 (Fallback Chains) +- **목적**: 실행 실패 시 다른 프로파일/전략으로 자동 재시도 +- **구현 방향**: IntentGate 프로파일에 `fallback` 속성 추가. 1차 실패 → 프로파일 전환 → 재시도 (최대 2회) +- **선행 조건**: P1(IntentGate) + P2(카테고리 프로파일) 완료 후 +- **예상 파일**: `IntentGateService.cs`, `AgentLoopService.cs` + +#### P7. 모델 성격 매칭 (Model Personality Matching) +- **목적**: 멀티 모델 환경에서 작업 유형별 최적 모델 자동 선택 +- **구현 방향**: `RegisteredModel`에 `strengths` 속성 추가 (coding/writing/analysis/speed). IntentGate 분류 결과 + 모델 강점 매칭으로 최적 모델 라우팅 +- **선행 조건**: 멀티 모델 환경 확보 + P1(IntentGate) 완료 후 +- **예상 파일**: `IntentGateService.cs`, `ModelRouterService.cs`, `AppSettings.cs` + +### 8-3. 구현 순서 및 의존 관계 + +``` +P1 (IntentGate) ─────┬──→ P2 (카테고리 프로파일) ──→ P5 (병렬 확장) + │ + ├──→ P3 (누적 학습) [독립] + │ + └──→ P4 (워크스페이스 컨텍스트) [독립] + +P1 + P2 완료 후 ──→ P6 (폴백 체인) +P1 + 멀티모델 후 ──→ P7 (모델 성격 매칭) +``` + +### 8-4. 단일 모델에서의 멀티에이전트 원리 + +> Claude Code, Codex, Cursor 등 주요 AI 코딩 도구는 모두 **단일 모델 + 다른 시스템 프롬프트/도구 권한/temperature** 조합으로 멀티에이전트를 구현합니다. +> +> - 메인 에이전트: 범용 system prompt + 전체 도구 + 중간 temperature +> - 서브에이전트: 특화 system prompt + 제한된 도구 + 작업별 temperature +> +> AX Copilot도 이 패턴을 따릅니다. `SubAgentTool`이 이미 존재하므로, **프로파일 체계화(P2)**만으로 사실상 멀티에이전트가 됩니다. diff --git a/docs/AX_AGENT_REGRESSION_PROMPTS.md b/docs/AX_AGENT_REGRESSION_PROMPTS.md index 51c70fa..b4816c7 100644 --- a/docs/AX_AGENT_REGRESSION_PROMPTS.md +++ b/docs/AX_AGENT_REGRESSION_PROMPTS.md @@ -2,7 +2,7 @@ 업데이트: 2026-04-08 10:38 (KST) -`claw-code`와 AX Agent를 같은 기준으로 비교하기 위한 공통 회귀 프롬프트 세트입니다. +`OpenCode`와 AX Agent를 같은 기준으로 비교하기 위한 공통 회귀 프롬프트 세트입니다. ## 사용 규칙 diff --git a/docs/AX_Copilot_Internal_Seminar.html b/docs/AX_Copilot_Internal_Seminar.html new file mode 100644 index 0000000..34fc1da --- /dev/null +++ b/docs/AX_Copilot_Internal_Seminar.html @@ -0,0 +1,1347 @@ + + + + + +AX Copilot - 내부 기술 세미나 + + + + + + + + + + + +
+
Internal Tech Seminar
+

AX Copilot

+

WPF 데스크톱 기반 AI 에이전트 플랫폼의
핵심 아키텍처와 차별화 전략

+
+ WPF / .NET 8 + Agentic AI Loop + 91+ Tools + Multi-Model + MCP Protocol +
+
+ + +
+ + + +
+
+ 1 +

오픈소스 기반 핵심 엔진 구성

+
+

AX Copilot의 에이전트 엔진은 OpenHands(전 OpenDevin)와 OpenCode 두 프로젝트의 아키텍처 패턴을 분석하여, WPF 데스크톱 환경에 맞게 재설계한 결과물입니다.

+ +

1.1 OpenHands 반영 사항

+ +
+
MemGPT 스타일 다단계 컨텍스트 압축 ContextCondenser
+

OpenHands의 AgentController가 수행하는 메모리 관리 전략을 5단계 파이프라인으로 구현. 단계 1~4는 LLM 호출 없이 수행하여 비용을 절감하고, 5단계에서만 LLM 1회 호출.

+
+ +
+
Plan-and-Solve 분해 전략 TaskDecomposer
+

OpenHands CodeActAgent의 태스크 분해 패턴 반영. LLM 응답에서 번호 매긴 단계를 추출 (TaskDecomposer.ExtractSteps()), 현재 도구 호출을 계획 단계에 매칭하여 실시간 진행률을 UI에 표시합니다.

+
+ +
+
도구 병렬 실행 + 프리페치 AgentLoopParallelExecution
+

읽기 전용 도구를 자동 분류하여 최대 12개까지 병렬 실행. 스트리밍 응답 도중에도 읽기 도구를 선행 실행(Prefetch)하여 대기 시간 최소화.

+
+ +

1.2 OpenCode 반영 사항

+ +
+
실행 엔진 3단계 분리 AxAgentExecutionEngine
+

OpenCode의 Prepare → Execute → Commit 분리 패턴을 답습합니다.

+
    +
  • ResolveExecutionMode() — 탭/설정에 따라 에이전트 루프 vs 단순 LLM 호출 결정
  • +
  • PrepareExecution() — 시스템 프롬프트 스택 + 메시지 조립
  • +
  • ExecutePreparedAsync() — agentLoopRunner 또는 llmRunner로 분기
  • +
  • CommitAssistantMessage() — 결과 메시지 영속화
  • +
+
+ +
+
스트리밍 도구 실행 + 자동 복구 StreamingToolExecutionCoordinator
+

SSE 스트리밍 중 도구 블록 도착 즉시 실행. 컨텍스트 오버플로우 자동 감지 → 강제 압축, 일시적 네트워크 오류 → 지수 백오프 재시도 포함.

+
+ +
+ 💡 +
+ 설계 철학
+ OpenHands의 자율적 다단계 태스크 수행 + OpenCode의 경량 실행 분리를 결합하되, + WPF 데스크톱 특성(UI 스레드 분리, 로컬 파일 시스템, 시스템 트레이 통합)에 맞춰 재설계. +
+
+
+ + +
+
+ 2 +

Agentic Loop 동작 구조

+
+ +

2.1 4단계 실행 사이클

+

에이전트 루프는 Plan → Execute → Observe → Evaluate 사이클을 작업 완료까지 자율 반복합니다.

+ +
+
+
Phase 1
+
Planning
+
LLM이 도구 호출 계획 생성
+
+ +
+
Phase 2
+
Execute
+
읽기 병렬 + 쓰기 순차 실행
+
+ +
+
Phase 3
+
Observe
+
도구 결과 수집 + 이벤트 발행
+
+ +
+
Phase 4
+
Evaluate
+
LLM이 결과 판단 + 다음 행동
+
+
+ +

AgentLoopService 핵심 기능

+
    +
  • 실행 중 사용자 개입ConcurrentQueue로 실행 중에도 사용자 메시지 수신 (Claude Code 스타일 mid-execution steering)
  • +
  • 일시정지/재개SemaphoreSlim 기반 Pause/Resume 제어
  • +
  • 반복 제한MaxAgentIterations (기본 25, 최대 200) 초과 시 자동 중단
  • +
  • 이벤트 스트림 — ToolCall, ToolResult, Thinking, Planning, Complete, Error 이벤트를 UI에 실시간 전달
  • +
+ +

2.2 전체 아키텍처 다이어그램

+ +
+------------------------------------------------------------------+ +| AX Copilot UI Layer | +| ChatWindow -- TranscriptHost -- AgentEventProcessor | +| | | | | +| v v v | +| V1 Render V2 Render AgentEvent stream | ++----------+----------------------------+---------------------------+ + | | ++----------v----------------------------v---------------------------+ +| AxAgentExecutionEngine | +| ResolveMode -> PrepareExecution -> ExecutePrepared -> Commit | ++----------+----------------------------+---------------------------+ + | | ++----------v-----------+ +------------v----------------------------+ +| AgentLoopService | | LlmService (Streaming) | +| | | Ollama / vLLM / Gemini / Claude | +| Plan -> Execute -> | | Override Stack (Push/Pop) | +| Observe -> Evaluate | | Token Tracking per call | ++-----------+-----------+ +---------------------------------------------+ + | ++-----------v---------------------------------------------------------+ +| ToolRegistry (91+ Tools) | +| File Ops | Document | Code/Git | System | MCP Dynamic | +| Parallel | Plan+ | Worktree | Notify | JSON-RPC stdio | +| Batch | Assemble | Branch | Env | mcp_{srv}_{fn} | ++-----------+---------------------------------------------------------+ + | ++-----------v---------------------------------------------------------+ +| Permission / Security Layer | +| OperationModePolicy (Internal/External) | +| PermissionModeCatalog (Default/AcceptEdits/Plan/Bypass/Deny) | +| Tool-level overrides with wildcard/pattern matching | ++---------------------------------------------------------------------+
+ +

2.3 도구 시스템 (91+ Tools)

+ +
+
91+
내장 도구
+
3
노출 버킷
+
12
최대 병렬 실행
+
+ +

도구 카테고리

+ + + + + + + + + + + +
카테고리주요 도구설명
파일 조작file_read file_write file_edit glob grep파일 읽기/쓰기/검색 + folder_map
문서 생성html_create docx_create xlsx_create pptx_create8가지 Mood 템플릿 + Multi-pass 조립
코드/Gitgit_tool code_search build_run test_loop브랜치 격리, 코드 리뷰, CI 연동
시스템process env_tool clipboard notify프로세스 실행, 환경 변수, 알림
데이터json_tool sql_tool math_tool data_pivot구조화 데이터 처리 + 피벗
워크플로우spawn_agent enter_worktree checkpoint playbook에이전트 분기, 체크포인트, 자동화
MCP 동적mcp_{server}_{fn}JSON-RPC 2.0 over stdio 외부 도구 서버
+
+ +
+ + +
+
+ 3 +

컨텍스트 관리 + 보안

+
+ +

3.1 5단계 압축 파이프라인

+

모델 입력 한도의 80%에 도달하면 자동으로 트리거. 단계 1~4는 LLM 호출 없이 수행하여 비용 절감.

+ +
+
+ Stage 1 +
+
Tool Result 절삭
+
LLM 호출 없음. 도구 결과를 1,500자 제한 (head 220자 + tail 140자 보존). 가장 빈번하게 적용.
+
+
+
+ Stage 2 +
+
Session Memory 통합
+
LLM 호출 없음. 이전 압축 경계의 요약들을 단일 세션 메모리 메시지로 병합.
+
+
+
+ Stage 3 +
+
Microcompact
+
LLM 호출 없음. 오래된 실행 로그/도구 묶음을 한 줄 요약으로 교체. 단일 결과 480자 제한.
+
+
+
+ Stage 4 +
+
Collapse / Snip
+
LLM 호출 없음. 남은 긴 로그를 head+tail 스니펫으로 절삭. 증거 보존 + 크기 축소.
+
+
+
+ Stage 5 +
+
LLM 요약 (Historical Summarization)
+
1회 LLM 호출. 가장 오래된 대화 구간을 LLM으로 요약. 최근 6개 메시지는 원본 유지.
+
+
+
+ +

3.2 권한 모드 + 사내/외부 모드

+ +
+
+
5가지 권한 모드 PermissionModeCatalog
+
    +
  • Default — 모든 도구 실행 전 사용자 승인 요청
  • +
  • AcceptEdits — 쓰기 도구 자동 승인, 위험 도구만 확인
  • +
  • Plan — 계획만 표시, 쓰기 도구 차단
  • +
  • Bypass — 모든 도구 자동 실행 (완전 자동)
  • +
  • Deny — 읽기 전용, 쓰기 완전 차단
  • +
+

도구별 패턴 매칭 오버라이드: tool_name@pattern 구문 지원

+
+
+
운영 모드 OperationModePolicy
+

사내 모드 (Internal)

+
    +
  • 외부 LLM (Gemini, Claude) 완전 차단
  • +
  • http_tool 차단
  • +
  • 워크스페이스 외부 경로 접근 시 강제 승인
  • +
  • Ollama / vLLM 로컬 백엔드만 허용
  • +
  • API 키 DPAPI+AES 암호화 저장
  • +
+

외부 모드 (External)

+
  • 모든 서비스/도구 사용 가능
+
+
+
+ +
+ + +
+
+ 4 +

코워크 / 코드 탭 특장점

+
+

Chat / Cowork / Code 3개 탭으로 분리된 워크스페이스. 각 탭은 고유한 시스템 프롬프트, 도구 집합, 권한 정책을 가집니다.

+ +

4.1 Cowork 탭 — 문서 생성 특화

+
+
핵심 원칙: "Tools First When Needed" Cowork
+

응답 시 텍스트보다 도구 호출을 우선. 독립적인 문서 읽기/생성을 병렬 배치 처리합니다.

+

문서 생성 워크플로우

+
    +
  • 단일 섹션html_create, docx_create 직접 생성
  • +
  • 3페이지 이상document_plan → 섹션별 작성 → document_assemble
  • +
  • Multi-pass 모드 — 아웃라인 → LLM 섹션별 집필 → 조립 (최고 품질)
  • +
+

지원 출력 포맷 + HTML Mood 템플릿

+
+ HTML DOCX XLSX + PPTX CSV Markdown +
+
+ modern professional creative elegant + dark corporate magazine dashboard +
+
+ +

4.2 Code 탭 — 코드 개발 특화

+
+
핵심 원칙: 코드 검증 게이트 + 브랜치 격리 Code
+

코드 전용 도구

+
    +
  • git_tool — Git 전체 워크플로우
  • +
  • file_edit — 정밀 라인 기반 코드 편집
  • +
  • build_run / test_loop — 빌드 + 테스트 반복
  • +
  • enter_worktree / exit_worktree — Git 워크트리 격리
  • +
  • code_review — Git diff 기반 자동 코드 리뷰
  • +
+

슬래시 커맨드 (Code 전용)

+
+ /review /commit /test + /build /diff /branch + /structure /doctor +
+
+ +

4.3 탭별 비교표

+ + + + + + + + + + +
특성ChatCoworkCode
에이전트 루프 단순 LLM 자율 루프 자율 루프
도구 실행 문서 중심 코드 중심
병렬 실행N/A
컨텍스트 압축 5단계 5단계
시스템 프롬프트기본 대화문서 생성 특화코드 개발 특화
워크스페이스 폴더 기반 폴더 기반
+
+ +
+ + +
+
+ 5 +

AX Commander 런처

+
+ +

5.1 글로벌 핫키 + 시스템 통합

+
+
Alt+Space 글로벌 런처 LauncherWindow
+

macOS Spotlight / Raycast 스타일이지만, AI 에이전트와 완전 통합된 시스템 전역 런처.

+
+
+

런처 핵심 기능

+
    +
  • 파일/폴더/앱 검색 + 실행
  • +
  • 클립보드 히스토리 (이미지 포함)
  • +
  • 웹 검색 (Google, Naver, DuckDuckGo, Yahoo, Wikipedia)
  • +
  • 커스텀 핫키 바인딩
  • +
  • 번호 배지 Ctrl+1~9 단축키
  • +
  • 즐겨찾기 / 최근 항목
  • +
  • 파일 액션 모드 (복사/이동/삭제)
  • +
  • 스니펫 자동 확장
  • +
  • 대형 텍스트 표시 (Shift+Enter)
  • +
+
+
+

시스템 통합

+
    +
  • WH_KEYBOARD_LL 로우레벨 키보드 후킹
  • +
  • 시스템 트레이 아이콘 + 커스텀 메뉴
  • +
  • Windows 시작 시 자동 실행 등록
  • +
  • 고해상도 DPI 스케일링 대응
  • +
  • 포커스 해제 시 자동 닫기
  • +
  • 위치 설정: center-top / center / bottom
  • +
  • 투명도 조절 (기본 96%)
  • +
+

테마 시스템

+
+ System Dark Light OLED + Nord Monokai Catppuccin Sepia + + Custom +
+
+
+
+ +

5.2 위젯 및 Quick Actions

+
+
+
위젯 시스템
+

런처 하단에 선택적으로 표시되는 미니 위젯:

+
+ 성능 모니터 뽀모도로 퀵 노트 + 날씨 캘린더 배터리 +
+
+
+
Quick Actions
+

입력창이 비어있을 때 최근 사용 파일/폴더 상위 8개를 칩으로 표시. 색상 구분:

+
    +
  • ■ 폴더 (초록)
  • +
  • ■ 실행파일/바로가기 (파랑)
  • +
  • ■ 기타 파일 (보라)
  • +
+
+
+
+
슬래시 커맨드 70+ Commands
+
+ /clear /model /permissions + /theme /summary /translate + /explain /fix /review + /commit /test /build + /structure /doctor /tasks + /init /search /diff + +50 more +
+
+
+ +
+ + +
+
+ 6 +

모델별 실행 프로파일

+
+

LLM 모델마다 다른 특성에 맞춰 4가지 실행 프로파일을 제공. 프로파일에 따라 온도 제한, 재시도 횟수, 병렬 규모, 검증 게이트가 자동 조정됩니다.

+ +

6.1 4가지 프로파일 상세

+ +
+
+
Strict tool_call_strict
+
IBM Granite, Qwen 등 시스템 프롬프트 무시 경향 모델용
+
+
온도 제한
0.2 (매우 낮음)
+
재시도
4회 (비도구 응답 시)
+
병렬 읽기
최대 8개
+
특수 기능
사용자 메시지에 도구 호출 리마인더 주입
+
+
+
+
Balanced reasoning_first
+
Claude, GPT-4 등 고성능 추론 모델용
+
+
온도 제한
0.45 (균형)
+
재시도
2회
+
병렬 읽기
최대 6개
+
코드 게이트
활성 (품질 검증)
+
+
+
+
Fast fast_readonly
+
빠른 조회/분석 작업 특화
+
+
온도 제한
0.25 (예측 가능)
+
재시도
1회 (최소)
+
병렬 읽기
최대 10개 (최대 공격적)
+
검증 게이트
비활성 (속도 최우선)
+
+
+
+
Document document_heavy
+
보고서/프레젠테이션 생성 집중 모드
+
+
온도 제한
0.35
+
계획 재시도
0 (계획 생략, 생성 집중)
+
병렬 읽기
최대 6개
+
메모리 압박
조기 해소 활성
+
+
+
+ +

6.2 모델별 컨텍스트 한도

+ + + + + + + + + + +
모델 계열모델 한도적용 한도 (90%)압축 트리거
Claude (Opus/Sonnet/Haiku)200K180K144K (80%)
Gemini 2.5 Pro1M900K720K
GPT-4 / GPT-4o128K120K96K
DeepSeek128K128K102K
Qwen / LLaMA32K32K25.6K
vLLM / Ollama (미확인)-32K (보수적)25.6K
+ +

6.3 자동 모델 라우팅

+
+ +
+ 현재 비활성 (EnableAutoRouter = false)
+ 사용자 메시지 의도(coding, translation, analysis, creative, document, math)를 분석하여 최적 모델로 자동 전환하는 기능. 신뢰도 임계값 초과 시에만 전환하며, 기존 모델보다 0.1점 이상 높을 때만 적용. 향후 사내 서버 확정 후 활성화 예정. +
+
+
+ +
+ + +
+
+ 7 +

경쟁 서비스 비교

+
+ +

7.1 기능 비교 매트릭스

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
기능AX CopilotClaude CodeClaude DesktopOpenAI CodexCursor / WindsurfChatGPT Desktop
플랫폼WPF 독립 앱터미널 CLIElectron 데스크톱클라우드 샌드박스VS Code ForkElectron 데스크톱
에이전트 루프 91+ 도구 MCP 기반 샌드박스 편집 특화
멀티 모델 4종 백엔드 Claude 전용 Claude 전용 GPT 전용 GPT 전용
사내 LLM Ollama/vLLM
문서 생성 7종 포맷
런처 (핫키) Alt+Space 기본
권한 세분화 5모드+패턴 3단계 샌드박스 격리
컨텍스트 관리 5단계 압축 기본
MCP 서버
모델별 프로파일 4종
사내/외부 모드
오프라인 동작 로컬 모델
로컬 파일 접근 직접 접근 직접 접근 MCP 경유 클라우드만 프로젝트 내
비용 구조API 종량제API 종량제구독 (Pro/Max)구독 (Pro)구독 ($20/mo~)구독 (Plus)
+
+ +

7.2 핵심 차별화 요약

+ +
+
+
① 완전 독립 데스크톱 에이전트
+

IDE 종속 없는 독립 실행형 에이전트. 코드 편집 + 문서 생성 + 시스템 관리 + 워크플로우 자동화를 단일 앱에서 수행. 런처로 어디서든 즉시 접근.

+
+
+
② 사내 인프라 네이티브
+

Ollama / vLLM 직접 연결. DPAPI+AES 암호화 API 키. 사내 모드에서 외부 완전 차단. 오프라인에서도 에이전트 루프 동작.

+
+
+
③ 모델 불문 최적 실행
+

4가지 프로파일이 모델 특성에 맞춰 자동 조정. "chatty" 모델에는 도구 호출 강제, 고성능 모델에는 추론 우선. 온도/재시도/병렬 규모 모두 자동 최적화.

+
+
+
④ 문서+코드 통합 워크스페이스
+

단일 앱에서 보고서/PPTX 자동 생성코드 개발/리뷰/테스트를 동시 수행. 같은 대화 컨텍스트에서 문서와 코드를 넘나드는 작업.

+
+
+
+ +
+ + +
+
+ 8 +

기술 스택 요약

+
+ +
+
.NET 8
런타임
+
WPF
UI 프레임워크
+
C#
언어
+
MCP
확장 프로토콜
+
+ + + + + + + + + + + + + +
레이어기술 / 패턴핵심 파일
UI 렌더링VirtualizingStackPanel + TranscriptVisualItem 가상화, V1/V2 분기 렌더ChatWindow.*.cs (40+ partial files)
실행 엔진Prepare → Execute → Commit 3단계 분리AxAgentExecutionEngine.cs
에이전트 루프Plan → Execute → Observe → Evaluate + Parallel BatchAgentLoopService.cs
LLM 통신SSE Streaming + Override Stack + Token TrackingLlmService.cs
도구 시스템IAgentTool + ToolRegistry + MCP 동적 래핑ToolRegistry.cs, IAgentTool.cs
컨텍스트5단계 MemGPT 스타일 압축 파이프라인ContextCondenser.cs
보안5모드 권한 + Internal/External 운영 + DPAPI 암호화PermissionModeCatalog.cs
런처WH_KEYBOARD_LL 글로벌 핫키 + Spotlight 스타일 UILauncherWindow.xaml.cs
+
+ + + + +
+ + + + + + diff --git a/docs/AX_Copilot_Internal_Seminar_SidebarTOC.html b/docs/AX_Copilot_Internal_Seminar_SidebarTOC.html new file mode 100644 index 0000000..a9d9740 --- /dev/null +++ b/docs/AX_Copilot_Internal_Seminar_SidebarTOC.html @@ -0,0 +1,1337 @@ + + + + + +AX Copilot - 내부 기술 세미나 + + + + + + + + +
+ + +
+ + +
+ Internal Tech Seminar +

AX Copilot

+

Agentic AI 데스크톱 코파일럿 — 아키텍처 & 핵심 기술 분석

+
+ WPF .NET 8 + 91+ Tools + Multi-Model + Agentic Loop +
+
+ + +
+
+ 1 +

오픈소스 기반 핵심 엔진 구성

+
+

AX Copilot의 에이전트 엔진은 OpenHands(전 OpenDevin)와 OpenCode 두 프로젝트의 아키텍처 패턴을 분석하여, WPF 데스크톱 환경에 맞게 재설계한 결과물입니다.

+ +

1.1 OpenHands 반영 사항

+ +
+
MemGPT 스타일 다단계 컨텍스트 압축 ContextCondenser
+

OpenHands의 AgentController가 수행하는 메모리 관리 전략을 5단계 파이프라인으로 구현. 단계 1~4는 LLM 호출 없이 수행하여 비용을 절감하고, 5단계에서만 LLM 1회 호출.

+
+ +
+
Plan-and-Solve 분해 전략 TaskDecomposer
+

OpenHands CodeActAgent의 태스크 분해 패턴 반영. LLM 응답에서 번호 매긴 단계를 추출 (TaskDecomposer.ExtractSteps()), 현재 도구 호출을 계획 단계에 매칭하여 실시간 진행률을 UI에 표시합니다.

+
+ +
+
도구 병렬 실행 + 프리페치 AgentLoopParallelExecution
+

읽기 전용 도구를 자동 분류하여 최대 12개까지 병렬 실행. 스트리밍 응답 도중에도 읽기 도구를 선행 실행(Prefetch)하여 대기 시간 최소화.

+
+ +

1.2 OpenCode 반영 사항

+ +
+
실행 엔진 3단계 분리 AxAgentExecutionEngine
+

OpenCode의 Prepare → Execute → Commit 분리 패턴을 답습합니다.

+
    +
  • ResolveExecutionMode() — 탭/설정에 따라 에이전트 루프 vs 단순 LLM 호출 결정
  • +
  • PrepareExecution() — 시스템 프롬프트 스택 + 메시지 조립
  • +
  • ExecutePreparedAsync() — agentLoopRunner 또는 llmRunner로 분기
  • +
  • CommitAssistantMessage() — 결과 메시지 영속화
  • +
+
+ +
+
스트리밍 도구 실행 + 자동 복구 StreamingToolExecutionCoordinator
+

SSE 스트리밍 중 도구 블록 도착 즉시 실행. 컨텍스트 오버플로우 자동 감지 → 강제 압축, 일시적 네트워크 오류 → 지수 백오프 재시도 포함.

+
+ +
+ 💡 +
+ 설계 철학
+ OpenHands의 자율적 다단계 태스크 수행 + OpenCode의 경량 실행 분리를 결합하되, + WPF 데스크톱 특성(UI 스레드 분리, 로컬 파일 시스템, 시스템 트레이 통합)에 맞춰 재설계. +
+
+
+ + +
+
+ 2 +

Agentic Loop 동작 구조

+
+ +

2.1 4단계 실행 사이클

+

에이전트 루프는 Plan → Execute → Observe → Evaluate 사이클을 작업 완료까지 자율 반복합니다.

+ +
+
+
Phase 1
+
Planning
+
LLM이 도구 호출 계획 생성
+
+ +
+
Phase 2
+
Execute
+
읽기 병렬 + 쓰기 순차 실행
+
+ +
+
Phase 3
+
Observe
+
도구 결과 수집 + 이벤트 발행
+
+ +
+
Phase 4
+
Evaluate
+
LLM이 결과 판단 + 다음 행동
+
+
+ +

AgentLoopService 핵심 기능

+
    +
  • 실행 중 사용자 개입ConcurrentQueue로 실행 중에도 사용자 메시지 수신 (Claude Code 스타일 mid-execution steering)
  • +
  • 일시정지/재개SemaphoreSlim 기반 Pause/Resume 제어
  • +
  • 반복 제한MaxAgentIterations (기본 25, 최대 200) 초과 시 자동 중단
  • +
  • 이벤트 스트림 — ToolCall, ToolResult, Thinking, Planning, Complete, Error 이벤트를 UI에 실시간 전달
  • +
+ +

2.2 전체 아키텍처 다이어그램

+ +
+------------------------------------------------------------------+ +| AX Copilot UI Layer | +| ChatWindow -- TranscriptHost -- AgentEventProcessor | +| | | | | +| v v v | +| V1 Render V2 Render AgentEvent stream | ++----------+----------------------------+---------------------------+ + | | ++----------v----------------------------v---------------------------+ +| AxAgentExecutionEngine | +| ResolveMode -> PrepareExecution -> ExecutePrepared -> Commit | ++----------+----------------------------+---------------------------+ + | | ++----------v-----------+ +------------v----------------------------+ +| AgentLoopService | | LlmService (Streaming) | +| | | Ollama / vLLM / Gemini / Claude | +| Plan -> Execute -> | | Override Stack (Push/Pop) | +| Observe -> Evaluate | | Token Tracking per call | ++-----------+-----------+ +---------------------------------------------+ + | ++-----------v---------------------------------------------------------+ +| ToolRegistry (91+ Tools) | +| File Ops | Document | Code/Git | System | MCP Dynamic | +| Parallel | Plan+ | Worktree | Notify | JSON-RPC stdio | +| Batch | Assemble | Branch | Env | mcp_{srv}_{fn} | ++-----------+---------------------------------------------------------+ + | ++-----------v---------------------------------------------------------+ +| Permission / Security Layer | +| OperationModePolicy (Internal/External) | +| PermissionModeCatalog (Default/AcceptEdits/Plan/Bypass/Deny) | +| Tool-level overrides with wildcard/pattern matching | ++---------------------------------------------------------------------+
+ +

2.3 도구 시스템 (91+ Tools)

+ +
+
91+
내장 도구
+
3
노출 버킷
+
12
최대 병렬 실행
+
+ +

도구 카테고리

+ + + + + + + + + + + +
카테고리주요 도구설명
파일 조작file_read file_write file_edit glob grep파일 읽기/쓰기/검색 + folder_map
문서 생성html_create docx_create xlsx_create pptx_create8가지 Mood 템플릿 + Multi-pass 조립
코드/Gitgit_tool code_search build_run test_loop브랜치 격리, 코드 리뷰, CI 연동
시스템process env_tool clipboard notify프로세스 실행, 환경 변수, 알림
데이터json_tool sql_tool math_tool data_pivot구조화 데이터 처리 + 피벗
워크플로우spawn_agent enter_worktree checkpoint playbook에이전트 분기, 체크포인트, 자동화
MCP 동적mcp_{server}_{fn}JSON-RPC 2.0 over stdio 외부 도구 서버
+
+ +
+ + +
+
+ 3 +

컨텍스트 관리 + 보안

+
+ +

3.1 5단계 압축 파이프라인

+

모델 입력 한도의 80%에 도달하면 자동으로 트리거. 단계 1~4는 LLM 호출 없이 수행하여 비용 절감.

+ +
+
+ Stage 1 +
+
Tool Result 절삭
+
LLM 호출 없음. 도구 결과를 1,500자 제한 (head 220자 + tail 140자 보존). 가장 빈번하게 적용.
+
+
+
+ Stage 2 +
+
Session Memory 통합
+
LLM 호출 없음. 이전 압축 경계의 요약들을 단일 세션 메모리 메시지로 병합.
+
+
+
+ Stage 3 +
+
Microcompact
+
LLM 호출 없음. 오래된 실행 로그/도구 묶음을 한 줄 요약으로 교체. 단일 결과 480자 제한.
+
+
+
+ Stage 4 +
+
Collapse / Snip
+
LLM 호출 없음. 남은 긴 로그를 head+tail 스니펫으로 절삭. 증거 보존 + 크기 축소.
+
+
+
+ Stage 5 +
+
LLM 요약 (Historical Summarization)
+
1회 LLM 호출. 가장 오래된 대화 구간을 LLM으로 요약. 최근 6개 메시지는 원본 유지.
+
+
+
+ +

3.2 권한 모드 + 사내/외부 모드

+ +
+
+
5가지 권한 모드 PermissionModeCatalog
+
    +
  • Default — 모든 도구 실행 전 사용자 승인 요청
  • +
  • AcceptEdits — 쓰기 도구 자동 승인, 위험 도구만 확인
  • +
  • Plan — 계획만 표시, 쓰기 도구 차단
  • +
  • Bypass — 모든 도구 자동 실행 (완전 자동)
  • +
  • Deny — 읽기 전용, 쓰기 완전 차단
  • +
+

도구별 패턴 매칭 오버라이드: tool_name@pattern 구문 지원

+
+
+
운영 모드 OperationModePolicy
+

사내 모드 (Internal)

+
    +
  • 외부 LLM (Gemini, Claude) 완전 차단
  • +
  • http_tool 차단
  • +
  • 워크스페이스 외부 경로 접근 시 강제 승인
  • +
  • Ollama / vLLM 로컬 백엔드만 허용
  • +
  • API 키 DPAPI+AES 암호화 저장
  • +
+

외부 모드 (External)

+
  • 모든 서비스/도구 사용 가능
+
+
+
+ +
+ + +
+
+ 4 +

코워크 / 코드 탭 특장점

+
+

Chat / Cowork / Code 3개 탭으로 분리된 워크스페이스. 각 탭은 고유한 시스템 프롬프트, 도구 집합, 권한 정책을 가집니다.

+ +

4.1 Cowork 탭 — 문서 생성 특화

+
+
핵심 원칙: "Tools First When Needed" Cowork
+

응답 시 텍스트보다 도구 호출을 우선. 독립적인 문서 읽기/생성을 병렬 배치 처리합니다.

+

문서 생성 워크플로우

+
    +
  • 단일 섹션html_create, docx_create 직접 생성
  • +
  • 3페이지 이상document_plan → 섹션별 작성 → document_assemble
  • +
  • Multi-pass 모드 — 아웃라인 → LLM 섹션별 집필 → 조립 (최고 품질)
  • +
+

지원 출력 포맷 + HTML Mood 템플릿

+
+ HTML DOCX XLSX + PPTX CSV Markdown +
+
+ modern professional creative elegant + dark corporate magazine dashboard +
+
+ +

4.2 Code 탭 — 코드 개발 특화

+
+
핵심 원칙: 코드 검증 게이트 + 브랜치 격리 Code
+

코드 전용 도구

+
    +
  • git_tool — Git 전체 워크플로우
  • +
  • file_edit — 정밀 라인 기반 코드 편집
  • +
  • build_run / test_loop — 빌드 + 테스트 반복
  • +
  • enter_worktree / exit_worktree — Git 워크트리 격리
  • +
  • code_review — Git diff 기반 자동 코드 리뷰
  • +
+

슬래시 커맨드 (Code 전용)

+
+ /review /commit /test + /build /diff /branch + /structure /doctor +
+
+ +

4.3 탭별 비교표

+ + + + + + + + + + +
특성ChatCoworkCode
에이전트 루프 단순 LLM 자율 루프 자율 루프
도구 실행 문서 중심 코드 중심
병렬 실행N/A
컨텍스트 압축 5단계 5단계
시스템 프롬프트기본 대화문서 생성 특화코드 개발 특화
워크스페이스 폴더 기반 폴더 기반
+
+ +
+ + +
+
+ 5 +

AX Commander 런처

+
+ +

5.1 글로벌 핫키 + 시스템 통합

+
+
Alt+Space 글로벌 런처 LauncherWindow
+

macOS Spotlight / Raycast 스타일이지만, AI 에이전트와 완전 통합된 시스템 전역 런처.

+
+
+

런처 핵심 기능

+
    +
  • 파일/폴더/앱 검색 + 실행
  • +
  • 클립보드 히스토리 (이미지 포함)
  • +
  • 웹 검색 (Google, Naver, DuckDuckGo, Yahoo, Wikipedia)
  • +
  • 커스텀 핫키 바인딩
  • +
  • 번호 배지 Ctrl+1~9 단축키
  • +
  • 즐겨찾기 / 최근 항목
  • +
  • 파일 액션 모드 (복사/이동/삭제)
  • +
  • 스니펫 자동 확장
  • +
  • 대형 텍스트 표시 (Shift+Enter)
  • +
+
+
+

시스템 통합

+
    +
  • WH_KEYBOARD_LL 로우레벨 키보드 후킹
  • +
  • 시스템 트레이 아이콘 + 커스텀 메뉴
  • +
  • Windows 시작 시 자동 실행 등록
  • +
  • 고해상도 DPI 스케일링 대응
  • +
  • 포커스 해제 시 자동 닫기
  • +
  • 위치 설정: center-top / center / bottom
  • +
  • 투명도 조절 (기본 96%)
  • +
+

테마 시스템

+
+ System Dark Light OLED + Nord Monokai Catppuccin Sepia + + Custom +
+
+
+
+ +

5.2 위젯 및 Quick Actions

+
+
+
위젯 시스템
+

런처 하단에 선택적으로 표시되는 미니 위젯:

+
+ 성능 모니터 뽀모도로 퀵 노트 + 날씨 캘린더 배터리 +
+
+
+
Quick Actions
+

입력창이 비어있을 때 최근 사용 파일/폴더 상위 8개를 칩으로 표시. 색상 구분:

+
    +
  • ■ 폴더 (초록)
  • +
  • ■ 실행파일/바로가기 (파랑)
  • +
  • ■ 기타 파일 (보라)
  • +
+
+
+
+
슬래시 커맨드 70+ Commands
+
+ /clear /model /permissions + /theme /summary /translate + /explain /fix /review + /commit /test /build + /structure /doctor /tasks + /init /search /diff + +50 more +
+
+
+ +
+ + +
+
+ 6 +

모델별 실행 프로파일

+
+

LLM 모델마다 다른 특성에 맞춰 4가지 실행 프로파일을 제공. 프로파일에 따라 온도 제한, 재시도 횟수, 병렬 규모, 검증 게이트가 자동 조정됩니다.

+ +

6.1 4가지 프로파일 상세

+ +
+
+
Strict tool_call_strict
+
IBM Granite, Qwen 등 시스템 프롬프트 무시 경향 모델용
+
+
온도 제한
0.2 (매우 낮음)
+
재시도
4회 (비도구 응답 시)
+
병렬 읽기
최대 8개
+
특수 기능
사용자 메시지에 도구 호출 리마인더 주입
+
+
+
+
Balanced reasoning_first
+
Claude, GPT-4 등 고성능 추론 모델용
+
+
온도 제한
0.45 (균형)
+
재시도
2회
+
병렬 읽기
최대 6개
+
코드 게이트
활성 (품질 검증)
+
+
+
+
Fast fast_readonly
+
빠른 조회/분석 작업 특화
+
+
온도 제한
0.25 (예측 가능)
+
재시도
1회 (최소)
+
병렬 읽기
최대 10개 (최대 공격적)
+
검증 게이트
비활성 (속도 최우선)
+
+
+
+
Document document_heavy
+
보고서/프레젠테이션 생성 집중 모드
+
+
온도 제한
0.35
+
계획 재시도
0 (계획 생략, 생성 집중)
+
병렬 읽기
최대 6개
+
메모리 압박
조기 해소 활성
+
+
+
+ +

6.2 모델별 컨텍스트 한도

+ + + + + + + + + + +
모델 계열모델 한도적용 한도 (90%)압축 트리거
Claude (Opus/Sonnet/Haiku)200K180K144K (80%)
Gemini 2.5 Pro1M900K720K
GPT-4 / GPT-4o128K120K96K
DeepSeek128K128K102K
Qwen / LLaMA32K32K25.6K
vLLM / Ollama (미확인)-32K (보수적)25.6K
+ +

6.3 자동 모델 라우팅

+
+ +
+ 현재 비활성 (EnableAutoRouter = false)
+ 사용자 메시지 의도(coding, translation, analysis, creative, document, math)를 분석하여 최적 모델로 자동 전환하는 기능. 신뢰도 임계값 초과 시에만 전환하며, 기존 모델보다 0.1점 이상 높을 때만 적용. 향후 사내 서버 확정 후 활성화 예정. +
+
+
+ +
+ + +
+
+ 7 +

경쟁 서비스 비교

+
+ +

7.1 기능 비교 매트릭스

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
기능AX CopilotClaude CodeClaude DesktopOpenAI CodexCursor / WindsurfChatGPT Desktop
플랫폼WPF 독립 앱터미널 CLIElectron 데스크톱클라우드 샌드박스VS Code ForkElectron 데스크톱
에이전트 루프 91+ 도구 MCP 기반 샌드박스 편집 특화
멀티 모델 4종 백엔드 Claude 전용 Claude 전용 GPT 전용 GPT 전용
사내 LLM Ollama/vLLM
문서 생성 7종 포맷
런처 (핫키) Alt+Space 기본
권한 세분화 5모드+패턴 3단계 샌드박스 격리
컨텍스트 관리 5단계 압축 기본
MCP 서버
모델별 프로파일 4종
사내/외부 모드
오프라인 동작 로컬 모델
로컬 파일 접근 직접 접근 직접 접근 MCP 경유 클라우드만 프로젝트 내
비용 구조API 종량제API 종량제구독 (Pro/Max)구독 (Pro)구독 ($20/mo~)구독 (Plus)
+
+ +

7.2 핵심 차별화 요약

+ +
+
+
① 완전 독립 데스크톱 에이전트
+

IDE 종속 없는 독립 실행형 에이전트. 코드 편집 + 문서 생성 + 시스템 관리 + 워크플로우 자동화를 단일 앱에서 수행. 런처로 어디서든 즉시 접근.

+
+
+
② 사내 인프라 네이티브
+

Ollama / vLLM 직접 연결. DPAPI+AES 암호화 API 키. 사내 모드에서 외부 완전 차단. 오프라인에서도 에이전트 루프 동작.

+
+
+
③ 모델 불문 최적 실행
+

4가지 프로파일이 모델 특성에 맞춰 자동 조정. "chatty" 모델에는 도구 호출 강제, 고성능 모델에는 추론 우선. 온도/재시도/병렬 규모 모두 자동 최적화.

+
+
+
④ 문서+코드 통합 워크스페이스
+

단일 앱에서 보고서/PPTX 자동 생성코드 개발/리뷰/테스트를 동시 수행. 같은 대화 컨텍스트에서 문서와 코드를 넘나드는 작업.

+
+
+
+ +
+ + +
+
+ 8 +

기술 스택 요약

+
+ +
+
.NET 8
런타임
+
WPF
UI 프레임워크
+
C#
언어
+
MCP
확장 프로토콜
+
+ + + + + + + + + + + + + +
레이어기술 / 패턴핵심 파일
UI 렌더링VirtualizingStackPanel + TranscriptVisualItem 가상화, V1/V2 분기 렌더ChatWindow.*.cs (40+ partial files)
실행 엔진Prepare → Execute → Commit 3단계 분리AxAgentExecutionEngine.cs
에이전트 루프Plan → Execute → Observe → Evaluate + Parallel BatchAgentLoopService.cs
LLM 통신SSE Streaming + Override Stack + Token TrackingLlmService.cs
도구 시스템IAgentTool + ToolRegistry + MCP 동적 래핑ToolRegistry.cs, IAgentTool.cs
컨텍스트5단계 MemGPT 스타일 압축 파이프라인ContextCondenser.cs
보안5모드 권한 + Internal/External 운영 + DPAPI 암호화PermissionModeCatalog.cs
런처WH_KEYBOARD_LL 글로벌 핫키 + Spotlight 스타일 UILauncherWindow.xaml.cs
+
+ + + + +
+
+ + + + + + diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1800e3f..893c6bd 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,763 +1,801 @@ -# AX Copilot - 媛쒕컻 臾몄꽌 +# AX Copilot - 개발 문서 +> 최종 업데이트: 2026-04-13 · 버전 0.7.3 -## 계획 승인 UI 테마 정렬 +## 업데이트 로그 -- 업데이트: 2026-04-10 08:47 (KST) -- `ChatWindow.AgentStatusPresentation`의 계획 승인 카드를 AX Agent 테마 리소스 기반으로 다시 정리했습니다. 승인/수정/취소 버튼의 라운드, 간격, 입력 패널 배경을 `LauncherBackground`, `ItemBackground`, `BorderColor`, `AccentColor` 축으로 통일해 채팅 본문과 더 자연스럽게 이어지도록 조정했습니다. -- `PlanViewerWindow`의 승인 단계 UI도 같은 방향으로 손봤습니다. 승인 버튼 영역 앞에 검토 안내 카드를 추가하고, 수정 피드백 입력 패널은 테마 배경/테두리를 사용하며, 액션 버튼 호버는 단순 opacity 대신 `ItemHoverBackground` 계열로 반응하도록 바꿨습니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - -## claude-code식 Cowork/Code 강제 로직 추가 완화 - -- 업데이트: 2026-04-10 09:02 (KST) -- `ChatWindow.SystemPromptBuilder`에서 Cowork/Code의 text-only 완료 조건을 더 현실적으로 완화했습니다. 작업이 이미 완료됐거나 충분한 근거가 확보된 경우에는 불필요한 도구 호출을 더 강제하지 않도록 정리했고, 새 문서 생성 시에도 `document_plan`은 선택적 구조 보조 단계로만 남겼습니다. -- `AgentLoopService`의 no-tool recovery와 `ForceToolCallAfterPlan`도 `claude-code` 기준에 맞춰 축소했습니다. 이제 Cowork는 실제 문서 생성 요청, Code는 bugfix/feature/refactor처럼 구체 수정이 필요한 경우에만 다시 도구를 강제하고, 분석형/설명형 응답은 불필요하게 재루프시키지 않습니다. -- 문서 fallback도 같은 기준으로 좁혔습니다. `document_plan` 이후 `html_create` 재유도나 앱 직접 생성 fallback은 실제 산출물 생성이 필요한 경우에만 작동합니다. -- `AgentLoopTransitions.Verification`은 일반 Code 완료 조건을 더 얇게 바꿨습니다. 최근 `build/test` 또는 `git diff` 근거가 이미 있으면 `CodeDiffGate`, `RecentExecutionGate`, `ExecutionSuccessGate`가 중복 발동하지 않도록 줄였습니다. -- `TaskTypePolicy`의 docs 가이드와 `ToolRegistry`의 노출 우선순위도 정리했습니다. docs는 `document_plan first` 고정에서 벗어나 creation tool 우선 + `document_plan` 선택형으로 바뀌었고, `folder_map`은 최상위 기본 도구에서 한 단계 내려 보조 탐색 도구로 재배치했습니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - -## claude-code식 계획/후속 권유 최소화 - -- 업데이트: 2026-04-10 00:08 (KST) -- `AgentLoopService`에서 과거 plan mode 잔재로 남아 있던 prelude/승인용 실행 계획 블록을 제거했습니다. 기본 Cowork/Code 루프는 이제 별도 “계획만 먼저 생성” 단계 없이 바로 메인 모델+도구 반복으로 들어갑니다. 사용자가 명시적으로 계획을 원할 때만 응답 텍스트 수준에서 계획을 제시하고, 루프 자체는 `claude-code`처럼 실행 중심으로 유지합니다. -- `AgentLoopTransitions.Verification`의 `FinalReportGate`는 review 작업 또는 고영향 변경일 때만 구조화된 재정리를 요구합니다. 일반 수정은 변경 내용과 검증 근거만 충분하면 “remaining risk / next action”을 추가로 다시 쓰게 하지 않습니다. -- `BuildFinalReportQualityPrompt`도 같은 기준으로 완화했습니다. 남은 리스크/추가 확인은 실제로 미해결 사항이 남아 있을 때만 적도록 바꾸고, 후속 권유는 기본이 아니라 조건부 정보로 낮췄습니다. -- `ChatWindow.SystemPromptBuilder`의 Cowork/Code 프롬프트도 조정했습니다. Cowork는 새 문서 생성 시 `document_plan`을 기본 선행 단계처럼 밀지 않고 “필요할 때만” 사용하게 했고, Code의 REPORT 단계는 “변경 내용과 검증 요약”을 기본으로 하며 미해결 리스크만 선택적으로 언급하게 바꿨습니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - -## claude-code식 후속 호환/선택성 보강 - -- 업데이트: 2026-04-09 23:02 (KST) -- `LlmService.ToolUse`의 메시지 파서를 더 느슨하게 확장했습니다. `TryExtractMessageToolBlocks`는 이제 `content`/`reasoning_content`가 문자열뿐 아니라 배열일 때도 `text`, `output_text`, nested `content`를 모아 텍스트를 만들고, 배열 안의 `tool_use`/`tool_call` 블록은 직접 `ContentBlock`으로 복구합니다. -- IBM 스트리밍 응답의 `results[0]`도 같은 기준으로 읽습니다. `generated_text`, `output_text`가 배열/블록이어도 텍스트를 추출하고, `message` 오브젝트가 있을 때는 그 안의 텍스트/도구 호출까지 함께 처리해 Qwen류 응답 포맷 차이에 덜 민감하게 만들었습니다. -- `ToolRegistry`에는 노출 순서 정렬을 추가했습니다. 기본 파일/검색/생성/실행 도구를 가장 앞에 두고, `document_review`·`format_convert`·`tool_search`·`code_search`는 보조 단계, `mcp_*`·`spawn_agent`·`wait_agents`, `task_*` 계열은 더 뒤에 배치해 `claude-code`처럼 기본 작업 도구가 먼저 선택되도록 조정했습니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - -- 업데이트: 2026-04-09 22:48 (KST) -- IBM 배포형 vLLM/Qwen 호환을 위해 `LlmService.ToolUse`의 `BuildIbmToolBody`를 다시 정리했습니다. 이전 assistant `tool_calls`와 `role=tool` 이력을 OpenAI 형식으로 재전송하던 경로를 제거하고, `_tool_use_blocks`는 `...` transcript로, `tool_result`는 plain user transcript로 평탄화합니다. 기존의 텍스트 기반 `TryExtractToolCallsFromText` 폴백과 함께 IBM 쪽의 엄격한 tool history 검사에 대응하는 방향입니다. -- `ChatWindow.SystemPromptBuilder`는 Cowork/Code 모두 “도구 호출 필수” 톤을 더 낮췄습니다. Cowork는 `document_review`와 `format_convert`를 기본 후속 단계처럼 밀지 않고, `file_read/document_read` 중심의 가벼운 검증을 기본으로 삼습니다. Code/Cowork 공통 `Sub-Agent Delegation`도 `spawn_agent`를 병렬성이 실제로 도움이 될 때만 선택하도록 바꿨습니다. -- `AgentLoopService`의 unknown/disallowed tool recovery는 `tool_search`를 항상 먼저 강제하지 않고, alias 자동 매핑 후보나 활성 도구 예시만으로 바로 선택 가능하면 그 도구를 바로 쓰도록 완화했습니다. `tool_search`는 정말 모호할 때만 쓰는 보조 수단으로 내렸습니다. -- `AgentLoopTransitions.Execution`의 문서 검증 근거도 단순화했습니다. 문서 생성 후 기본 완료 근거는 `file_read`/`document_read`면 충분하고, `document_review`는 선택적 품질 점검 도구로만 남깁니다. -- `AgentLoopExplorationPolicy`는 Code 쿼리에 정의/참조/구현/호출관계/심볼 의도가 보이면 `lsp_code_intel -> targeted file_read -> edit/verify` 순서를 더 앞세웁니다. `LspTool` 결과도 파일 수, 대표 위치, 첫 결과를 같이 요약해 `claude-code`의 LSP 결과 shaping에 더 가깝게 맞췄습니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - -## claude-code식 선택 탐색 우선순위 정렬 - -- 업데이트: 2026-04-09 22:38 (KST) -- AX에 이미 존재하던 `LspTool`/`LspClientService`를 `claude-code`의 `LSPTool` 수준에 더 가깝게 확장했습니다. 기존 `goto_definition`, `find_references`, `symbols` 외에 `hover`, `goto_implementation`, `workspace_symbols`, `prepare_call_hierarchy`, `incoming_calls`, `outgoing_calls`를 추가해 정의/참조/문서 심볼/워크스페이스 심볼/호출 계층을 모두 조회할 수 있게 했습니다. -- `LspClientService`는 LSP initialize capability도 확장했습니다. 이제 `implementation`, `hover`, `callHierarchy`, `workspace/symbol` 요청을 직접 보낼 수 있고, `LocationLink`, hover contents, call hierarchy 결과를 AX용 단순 모델로 파싱합니다. -- `AgentLoopExplorationPolicy`, `TaskTypePolicy`, `ChatWindow.SystemPromptBuilder`도 같이 조정해 Code 탭의 좁은 요청에서 `file_read`, `grep/glob`와 함께 `lsp_code_intel`이 자연스러운 선택지로 노출되도록 맞췄습니다. `claude-code`처럼 기본은 텍스트 탐색이되, 정의/참조/호출관계는 LSP를 더 우선적으로 쓰는 흐름입니다. - -- 업데이트: 2026-04-09 21:58 (KST) -- `ModelExecutionProfileCatalog`의 기본 프로필을 다시 완화했습니다. `balanced`, `reasoning_first`는 이제 초기 도구 호출 강제와 post-tool verification을 기본으로 켜지 않고, 문서 검증 게이트와 diff/final-report 후속 게이트도 기본적으로 줄여 `claude-code`처럼 더 얇은 반복 구조를 따릅니다. -- Cowork 문서 생성 완료 경로는 `AgentLoopTransitions.Documents`에서 한 번 더 정리했습니다. Code 탭이 아닌 경우 terminal document tool 성공 뒤 별도 post-tool verification 턴을 추가하지 않고 바로 완료 가능하도록 바꿔, 문서 생성 후 불필요한 재호출을 줄였습니다. -- Code 완료 게이트도 `AgentLoopTransitions.Verification`에서 완화했습니다. 일반 수정은 `diff` 또는 최근 build/test 같은 가벼운 완료 증거만 있어도 마무리 가능하게 하고, build/test 강제는 고영향 수정일 때 중심으로 남겨 `claude-code`의 얇은 code loop에 더 가깝게 맞췄습니다. -- `ChatWindow.SystemPromptBuilder`의 Cowork/Code 프롬프트도 같은 방향으로 손봤습니다. “모든 응답은 도구 호출 필수” 식의 과한 강제 표현을 줄이고, 필요한 경우에만 즉시 도구를 쓰되 불필요한 도구 호출은 강제하지 않도록 완화했습니다. - -- 업데이트: 2026-04-09 21:48 (KST) -- Cowork 시스템 프롬프트에서 “불확실하면 먼저 파일을 찾아라” 성향을 더 줄였습니다. 이제 순수 문서 생성 요청은 `document_plan -> docx_create/html_create/...`를 먼저 타고, `glob/grep/document_read/folder_map`은 기존 자료 참조가 명시된 경우에만 먼저 쓰도록 유도합니다. -- Code 시스템 프롬프트와 탐색 우선순위도 `claude-code`처럼 더 얇게 바꿨습니다. 기본 시작 흐름은 `specific file -> file_read`, 아니면 `grep/glob -> small targeted read`, 그 다음 `file_edit/file_write`이며, `build_run/test_loop`와 `git_tool(diff)`는 검증이 실제로 필요할 때 붙는 구조로 정리했습니다. -- `TaskTypePolicy`의 bugfix/feature/refactor/review 기본 도구 순서도 같은 기준으로 완화해, AX Code가 과하게 `git diff/build/test`를 절차적으로 앞세우지 않도록 맞췄습니다. - -- 업데이트: 2026-04-09 21:03 (KST) -- `StreamingToolExecutionCoordinator`에 `RetryReset` 이벤트를 추가해, 컨텍스트 복구나 일시적 LLM 오류 재시도 전에 부분 스트림 미리보기 상태를 끊도록 했습니다. `claude-code`가 fallback 시 orphaned partial state를 정리하고 executor를 새로 잡는 흐름과 비슷한 방향으로 AX도 재시도 경계를 더 명확히 가지게 됐습니다. -- 엔진 레벨 `PrefetchableReadOnlyTools`는 `file_read`/`document_read` 중심으로 다시 줄였습니다. `folder_map`, `glob`, `grep`, `multi_read`, `code_search` 같은 구조 탐색/광범위 검색 도구는 prefetch 대상에서 빼서, 탐색 정책과 실행 엔진의 우선순위가 서로 어긋나지 않도록 맞췄습니다. -- `AxAgentExecutionEngine`의 Cowork/Code 빈 응답 처리도 보수적으로 조정했습니다. 이제 최종 텍스트가 비어 있을 때는 실행 이벤트에 실제 파일 경로나 유의미한 완료 요약이 있을 때만 합성 메시지를 만들고, 근거가 없으면 로그 확인을 안내하는 쪽으로 바꿨습니다. - -- 업데이트: 2026-04-09 20:46 (KST) -- Code 핵심 루프에서 `claude-code`와 가장 크게 달랐던 “수정 직후 과한 검증 개입”을 줄였습니다. `TryApplyPostToolVerificationTransitionAsync`는 이제 Code 탭에서 고영향 수정일 때만 별도 검증 LLM 턴을 실행하고, 일반 수정은 메인 루프의 `diff/build/test` 근거 흐름에 맡기도록 바꿨습니다. -- `ApplyCodeQualityFollowUpTransition`도 모든 코드 수정 뒤에 추가 검증 프롬프트를 넣지 않고, 고영향 수정만 즉시 후속 검증을 유도하도록 완화했습니다. 이로써 `file_edit -> file_read -> grep -> file_read` 식의 과도한 재접근이 줄어들도록 정리했습니다. -- `TryApplyCodeCompletionGateTransition`은 diff/build/test 근거가 이미 있는 일반 수정에 대해 `CodeQualityGate`를 중복 발동하지 않도록 조정했고, `FinalReportGate`는 코드 검증 공백이 남아 있을 때는 먼저 열리지 않게 순서를 정리했습니다. - -- 업데이트: 2026-04-09 20:29 (KST) -- `AgentLoopExplorationPolicy`에 현재 반복 기준의 도구 필터링을 추가해, `Localized`/`TopicBased` 요청에서는 `glob`, `grep`, `file_read`, `document_read`, `multi_read`를 먼저 노출하고 `folder_map`은 기본적으로 뒤로 미루거나 제외하도록 조정했습니다. -- `folder_map`은 사용자가 폴더 구조/파일 목록/기존 자료 참조를 명시했거나, 선택 탐색이 몇 차례 실패한 뒤에만 다시 허용합니다. Cowork의 문서형 요청과 Code의 코드 수정 요청이 모두 같은 기준을 따르도록 맞췄습니다. -- `TaskTypePolicy`의 `feature`, `bugfix`, `refactor`, `review`, `docs`, `general` 가이드를 다시 정리해 AX가 `claude-code`처럼 `glob/grep -> targeted read`를 먼저 타게 만들었습니다. 기존처럼 `feature/docs`에서 `folder_map`을 선행 단계처럼 유도하던 문구를 제거했습니다. -- `AgentLoopService`의 no-tool 재시도 프롬프트와 탐색 교정 메시지, 실패 복구 우선순위도 `glob/grep -> targeted read -> folder_map(필요 시만)` 순서로 재정렬했습니다. 이로써 `folder_map` 성공 직후 멈춘 것처럼 보이던 일부 흐름과, 계획만 세우고 첫 도구 선택을 망설이던 Code 루프를 함께 보정했습니다. -- `ChatWindow.SystemPromptBuilder`의 Cowork/Code 시스템 프롬프트는 `모든 응답에 도구 호출 강제`, `첫 항목은 반드시 도구`, `애매하면 folder_map부터` 같은 문구를 제거하고, 실제 `claude-code`처럼 좁은 범위 탐색과 마지막 턴의 텍스트 응답을 허용하는 방향으로 완화했습니다. -- `code_개발`, `code_리뷰`, `code_리팩터링`, `cowork_문서작성`, `cowork_보고서` 프리셋도 같은 기준으로 갱신해 프롬프트 층과 런타임 정책 층이 서로 충돌하지 않도록 정렬했습니다. -- `ChatWindow.FileMentionSuggestions`를 추가해 Cowork/Code 입력창에서 파일명 후보 칩을 즉시 제안합니다. 사용자가 파일명 일부, 경로 조각, 확장자를 입력하면 작업 폴더 인덱스를 바탕으로 관련 파일을 추천하고 클릭 또는 `Tab`으로 삽입할 수 있습니다. -- `AgentLoopExplorationRecovery`를 추가해 `folder_map`의 빈 결과를 자동 복구합니다. `0 files, 0 dirs` 응답 뒤에 실제 파일 후보가 보이면 `folder_map_empty_recovery` 전이를 기록하고, LLM에는 `glob -> file_read/document_read`로 전환하라는 시스템 메시지를 추가합니다. -- 마지막 도구 결과 이후 다음 LLM 호출까지의 대기 시간도 `llm_wait_after_tool_result` 전이로 기록하도록 해, 멈춤 체감이 LLM 대기인지 루프 정체인지 워크플로우 로그만으로 더 빨리 판별할 수 있게 했습니다. -- `DocumentPlannerTool`, `SkillService` 예시 가이드도 같은 선택 탐색 순서로 갱신해 내부 문서 계획 지시와 스킬 샘플이 런타임 정책과 어긋나지 않도록 맞췄습니다. -- `AgentLoopPathStagnation`을 추가해 Code 탭의 동일 경로 재접근 루프를 차단합니다. 기존 가드는 “같은 도구+같은 파라미터” 반복만 강하게 막았지만, 실제 루프는 `file_read -> grep -> file_read`처럼 도구를 바꿔 같은 파일을 계속 두드리는 패턴을 허용했습니다. 이제 동일 경로 읽기 접근이 4회 이상 이어지면 읽기를 중단시키고 `grep/glob으로 호출부 탐색 -> git_tool(diff) -> build_run/test_loop` 순서로 전환하라는 복구 메시지를 주입합니다. - -## claude-code식 transcript 표시 구조 정리 - -- 업데이트: 2026-04-09 13:05 (KST) -- AgentTranscriptDisplayCatalog를 row presentation 중심으로 재구성해 thinking / waiting / compact / tool activity / permission / tool result / status를 개별 transcript row 의미로 다룰 수 있게 정리했습니다. -- ChatWindow.AgentEventRendering은 process feed 계열 이벤트를 같은 GroupKey 단위로 병합해, 긴 Cowork/Code 실행 중 append 수를 줄이면서도 주요 활동 흐름이 기본 transcript에 남도록 조정했습니다. -- PermissionRequestPresentationCatalog, ToolResultPresentationCatalog를 다시 정리해 권한 요청과 도구 결과를 행위/상태 기준으로 구분하고, transcript 렌더와 popup이 같은 메타를 공유하도록 맞췄습니다. -- ChatWindow.FooterPresentation은 execution event가 생긴 뒤에는 프리셋 안내 카드를 자동으로 숨기도록 바꿔 결과/진행 화면을 덮지 않게 했고, Cowork/Code 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. -- render_messages 성능 로그에는 processFeedAppends, processFeedMerges, rowKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript grouping 효과를 수치로 비교할 수 있게 했습니다. - -## 1. ?꾨줈?앺듃 媛쒖슂 - -AX Copilot?€ Windows???앹궛???곗쿂 + AI ?먯씠?꾪듃 ?곗뒪?ы넲 ?깆엯?덈떎. -- **?곗쿂**: Alfred/Raycast ?ㅽ??쇱쓽 ?쇱? 寃€?? 紐낅졊 ?ㅽ뻾, ?꾩젽 -- **?먯씠?꾪듃**: LLM 湲곕컲 ?€?뷀삎 肄붾뱶/臾몄꽌 ?묒뾽 ?먮룞??(?꾧뎄 ?몄텧 猷⑦봽) -- **??諛?*: ?쒖뒪??由ъ냼?? ?대┰蹂대뱶, ?ㅽ겕由곗꺑 ??鍮좊Ⅸ ?묎렐 +- 업데이트: 2026-04-14 17:46 (KST) +- 도구 이름 정합성 문제를 줄이기 위해 `src/AxCopilot/Services/Agent/AgentToolCatalog.cs`를 추가했습니다. canonical id, legacy alias, 탭 노출, 설정 카테고리, 병렬 read-only 분류를 한곳에서 관리하도록 정리했습니다. +- `ToolRegistry`, `AgentLoopService`, `AgentLoopParallelExecution`, `IAgentTool`, `AgentHookRunner`, `SkillService`가 모두 같은 카탈로그를 사용하도록 연결했습니다. 이에 따라 `git/lsp/zip/project_rule/snippet_run` 같은 예전 이름도 런타임에서 자동 정규화됩니다. +- 내부설정 연동도 함께 반영했습니다. `AgentSettingsWindow`와 `SettingsWindow`의 도구 카드, 훅 편집기, 비활성 도구 저장, 도구 권한 저장이 canonical 이름 기준으로 동작하며 기존 저장값은 alias 호환으로 흡수합니다. +- 스킬 관련 설명은 현재 구조에 맞게 완화했습니다. 기본 스킬 폴더와 추가 폴더를 함께 로드하는 흐름, 직접 호출 스킬과 런타임 정책 연결 스킬을 같이 보여주는 방향으로 설정/헬프 문구를 보정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_toolcat\\ -p:IntermediateOutputPath=obj\\verify_toolcat\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\\verify_toolcat_tests\\ -p:IntermediateOutputPath=obj\\verify_toolcat_tests\\` 통과 8 +- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs`의 nullable 경고 1건이 함께 표시되었으나, 이번 변경에서 새 경고를 추가하지는 않았습니다. --- -## 2. 湲곗닠 ?ㅽ깮 +## 1. 프로젝트 개요 -| ??ぉ | 媛?| +AX Copilot은 Windows용 생산성 런처 + AI 에이전트 데스크톱 앱입니다. +- **런처**: Alfred/Raycast 스타일의 퍼지 검색, 명령 실행, 위젯 +- **에이전트**: LLM 기반 대화형 코드/문서 작업 자동화 (도구 호출 루프) +- **독 바**: 시스템 리소스, 클립보드, 스크린샷 등 빠른 접근 + +--- + +## 2. 기술 스택 + +| 항목 | 값 | |------|-----| -| ?꾨젅?꾩썙??| .NET 8 (net8.0-windows10.0.17763.0) | -| UI | WPF + Windows Forms (?섏씠釉뚮━?? | -| ?몄뼱 | C# 12 | -| ?⑦꽩 | MVVM, ?대깽??湲곕컲, ?깃????쒕퉬??| -| ?뚯뒪??| xUnit 2.9 + FluentAssertions 6.12 | -| 鍮뚮뱶 | dotnet CLI, PublishSingleFile | +| 프레임워크 | .NET 8 (net8.0-windows10.0.17763.0) | +| UI | WPF + Windows Forms (하이브리드) | +| 언어 | C# 12 | +| 패턴 | MVVM, 이벤트 기반, 싱글톤 서비스 | +| 테스트 | xUnit 2.9 + FluentAssertions 6.12 | +| 빌드 | dotnet CLI, PublishSingleFile | -### 二쇱슂 NuGet ?⑦궎吏€ +### 주요 NuGet 패키지 -| ?⑦궎吏€ | ?⑸룄 | +| 패키지 | 용도 | |--------|------| -| DocumentFormat.OpenXml 3.2.0 | DOCX/XLSX/PPTX ?앹꽦 | -| Markdig 0.37.0 | Markdown ??HTML ?뚮뜑留?| -| Microsoft.Data.Sqlite 8.0 | SQLite (?€???€?μ냼) | -| Microsoft.Web.WebView2 | HTML 誘몃━蹂닿린, 媛€?대뱶 酉곗뼱 | -| QRCoder 1.6.0 | QR 肄붾뱶 ?앹꽦 | -| System.Security.Cryptography.ProtectedData | DPAPI ?뷀샇??| -| UglyToad.PdfPig | PDF ?쎄린 | +| DocumentFormat.OpenXml 3.2.0 | DOCX/XLSX/PPTX 생성 | +| Markdig 0.37.0 | Markdown → HTML 렌더링 | +| Microsoft.Data.Sqlite 8.0 | SQLite (대화 저장소) | +| Microsoft.Web.WebView2 | HTML 미리보기, 가이드 뷰어 | +| QRCoder 1.6.0 | QR 코드 생성 | +| System.Security.Cryptography.ProtectedData | DPAPI 암호화 | +| UglyToad.PdfPig | PDF 읽기 | --- -## 3. ?붾(??援ъ“ +## 3. 솔루션 구조 ``` src/ -?쒋??€ AxCopilot/ # 硫붿씤 WPF ??(v0.7.3) -?? ?쒋??€ Assets/ # ?꾩씠肄? ?꾨━??JSON, ?뷀샇?붾맂 媛€?대뱶, 留덉뒪肄뷀듃 -?? ?쒋??€ Core/ # FuzzyEngine, CommandResolver, InputListener, PluginHost -?? ?쒋??€ Handlers/ # 136媛?鍮뚰듃??紐낅졊 ?몃뱾???? ?쒋??€ Models/ # AppSettings, ChatModels, McpSettings -?? ?쒋??€ Security/ # AntiTamper (?붾쾭嫄??붿뺨?뚯씪???먯?) -?? ?쒋??€ Services/ # 60媛??쒕퉬???? ?? ?붴??€ Agent/ # ?먯씠?꾪듃 猷⑦봽 + 114媛??꾧뎄 -?? ?쒋??€ Themes/ # 9媛??뚮쭏 (Dark, Light, OLED, Nord, Monokai ?? -?? ?쒋??€ ViewModels/ # LauncherViewModel, SettingsViewModel, StatisticsViewModel -?? ?붴??€ Views/ # 30媛?XAML ?덈룄???쒋??€ AxCopilot.SDK/ # ?뚮윭洹몄씤 SDK (IActionHandler ?명꽣?섏씠?? -?쒋??€ AxCopilot.Installer/ # Windows Forms ?ㅼ튂 ?꾨줈洹몃옩 (.NET Framework 4.8) -?쒋??€ AxCopilot.Tests/ # xUnit ?⑥쐞/?듯빀 ?뚯뒪???붴??€ AxKeyEncryptor/ # API ??DPAPI ?뷀샇???좏떥由ы떚 +├── AxCopilot/ # 메인 WPF 앱 (v0.7.3) +│ ├── Assets/ # 아이콘, 프리셋 JSON, 암호화된 가이드, 마스코트 +│ ├── Core/ # FuzzyEngine, CommandResolver, InputListener, PluginHost +│ ├── Handlers/ # 136개 빌트인 명령 핸들러 +│ ├── Models/ # AppSettings, ChatModels, McpSettings +│ ├── Security/ # AntiTamper (디버거/디컴파일러 탐지) +│ ├── Services/ # 60개 서비스 +│ │ └── Agent/ # 에이전트 루프 + 114개 도구 +│ ├── Themes/ # 9개 테마 (Dark, Light, OLED, Nord, Monokai 등) +│ ├── ViewModels/ # LauncherViewModel, SettingsViewModel, StatisticsViewModel +│ └── Views/ # 30개 XAML 윈도우 +├── AxCopilot.SDK/ # 플러그인 SDK (IActionHandler 인터페이스) +├── AxCopilot.Installer/ # Windows Forms 설치 프로그램 (.NET Framework 4.8) +├── AxCopilot.Tests/ # xUnit 단위/통합 테스트 +└── AxKeyEncryptor/ # API 키 DPAPI 암호화 유틸리티 ``` --- -## 4. ???쒖옉 ?먮쫫 (App.xaml.cs) +## 4. 앱 시작 흐름 (App.xaml.cs) ``` OnStartup() - ?쒋? AntiTamper ?붾쾭嫄?媛먯? (Release 鍮뚮뱶) - ?쒋? ?⑥씪 ?몄뒪?댁뒪 裕ㅽ뀓???뺤씤 - ?쒋? SettingsService 珥덇린??+ ?ㅼ젙 濡쒕뱶 - ?쒋? ChatStorageService 蹂닿? ?뺤콉 ?ㅽ뻾 (留뚮즺 ?€???뺣━) - ?쒋? L10n ?몄뼱 珥덇린?? ?쒋? ?쒕퉬??珥덇린?? ?? ?쒋? AgentMemoryService - ?? ?쒋? ChatSessionStateService - ?? ?쒋? AppStateService - ?? ?쒋? IndexService (諛깃렇?쇱슫???뚯씪 ?몃뜳?? - ?? ?쒋? FuzzyEngine + CommandResolver - ?? ?쒋? ContextManager - ?? ?쒋? SessionTrackingService - ?? ?쒋? WorktimeReminderService - ?? ?붴? ClipboardHistoryService - ?쒋? 鍮뚰듃???몃뱾???깅줉 (136媛? - ?쒋? SchedulerService + PluginHost 珥덇린?? ?쒋? InputListener ?쒖옉 (湲€濡쒕쾶 ?ロ궎) - ?붴? ?곗쿂/?ㅼ젙/?몃젅???덈룄???앹꽦 + ├─ AntiTamper 디버거 감지 (Release 빌드) + ├─ 단일 인스턴스 뮤텍스 확인 + ├─ SettingsService 초기화 + 설정 로드 + ├─ ChatStorageService 보관 정책 실행 (만료 대화 정리) + ├─ L10n 언어 초기화 + ├─ 서비스 초기화 + │ ├─ AgentMemoryService + │ ├─ ChatSessionStateService + │ ├─ AppStateService + │ ├─ IndexService (백그라운드 파일 인덱싱) + │ ├─ FuzzyEngine + CommandResolver + │ ├─ ContextManager + │ ├─ SessionTrackingService + │ ├─ WorktimeReminderService + │ └─ ClipboardHistoryService + ├─ 빌트인 핸들러 등록 (136개) + ├─ SchedulerService + PluginHost 초기화 + ├─ InputListener 시작 (글로벌 핫키) + └─ 런처/설정/트레이 윈도우 생성 ``` --- -## 5. ?듭떖 ?꾪궎?띿쿂 +## 5. 핵심 아키텍처 -### 5.1 ?곗쿂 (Launcher) +### 5.1 런처 (Launcher) -**寃€???뚯씠?꾨씪??*: ?ъ슜???낅젰 ??`CommandResolver` (?묐몢??留ㅼ묶) ??`FuzzyEngine` (?쇱? 寃€?? ??寃곌낵 ?뺣젹 ??UI ?뚮뜑留? -- `FuzzyEngine`: ?뚯씪 ?몃뜳??湲곕컲 ?쇱? 留ㅼ묶, ?먯닔 ?쒖쐞 -- `CommandResolver`: ?몃뱾???쇱슦??(?묐몢??`@`, `!`, `#`, `~`, `>`, `$` ?? -- `IndexService`: 諛깃렇?쇱슫???뚯씪 ?몃뜳??(`.git`, `node_modules` ???쒖쇅) +**검색 파이프라인**: 사용자 입력 → `CommandResolver` (접두어 매칭) → `FuzzyEngine` (퍼지 검색) → 결과 정렬 → UI 렌더링 -**?꾩젽**: ?깅뒫 紐⑤땲?? ?щえ?꾨줈, 硫붾え, ?좎뵪, 罹섎┛?? 諛고꽣由? -### 5.2 ?먯씠?꾪듃 (Agent Loop) +- `FuzzyEngine`: 파일 인덱스 기반 퍼지 매칭, 점수 순위 +- `CommandResolver`: 핸들러 라우팅 (접두어 `@`, `!`, `#`, `~`, `>`, `$` 등) +- `IndexService`: 백그라운드 파일 인덱싱 (`.git`, `node_modules` 등 제외) + +**위젯**: 성능 모니터, 포모도로, 메모, 날씨, 캘린더, 배터리 + +### 5.2 에이전트 (Agent Loop) ``` -?ъ슜??硫붿떆吏€ - ??LlmService.StreamAsync() (LLM API ?몄텧) - ???묐떟 ?ㅽ듃由щ컢 ?섏떊 - ???꾧뎄 ?몄텧 媛먯? ?? - ??ToolRegistry?먯꽌 ?꾧뎄 議고쉶 - ??沅뚰븳 ?뺤씤 (AskPermissionCallback) - ???꾧뎄 ?ㅽ뻾 - ??寃곌낵瑜?而⑦뀓?ㅽ듃??異붽? - ??LLM ?ы샇異?(諛섎났) - ??理쒖쥌 ?띿뒪???묐떟 諛섑솚 +사용자 메시지 + → LlmService.StreamAsync() (LLM API 호출) + → 응답 스트리밍 수신 + → 도구 호출 감지 시: + → ToolRegistry에서 도구 조회 + → 권한 확인 (AskPermissionCallback) + → 도구 실행 + → 결과를 컨텍스트에 추가 + → LLM 재호출 (반복) + → 최종 텍스트 응답 반환 ``` -**?듭떖 ?대옒??*: -- `AgentLoopService` ??猷⑦봽 ?붿쭊 (諛섎났, ?쇱떆?뺤?/?ш컻, ?대깽??諛쒗뻾) -- `AxAgentExecutionEngine` ???꾧뎄 ?ㅽ뻾 議곗쑉 -- `AgentLoopParallelExecution` ??蹂묐젹 ?꾧뎄 ?ㅽ뻾 -- `AgentLoopTransitions` / `.Execution` ???곹깭 ?꾩씠 濡쒖쭅 -- `ToolRegistry` ???꾧뎄 ?깅줉/議고쉶 -- `ContextCondenser` ??而⑦뀓?ㅽ듃 ?뺤텞 (?좏겙 愿€由? +**핵심 클래스**: +- `AgentLoopService` — 루프 엔진 (반복, 일시정지/재개, 이벤트 발행) +- `AxAgentExecutionEngine` — 도구 실행 조율 +- `AgentLoopParallelExecution` — 병렬 도구 실행 +- `AgentLoopTransitions` / `.Execution` — 상태 전이 로직 +- `ToolRegistry` — 도구 등록/조회 +- `ContextCondenser` — 컨텍스트 압축 (토큰 관리) -**?꾧뎄 移댄뀒怨좊━** (114媛?: -| 移댄뀒怨좊━ | ?덉떆 | +**도구 카테고리** (114개): +| 카테고리 | 예시 | |---------|------| -| ?뚯씪 I/O | FileReadTool, FileEditTool, FileManageTool, FileWriteTool | -| 寃€??| GlobTool, GrepTool, CodeSearchTool, FileSearchTool | -| 臾몄꽌 | DocumentReaderTool, ExcelSkill, DocxSkill, PptxSkill, CsvSkill, HtmlSkill | -| 肄붾뱶 | BuildRunTool, SnippetRunnerTool, CodeReviewTool, TestLoopTool, LspTool | -| ?곗씠??| JsonTool, XmlTool, SqlTool, DataPivotTool, RegexTool | -| ?쒖뒪??| ProcessTool, EnvTool, ZipTool, ClipboardTool | -| 怨꾪쉷/異붿쟻 | TodoWriteTool, TaskTrackerTool, CheckpointTool, PlaybookTool | -| ?ъ슜??| UserAskTool, SuggestActionsTool, NotifyTool | +| 파일 I/O | FileReadTool, FileEditTool, FileManageTool, FileWriteTool | +| 검색 | GlobTool, GrepTool, CodeSearchTool, FileSearchTool | +| 문서 | DocumentReaderTool, ExcelSkill, DocxSkill, PptxSkill, CsvSkill, HtmlSkill | +| 코드 | BuildRunTool, SnippetRunnerTool, CodeReviewTool, TestLoopTool, LspTool | +| 데이터 | JsonTool, XmlTool, SqlTool, DataPivotTool, RegexTool | +| 시스템 | ProcessTool, EnvTool, ZipTool, ClipboardTool | +| 계획/추적 | TodoWriteTool, TaskTrackerTool, CheckpointTool, PlaybookTool | +| 사용자 | UserAskTool, SuggestActionsTool, NotifyTool | | MCP | McpTool, McpListResourcesTool, McpReadResourceTool | -### 5.3 LLM ?쒕퉬?? -**吏€??怨듦툒??*: -| ?쒕퉬??| ?ㅻ챸 | +**탭별 도구 필터링** (`ToolRegistry.ToolTabOverrides`): + +`IAgentTool.TabCategory` 또는 `ToolTabOverrides` 딕셔너리로 도구를 탭별로 분류합니다. +`GetActiveToolsForTab(activeTab)` 메서드가 현재 탭에 맞는 도구만 LLM에 전송하여 토큰을 절약합니다. + +| 탭 | 활성 도구 범위 | 설명 | +|------|--------------|------| +| **Chat** | 0개 | 순수 대화. 도구 없이 LLM만 응답 | +| **Cowork** | ~50개 | 파일/검색 + 문서생성(xlsx, docx, pptx...) + 데이터/유틸 | +| **Code** | ~50개 | 파일/검색 + 개발(git, build, lsp...) + 태스크/워크트리 + 유틸 | + +- Chat 탭 토큰 절약: ~14,400토큰 → 0 (도구 정의 완전 제거) +- Cowork/Code: 교차 제외로 각 ~3,600토큰 추가 절약 + +### 5.3 LLM 서비스 + +**지원 공급자**: +| 서비스 | 설명 | |--------|------| -| `claude` / `sigmoid` | Anthropic Claude (Sigmoid API 寃쎌쑀) | +| `claude` / `sigmoid` | Anthropic Claude (Sigmoid API 경유) | | `gemini` | Google Gemini API | -| `vllm` | OpenAI ?명솚 vLLM (IBM CP4D 吏€???ы븿) | -| `ollama` | 濡쒖뺄 Ollama 紐⑤뜽 | +| `vllm` | OpenAI 호환 vLLM (IBM CP4D 지원 포함) | +| `ollama` | 로컬 Ollama 모델 | -**紐⑤뜽 ?쇱슦??*: `ModelRouterService`瑜??듯븳 ?ㅻ쾭?쇱씠???ㅽ깮 ???€??以?紐⑤뜽/?쒕퉬?ㅻ? ?숈쟻?쇰줈 ?꾪솚 媛€?? -**?좏겙 愿€由?*: `TokenEstimator`濡?而⑦뀓?ㅽ듃 湲몄씠 異붿젙, ?ㅻ쾭?뚮줈????`ContextCondenser`媛€ ?먮룞 ?뺤텞 +**모델 라우팅**: `ModelRouterService`를 통한 오버라이드 스택 — 대화 중 모델/서비스를 동적으로 전환 가능 -### 5.4 ?€???€?μ냼 +**토큰 관리**: `TokenEstimator`로 컨텍스트 길이 추정, 오버플로우 시 `ContextCondenser`가 자동 압축 +- `EstimateBaseOverhead(systemPromptLength, toolCount)`: 시스템 프롬프트 + 도구 정의 오버헤드 추정 +- `_tool_use_blocks` 메시지 0.6x, `tool_result` 메시지 0.7x 할인 적용 +- 컨텍스트 사용량 표시에 시스템 프롬프트 + 도구 오버헤드 포함 + +### 5.4 대화 저장소 + +- `ChatStorageService`: SQLite 기반 대화 영속화 +- `ChatSessionStateService`: 메모리 내 세션 상태 관리 +- `ChatConversation`: 메시지 목록 + 실행 이벤트 타임라인 + `Archived` 아카이브 플래그 -- `ChatStorageService`: SQLite 湲곕컲 ?€???곸냽??- `ChatSessionStateService`: 硫붾え由????몄뀡 ?곹깭 愿€由?- `ChatConversation`: 硫붿떆吏€ 紐⑸줉 + ?ㅽ뻾 ?대깽???€?꾨씪?? --- -## 6. UI 怨꾩링 +## 6. UI 계층 -### 二쇱슂 ?덈룄?? -| ?덈룄??| ??븷 | +### 주요 윈도우 + +| 윈도우 | 역할 | |--------|------| -| `LauncherWindow` | 硫붿씤 ?곗쿂 (寃€?? ?꾩젽, 寃곌낵 紐⑸줉) | -| `ChatWindow` | AI ?먯씠?꾪듃 ?€??(梨꾪똿/Cowork/肄붾뱶 ?? | -| `DockBarWindow` | ??諛?(?쒖뒪??由ъ냼?? 鍮좊Ⅸ ?묎렐) | -| `SettingsWindow` | ?ㅼ젙 愿€由?| -| `AgentSettingsWindow` | ?먯씠?꾪듃 ?꾩슜 ?ㅼ젙 | -| `AgentStatsDashboardWindow` | ?먯씠?꾪듃 ?듦퀎 ?€?쒕낫??| -| `SkillEditorWindow` | ?ㅽ궗 ?몄쭛湲?| -| `SkillGalleryWindow` | ?ㅽ궗 媛ㅻ윭由?| -| `TrayMenuWindow` | ?쒖뒪???몃젅??硫붾돱 | -| `PreviewWindow` | 臾몄꽌 誘몃━蹂닿린 (WebView2) | +| `LauncherWindow` | 메인 런처 (검색, 위젯, 결과 목록) | +| `ChatWindow` | AI 에이전트 대화 (채팅/Cowork/코드 탭) | +| `DockBarWindow` | 독 바 (시스템 리소스, 빠른 접근) | +| `SettingsWindow` | 설정 관리 | +| `AgentSettingsWindow` | 에이전트 전용 설정 | +| `AgentStatsDashboardWindow` | 에이전트 통계 대시보드 | +| `SkillEditorWindow` | 스킬 편집기 | +| `SkillGalleryWindow` | 스킬 갤러리 | +| `TrayMenuWindow` | 시스템 트레이 메뉴 | +| `PreviewWindow` | 문서 미리보기 (WebView2) | -### ChatWindow 遺꾪븷 援ъ“ +### ChatWindow 분할 구조 -`ChatWindow.xaml.cs`??partial class濡?湲곕뒫蹂?遺꾪븷: +`ChatWindow.xaml.cs`는 partial class로 기능별 분할: -| ?뚯씪 | ??븷 | +| 파일 | 역할 | |------|------| -| `ChatWindow.xaml.cs` | 硫붿씤 ?ㅼ??ㅽ듃?덉씠?? ?ㅽ듃由щ컢, ?낅젰 泥섎━ | -| `ChatWindow.AgentEventProcessor.cs` | ?먯씠?꾪듃 ?대깽???섏떊/?쇱슦??| -| `ChatWindow.AgentEventRendering.cs` | ?먯씠?꾪듃 ?대깽??諛곕꼫/移대뱶 ?뚮뜑留?| -| `ChatWindow.ComposerQueuePresentation.cs` | ?묒꽦湲???UI | -| `ChatWindow.ContextUsagePresentation.cs` | 而⑦뀓?ㅽ듃 ?ъ슜??留??앹뾽 | -| `ChatWindow.ConversationFilterPresentation.cs` | ?€???꾪꽣留?| -| `ChatWindow.ConversationListPresentation.cs` | ?ъ씠?쒕컮 ?€??紐⑸줉 | -| `ChatWindow.ConversationManagementPresentation.cs` | ?€???앹꽦/??젣/愿€由?| -| `ChatWindow.FileBrowserPresentation.cs` | ?뚯씪 釉뚮씪?곗? UI | -| `ChatWindow.FooterPresentation.cs` | ?섎떒 諛?(?대뜑, 沅뚰븳) | -| `ChatWindow.GitBranchPresentation.cs` | Git 釉뚮옖移??쒖떆/?꾪솚 | -| `ChatWindow.LiveProgressPresentation.cs` | ?ㅼ떆媛?吏꾪뻾 ?곹깭 | -| `ChatWindow.MessageBubblePresentation.cs` | 硫붿떆吏€ 踰꾨툝 ?뚮뜑留?| -| `ChatWindow.MessageInteractions.cs` | 硫붿떆吏€ 蹂듭궗/?몄쭛/?ъ쟾??| -| `ChatWindow.PermissionPresentation.cs` | 沅뚰븳 ?앹뾽/諛곕꼫 UI | -| `ChatWindow.PlanApprovalPresentation.cs` | 怨꾪쉷 ?뱀씤 移대뱶 | -| `ChatWindow.PopupPresentation.cs` | 怨듯넻 ?앹뾽 援ъ꽦 | -| `ChatWindow.PreviewPresentation.cs` | ?뚯씪 誘몃━蹂닿린 ??| -| `ChatWindow.SelectionPopupPresentation.cs` | ?뚰겕?몃━ ?좏깮 ?앹뾽 | -| `ChatWindow.SidebarInteractionPresentation.cs` | ?ъ씠?쒕컮 ?곹샇?묒슜 | -| `ChatWindow.StatusPresentation.cs` | ?곹깭 諛곗?/?ㅽ듃由?| -| `ChatWindow.SurfaceVisualPresentation.cs` | ?쒓컖 ?④낵 (湲€濡쒖슦, ?꾩뒪 ?? | -| `ChatWindow.TaskSummary.cs` | ?묒뾽 ?붿빟 移대뱶 | -| `ChatWindow.TimelinePresentation.cs` | ?€?꾨씪???뺣젹, 罹먯떆, ?대깽???꾪꽣留?| -| `ChatWindow.TopicPresetPresentation.cs` | 二쇱젣 ?꾨━??UI | -| `ChatWindow.TranscriptHost.cs` | ?몃옖?ㅽ겕由쏀듃 ?몄뒪??而⑦뀒?대꼫 | -| `ChatWindow.TranscriptPolicy.cs` | ?몃옖?ㅽ겕由쏀듃 ?쒖떆 ?뺤콉 | -| `ChatWindow.TranscriptRenderExecution.cs` | ?몃옖?ㅽ겕由쏀듃 ?뚮뜑 ?ㅽ뻾 | -| `ChatWindow.TranscriptRenderPlanner.cs` | ?몃옖?ㅽ겕由쏀듃 ?뚮뜑 怨꾪쉷 | -| `ChatWindow.TranscriptRendering.cs` | ?몃옖?ㅽ겕由쏀듃 ?뚮뜑留?| -| `ChatWindow.TranscriptVirtualization.cs` | ?몃옖?ㅽ겕由쏀듃 媛€?곹솕 (?€洹쒕え ?€?? | -| `ChatWindow.UserAskPresentation.cs` | ?ъ슜??吏덈Ц ?몃씪??移대뱶 | -| `ChatWindow.VisualInteractionHelpers.cs` | ?쒓컖 ?곹샇?묒슜 ?ы띁 | +| `ChatWindow.xaml.cs` | 메인 오케스트레이션, 스트리밍, 입력 처리 | +| `ChatWindow.AgentEventProcessor.cs` | 에이전트 이벤트 수신/라우팅 | +| `ChatWindow.AgentEventRendering.cs` | 에이전트 이벤트 배너/카드 렌더링 (SessionStart/UserPromptSubmit 숨김) | +| `ChatWindow.AgentStatusPresentation.cs` | 에이전트 실시간 상태 표시 | +| `ChatWindow.ComposerQueuePresentation.cs` | 작성기 큐 UI | +| `ChatWindow.ContextUsagePresentation.cs` | 컨텍스트 사용량 링/팝업 | +| `ChatWindow.ConversationFilterPresentation.cs` | 대화 필터링 | +| `ChatWindow.ConversationListPresentation.cs` | 사이드바 대화 목록 | +| `ChatWindow.ConversationManagementPresentation.cs` | 대화 생성/삭제/관리 | +| `ChatWindow.FileBrowserPresentation.cs` | 파일 브라우저 UI | +| `ChatWindow.FooterPresentation.cs` | 하단 바 (폴더, 권한) | +| `ChatWindow.GitBranchPresentation.cs` | Git 브랜치 표시/전환 | +| `ChatWindow.LiveProgressPresentation.cs` | 실시간 진행 상태 | +| `ChatWindow.MessageBubblePresentation.cs` | 메시지 버블 렌더링 | +| `ChatWindow.MessageInteractions.cs` | 메시지 복사/편집/재전송 | +| `ChatWindow.PermissionPresentation.cs` | 권한 팝업/배너 UI | +| `ChatWindow.PlanApprovalPresentation.cs` | 계획 승인 카드 | +| `ChatWindow.PopupPresentation.cs` | 공통 팝업 구성 | +| `ChatWindow.PreviewPresentation.cs` | 파일 미리보기 탭 | +| `ChatWindow.SelectionPopupPresentation.cs` | 워크트리 선택 팝업 | +| `ChatWindow.SidebarInteractionPresentation.cs` | 사이드바 상호작용 | +| `ChatWindow.StatusPresentation.cs` | 상태 배지/스트립 | +| `ChatWindow.SurfaceVisualPresentation.cs` | 시각 효과 (글로우, 펄스 등) | +| `ChatWindow.TaskSummary.cs` | 작업 요약 카드 | +| `ChatWindow.TimelinePresentation.cs` | 타임라인 정렬, 캐시, 이벤트 필터링 | +| `ChatWindow.TopicPresetPresentation.cs` | 주제 프리셋 UI | +| `ChatWindow.TranscriptHost.cs` | 트랜스크립트 호스트 컨테이너 | +| `ChatWindow.TranscriptPolicy.cs` | 트랜스크립트 표시 정책 | +| `ChatWindow.TranscriptRenderExecution.cs` | 트랜스크립트 렌더 실행 | +| `ChatWindow.TranscriptRenderPlanner.cs` | 트랜스크립트 렌더 계획 | +| `ChatWindow.TranscriptRendering.cs` | 트랜스크립트 렌더링 | +| `ChatWindow.TranscriptVirtualization.cs` | 트랜스크립트 가상화 (대규모 대화) | +| `ChatWindow.SystemPromptBuilder.cs` | 시스템 프롬프트 동적 조립 (탭/프리셋/컨텍스트 주입) | +| `ChatWindow.OverlaySettingsPresentation.cs` | 인라인 설정 팝업 (모델, 빠른액션) | +| `ChatWindow.UserAskPresentation.cs` | 사용자 질문 인라인 카드 | +| `ChatWindow.VisualInteractionHelpers.cs` | 시각 상호작용 헬퍼 | -### ?뚮쭏 ?쒖뒪?? -9媛??뚮쭏 XAML 由ъ냼???뺤뀛?덈━: `Dark`, `Light`, `OLED`, `Nord`, `Monokai`, `Catppuccin`, `Sepia`, `Alfred`, `AlfredLight` +### 테마 시스템 -?고????뚮쭏 ?꾪솚: `SettingsService.Settings.Launcher.Theme` 蹂€寃???由ъ냼???뺤뀛?덈━ 援먯껜 +9개 테마 XAML 리소스 딕셔너리: `Dark`, `Light`, `OLED`, `Nord`, `Monokai`, `Catppuccin`, `Sepia`, `Alfred`, `AlfredLight` + +런타임 테마 전환: `SettingsService.Settings.Launcher.Theme` 변경 → 리소스 딕셔너리 교체 --- -## 7. ?ㅼ젙 援ъ“ (AppSettings) +## 7. 설정 구조 (AppSettings) -### 理쒖긽???ㅼ젙 +### 최상위 설정 -| ?띿꽦 | 湲곕낯媛?| ?ㅻ챸 | +| 속성 | 기본값 | 설명 | |------|--------|------| -| `AiEnabled` | true | AI 湲곕뒫 ?쒖꽦??| -| `OperationMode` | "internal" | ?댁쁺 紐⑤뱶 (internal/external) | -| `Hotkey` | "Alt+Space" | ?곗쿂 ?⑥텞??| -| `CleanupPeriodDays` | 30 | ?€??蹂닿? 湲곌컙 (?? | -| `InternalModeEnabled` | true | ?щ궡 紐⑤뱶 ?щ? | +| `AiEnabled` | true | AI 기능 활성화 | +| `OperationMode` | "internal" | 운영 모드 (internal/external) | +| `Hotkey` | "Alt+Space" | 런처 단축키 | +| `CleanupPeriodDays` | 30 | 대화 보관 기간 (일) | +| `InternalModeEnabled` | true | 사내 모드 여부 | -### LauncherSettings (以묒꺽) +### LauncherSettings (중첩) -| 洹몃9 | 二쇱슂 ?띿꽦 | +| 그룹 | 주요 속성 | |------|----------| -| ?쒖떆 | `Theme`, `Opacity`, `Position`, `Width`, `MaxResults` | -| 湲€濡쒖슦 | `EnableRainbowGlow`, `EnableSelectionGlow`, `ShowLauncherBorder` | -| ?꾩젽 | `ShowWidgetPerf`, `ShowWidgetPomo`, `ShowWidgetNote`, `ShowWidgetWeather`, `ShowWidgetCalendar`, `ShowWidgetBattery` | -| ??諛?| `DockBarItems`, `DockBarAutoShow`, `DockBarOpacity`, `DockBarRainbowGlow` | -| 湲곕뒫 | `EnableFavorites`, `EnableRecent`, `EnableActionMode`, `EnableClipboardAutoCategory` | +| 표시 | `Theme`, `Opacity`, `Position`, `Width`, `MaxResults` | +| 글로우 | `EnableRainbowGlow`, `EnableSelectionGlow`, `ShowLauncherBorder` | +| 위젯 | `ShowWidgetPerf`, `ShowWidgetPomo`, `ShowWidgetNote`, `ShowWidgetWeather`, `ShowWidgetCalendar`, `ShowWidgetBattery` | +| 독 바 | `DockBarItems`, `DockBarAutoShow`, `DockBarOpacity`, `DockBarRainbowGlow` | +| 기능 | `EnableFavorites`, `EnableRecent`, `EnableActionMode`, `EnableClipboardAutoCategory` | -### LlmSettings (以묒꺽) +### LlmSettings (중첩) -?먯씠?꾪듃??LLM ?곌껐 ?ㅼ젙: ?쒕퉬???좏깮, 紐⑤뜽, API ??(DPAPI ?뷀샇??, ?붾뱶?ъ씤?? ?⑤룄, 理쒕? ?좏겙 ?? -| ?띿꽦 | 湲곕낯媛?| ?ㅻ챸 | +에이전트의 LLM 연결 설정: 서비스 선택, 모델, API 키 (DPAPI 암호화), 엔드포인트, 온도, 최대 토큰 등 + +| 속성 | 기본값 | 설명 | |------|--------|------| -| `UseAutomaticProfileTemperature` | true | ?깅줉 紐⑤뜽 ?꾨줈?뚯씪???먮룞 temperature ?뺤콉 | -| `EnableDetailedLog` | false | ?뚰겕?뚮줈???곸꽭 濡쒓렇 (LLM ?붿껌/?묐떟, ?꾧뎄 ?대젰) | -| `DetailedLogRetentionDays` | 3 | ?곸꽭 濡쒓렇 蹂닿? 湲곌컙 (?? | -| `EnableRawLlmLog` | false | LLM ?붿껌/?묐떟 ?먮Ц 湲곕줉 (?붾쾭源낆슜) | +| `UseAutomaticProfileTemperature` | true | 등록 모델 프로파일의 자동 temperature 정책 | +| `EnableDetailedLog` | false | 워크플로우 상세 로그 (LLM 요청/응답, 도구 이력) | +| `DetailedLogRetentionDays` | 3 | 상세 로그 보관 기간 (일) | +| `EnableRawLlmLog` | false | LLM 요청/응답 원문 기록 (디버깅용) | -### RegisteredModel ?ㅽ뻾 ?꾨줈?뚯씪 +### RegisteredModel 실행 프로파일 -紐⑤뜽蹂?`ExecutionProfile`濡??꾧뎄 ?몄텧 媛뺣룄, ?ъ떆?? 硫붾え由??뺤텞 二쇱엯?됱쓣 議곗젅: +모델별 `ExecutionProfile`로 도구 호출 강도, 재시도, 메모리/압축 주입량을 조절: -| ?꾨줈?뚯씪 | ?ㅻ챸 | +| 프로파일 | 설명 | |---------|------| -| `balanced` | 湲곕낯 洹좏삎 紐⑤뱶 | -| `tool_call_strict` | ?꾧뎄 ?몄텧 媛뺤젣/?꾧꺽 紐⑤뱶 | -| `reasoning_first` | 異붾줎 ?곗꽑 紐⑤뱶 | -| `fast_readonly` | 鍮좊Ⅸ ?쎄린 ?꾩슜 紐⑤뱶 | -| `document_heavy` | 臾몄꽌 泥섎━ 吏묒쨷 紐⑤뱶 | +| `balanced` | 기본 균형 모드 | +| `tool_call_strict` | 도구 호출 강제/엄격 모드 | +| `reasoning_first` | 추론 우선 모드 | +| `fast_readonly` | 빠른 읽기 전용 모드 | +| `document_heavy` | 문서 처리 집중 모드 | --- -## 8. ?뚮윭洹몄씤 ?쒖뒪?? +## 8. 플러그인 시스템 + ### SDK (AxCopilot.SDK) ```csharp public interface IActionHandler { - string? Prefix { get; } // ?묐몢??(null?대㈃ ?쇱? 寃€?됰쭔) + string? Prefix { get; } // 접두어 (null이면 퍼지 검색만) PluginMetadata Metadata { get; } - Task -## claude-code식 transcript 표시 구조 정리 + Task> GetItemsAsync(string query, CancellationToken ct); + Task ExecuteAsync(LauncherItem item, CancellationToken ct); +} +``` -- 업데이트: 2026-04-09 13:05 (KST) -- AgentTranscriptDisplayCatalog를 row presentation 중심으로 재구성해 hinking / waiting / compact / tool activity / permission / tool result / status를 개별 transcript row 의미로 다룰 수 있게 정리했습니다. -- ChatWindow.AgentEventRendering은 process feed 계열 이벤트를 같은 GroupKey 단위로 병합해, 긴 Cowork/Code 실행 중 append 수를 줄이면서도 주요 활동 흐름이 기본 transcript에 남도록 조정했습니다. -- PermissionRequestPresentationCatalog, ToolResultPresentationCatalog를 다시 정리해 권한 요청과 도구 결과를 행위/상태 기준으로 구분하고, transcript 렌더와 popup이 같은 메타를 공유하도록 맞췄습니다. -- ChatWindow.FooterPresentation은 execution event가 생긴 뒤에는 프리셋 안내 카드를 자동으로 숨기도록 바꿔 결과/진행 화면을 덮지 않게 했고, Cowork/Code 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. -- -ender_messages 성능 로그에는 processFeedAppends, processFeedMerges, -owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript grouping 효과를 수치로 비교할 수 있게 했습니다. +### 개발 방법 -## 9. 鍮뚮뱶 諛??ㅽ뻾 +1. `AxCopilot.SDK` 참조하여 `IActionHandler` 구현 +2. 빌드된 `.dll`을 `settings.json`의 `Plugins` 배열에 경로 등록 +3. `PluginHost`가 앱 시작 시 동적 로드 -### 媛쒕컻 鍮뚮뱶 +--- + +## 9. 빌드 및 실행 + +### 개발 빌드 ```bash dotnet build src/AxCopilot/AxCopilot.csproj ``` -### 由대━??鍮뚮뱶 (?⑥씪 ?뚯씪) +### 빠른 빌드 (암호화/설치프로그램 생략) + +```bash +./build-quick.sh +``` + +메인 앱만 self-contained 단일 파일로 빌드합니다. AxKeyEncryptor/Installer/난독화를 건너뛰어 빠른 개발 반복에 적합합니다. + +### 릴리스 빌드 (단일 파일) ```bash dotnet publish src/AxCopilot/AxCopilot.csproj -c Release -r win-x64 --self-contained ``` -由대━??鍮뚮뱶 ?듭뀡: -- `PublishSingleFile`: ?⑥씪 ?ㅽ뻾 ?뚯씪 -- `EnableCompressionInSingleFile`: ?뺤텞 ?곸슜 -- `PublishReadyToRun`: AOT ?꾨━而댄뙆??- `DebugType=none`: ?붾쾭洹??щ낵 ?쒓굅 -- `TrimMode=partial`: IL ?몃━諛? -### ?뚯뒪?? +릴리스 빌드 옵션: +- `PublishSingleFile`: 단일 실행 파일 +- `EnableCompressionInSingleFile`: 압축 적용 +- `PublishReadyToRun`: AOT 프리컴파일 +- `DebugType=none`: 디버그 심볼 제거 +- `TrimMode=partial`: IL 트리밍 + +### 테스트 + ```bash dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj ``` --- -## 10. 踰꾩쟾 愿€由? -- `AxCopilot.csproj`??`` ?쒓렇 ?섎굹留?蹂€寃쏀븯硫????꾩껜??諛섏쁺 -- ?ㅼ젙 ?ㅽ궎留?踰꾩쟾?€ `SettingsService.cs` ??`CurrentSettingsVersion`?먯꽌 蹂꾨룄 愿€由?- 留덉씠洹몃젅?댁뀡: `SettingsService`媛€ ?댁쟾 踰꾩쟾 ?ㅼ젙 ?뚯씪???먮룞 ?낃렇?덉씠?? +## 10. 버전 관리 + +- `AxCopilot.csproj`의 `` 태그 하나만 변경하면 앱 전체에 반영 +- 설정 스키마 버전은 `SettingsService.cs` → `CurrentSettingsVersion`에서 별도 관리 +- 마이그레이션: `SettingsService`가 이전 버전 설정 파일을 자동 업그레이드 + --- -## 11. 蹂댁븞 +## 11. 보안 -| ??ぉ | 援ы쁽 | +| 항목 | 구현 | |------|------| -| API ???€??| DPAPI ?뷀샇??(System.Security.Cryptography.ProtectedData) | -| ??愿€由??꾧뎄 | AxKeyEncryptor (蹂꾨룄 ?좏떥由ы떚) | -| ?덊떚 ?ы띁 | ?붾쾭嫄??붿뺨?뚯씪??媛먯? (Release 鍮뚮뱶, `Security/AntiTamper.cs`) | -| Unsafe 肄붾뱶 | `AllowUnsafeBlocks=true` (ScreenCaptureHandler ?ъ씤???곗궛?? | +| API 키 저장 | DPAPI 암호화 (System.Security.Cryptography.ProtectedData) | +| 키 관리 도구 | AxKeyEncryptor (별도 유틸리티) | +| 안티 탬퍼 | 디버거/디컴파일러 감지 (Release 빌드, `Security/AntiTamper.cs`) | +| Unsafe 코드 | `AllowUnsafeBlocks=true` (ScreenCaptureHandler 포인터 연산용) | --- -## 12. ?깅뒫 理쒖쟻???댁뿭 +## 12. 성능 최적화 내역 -### ?좏쑕 CPU 理쒖쟻??(2026-04-09) +### 유휴 CPU 최적화 (2026-04-09) -| ?€??| 蹂€寃???| 蹂€寃???| +| 대상 | 변경 전 | 변경 후 | |------|---------|---------| -| PerformanceMonitorService ?대쭅 | 2珥?| 5珥?| -| ?꾩젽 ?€?대㉧ | 1珥?| 3珥?| -| ?덉씤蹂댁슦 湲€濡쒖슦 ?€?대㉧ | 150ms | 300ms | -| ServerStatusService ??| 15珥?| 60珥?| +| PerformanceMonitorService 폴링 | 2초 | 5초 | +| 위젯 타이머 | 1초 | 3초 | +| 레인보우 글로우 타이머 | 150ms | 300ms | +| ServerStatusService 핑 | 15초 | 60초 | -### ?ㅽ듃由щ컢 ?뚮뜑留?理쒖쟻??(2026-04-09) +### 스트리밍 렌더링 최적화 (2026-04-09) -- **TypingTimer**: 50ms ??80ms, `string.Concat` ??`char[]` 踰꾪띁 ?ъ궗??- **CursorTimer**: ?꾩껜 臾몄옄???ъ깮????留덉?留?臾몄옄留?援먯껜 -- **StringBuilder.ToString()**: 30ms 理쒖냼 媛꾧꺽 ?곕줈?€留?- **RenderMessages**: ?ㅽ듃由щ컢 以?遺덊븘?뷀븳 ?꾩껜 ?щ젋?붾쭅 諛⑹? (議곌린 諛섑솚) -- **?€?꾨씪???대깽??*: ?묓엺 紐⑤뱶?먯꽌 ?곗냽 ?숈씪 ToolCall 蹂묓빀 +- **TypingTimer**: 50ms → 80ms, `string.Concat` → `char[]` 버퍼 재사용 +- **CursorTimer**: 전체 문자열 재생성 → 마지막 문자만 교체 +- **StringBuilder.ToString()**: 30ms 최소 간격 쓰로틀링 +- **RenderMessages**: 스트리밍 중 불필요한 전체 재렌더링 방지 (조기 반환) +- **타임라인 이벤트**: 접힌 모드에서 연속 동일 ToolCall 병합 -### ?고????덉젙???섏젙 (2026-04-09) +### 런타임 안정성 수정 (2026-04-09) -| ?뚯씪 | ?섏젙 ?댁슜 | +| 파일 | 수정 내용 | |------|----------| -| `CsvSkill.cs` | JSON 諛곗뿴 泥??붿냼 `ValueKind` 寃€利?異붽? | -| `HtmlSkill.cs` | gradient `Split(',')` 寃곌낵 `Length >= 2` 媛€??異붽? | -| `ChatWindow.xaml.cs` | `ParseGenericAction` 鍮?諛곗뿴 媛€?? `ShowDropActionMenu` null 媛€?? `GetAgentLoop` `.FirstOrDefault()` ?꾪솚 | -| `ChatWindow.GitBranchPresentation.cs` | async void ?몃뱾??try/catch 蹂댄샇 | -| `ChatWindow.xaml.cs` (BtnGitBranch_Click) | async void ?몃뱾??try/catch 蹂댄샇 | +| `CsvSkill.cs` | JSON 배열 첫 요소 `ValueKind` 검증 추가 | +| `HtmlSkill.cs` | gradient `Split(',')` 결과 `Length >= 2` 가드 추가 | +| `ChatWindow.xaml.cs` | `ParseGenericAction` 빈 배열 가드, `ShowDropActionMenu` null 가드, `GetAgentLoop` `.FirstOrDefault()` 전환 | +| `ChatWindow.GitBranchPresentation.cs` | async void 핸들러 try/catch 보호 | +| `ChatWindow.xaml.cs` (BtnGitBranch_Click) | async void 핸들러 try/catch 보호 | -### UI ?ㅻ젅??遺€??理쒖쟻??2李?(2026-04-09) +### UI 스레드 부하 최적화 2차 (2026-04-09) -| ?€??| 蹂€寃???| 蹂€寃???| ?④낵 | +| 대상 | 변경 전 | 변경 후 | 효과 | |------|---------|---------|------| -| ?ㅽ겕濡??좊땲硫붿씠??| 留ㅻ쾲 ??16ms ?€?대㉧ ?앹꽦 | ?ъ궗??32ms ?€?대㉧ 1媛?| GC ?뺣젰 + ?€?대㉧ ?꾩쟻 ?댁냼 | -| ?ъ씠?쒕컮 ?좊땲硫붿씠??| 留ㅻ쾲 ??10ms ?€?대㉧ ?앹꽦 | ?ъ궗??32ms ?€?대㉧ 1媛?| ?숈씪 | -| Git 釉뚮옖移?UI | `Dispatcher.Invoke` (釉붾줈?? | `Dispatcher.InvokeAsync` (?쇰툝濡쒗궧) | UI ?ㅻ젅??李⑤떒 ?댁냼 | -| ?좏겙 ?ъ슜????| 留?250ms PathGeometry ?ъ깮??| 1% 誘몃쭔 蹂€?????뚮뜑留??앸왂 | 遺덊븘?뷀븳 ?덉씠?꾩썐 ?곗궛 ?쒓굅 | -| ?€??寃€???€?대㉧ | 140ms | 300ms | 珥덈떦 7????3??| -| ?먯씠?꾪듃 ?대깽???€?대㉧ | 140ms (?ㅽ듃由щ컢: 300/420) | 200ms (?ㅽ듃由щ컢: 350/500) | ?대깽??泥섎━ 鍮덈룄 ?꾪솕 | -| 諛섏쓳???덉씠?꾩썐 ?€?대㉧ | 120ms | 250ms | 由ъ궗?댁쫰 ?붾컮?댁뒪 媛뺥솕 | -| ?€??紐⑸줉 LINQ | Where횞2 + Count횞3 = 由ъ뒪??5???쒗쉶 | Where 1??蹂묓빀 + ?⑥씪 猷⑦봽 移댁슫??| ?좊떦/?쒗쉶 ?€??媛먯냼 | +| 스크롤 애니메이션 | 매번 새 16ms 타이머 생성 | 재사용 32ms 타이머 1개 | GC 압력 + 타이머 누적 해소 | +| 사이드바 애니메이션 | 매번 새 10ms 타이머 생성 | 재사용 32ms 타이머 1개 | 동일 | +| Git 브랜치 UI | `Dispatcher.Invoke` (블로킹) | `Dispatcher.InvokeAsync` (논블로킹) | UI 스레드 차단 해소 | +| 토큰 사용량 호 | 매 250ms PathGeometry 재생성 | 1% 미만 변화 시 렌더링 생략 | 불필요한 레이아웃 연산 제거 | +| 대화 검색 타이머 | 140ms | 300ms | 초당 7회 → 3회 | +| 에이전트 이벤트 타이머 | 140ms (스트리밍: 300/420) | 200ms (스트리밍: 350/500) | 이벤트 처리 빈도 완화 | +| 반응형 레이아웃 타이머 | 120ms | 250ms | 리사이즈 디바운스 강화 | +| 대화 목록 LINQ | Where×2 + Count×3 = 리스트 5회 순회 | Where 1회 병합 + 단일 루프 카운트 | 할당/순회 대폭 감소 | -### 援ъ“??硫붾え由??덉젙???섏젙 (2026-04-09) +### 구조적 메모리/안정성 수정 (2026-04-09) -| 臾몄젣 | ?꾩튂 | ?섏젙 | +| 문제 | 위치 | 수정 | |------|------|------| -| Events 而щ젆??臾댄븳 ?깆옣 | `AgentLoopService.cs` | 500媛?珥덇낵 ???ㅻ옒???대깽???먮룞 ?쒓굅 | -| ?뚯씪 釉뚮씪?곗? ?€?대㉧ 醫€鍮?| `ChatWindow.FileBrowserPresentation.cs` | 留ㅻ쾲 ???€?대㉧ ?앹꽦 ???ъ궗???⑦꽩 | -| ?섎━癒쇳듃 罹먯떆 誘몄젙由?| `ChatWindow.TranscriptVirtualization.cs` | 蹂댁쑀 ?쒕룄 240??20, 1.5諛?珥덇낵 ???뺣━ | -| WorkflowAnalyzer UI 釉붾줈??| `WorkflowAnalyzerWindow.xaml.cs` | `Dispatcher.Invoke` ??`InvokeAsync` | +| Events 컬렉션 무한 성장 | `AgentLoopService.cs` | 500개 초과 시 오래된 이벤트 자동 제거 | +| 파일 브라우저 타이머 좀비 | `ChatWindow.FileBrowserPresentation.cs` | 매번 새 타이머 생성 → 재사용 패턴 | +| 엘리먼트 캐시 미정리 | `ChatWindow.TranscriptVirtualization.cs` | 보유 한도 240→120, 1.5배 초과 시 정리 | +| WorkflowAnalyzer UI 블로킹 | `WorkflowAnalyzerWindow.xaml.cs` | `Dispatcher.Invoke` → `InvokeAsync` | -### 援ъ“??由ы뙥?좊쭅 P1 (2026-04-09) +### 구조적 리팩토링 P1 (2026-04-09) -| ?€??| ?뚯씪 | 蹂€寃?| +| 대상 | 파일 | 변경 | |------|------|------| -| ?명겕由щ찘???뚮뜑 hiddenCount ?덉젙??| `ChatWindow.TranscriptRenderPlanner.cs` | ?ㅽ듃由щ컢 以?hiddenCount 媛먯냼 李⑤떒 ??prefix ??遺덉씪移섎줈 ?명븳 ?꾩껜 ?щ퉴???대갚 諛⑹? | -| 鍮꾧????뚮뜑 李⑤떒 | `ChatWindow.TranscriptRendering.cs` | 理쒖냼???④? ?곹깭?먯꽌 RenderMessages 利됱떆 諛섑솚 ??遺덊븘?뷀븳 UI ?ш뎄異??쒓굅 | -| ConversationList ?대깽???꾩엫 | `ChatWindow.ConversationListPresentation.cs` | ??ぉ??5媛??뚮떎 ?몃뱾????ConversationPanel???⑥씪 ?꾩엫 ?몃뱾??(Tag 湲곕컲 遺꾧린). ???꾪솚 ??250媛??몃뱾???꾩쟻 ?댁냼 | -| TopicPreset ?대깽???꾩엫 | `ChatWindow.TopicPresetPresentation.cs` | 移대뱶??3媛??뚮떎 ?몃뱾????TopicButtonPanel???⑥씪 ?꾩엫 ?몃뱾?? ???꾪솚 ??45媛??몃뱾???꾩쟻 ?댁냼 | -| 怨듯넻 VisualTree ?ы띁 | `ChatWindow.VisualInteractionHelpers.cs` | `FindAncestorWithTag`, `FindAncestor` ?좏떥 異붽? | +| 인크리멘탈 렌더 hiddenCount 안정화 | `ChatWindow.TranscriptRenderPlanner.cs` | 스트리밍 중 hiddenCount 감소 차단 → prefix 키 불일치로 인한 전체 재빌드 폴백 방지 | +| 비가시 렌더 차단 | `ChatWindow.TranscriptRendering.cs` | 최소화/숨김 상태에서 RenderMessages 즉시 반환 → 불필요한 UI 재구축 제거 | +| ConversationList 이벤트 위임 | `ChatWindow.ConversationListPresentation.cs` | 항목당 5개 람다 핸들러 → ConversationPanel에 단일 위임 핸들러 (Tag 기반 분기). 탭 전환 시 250개 핸들러 누적 해소 | +| TopicPreset 이벤트 위임 | `ChatWindow.TopicPresetPresentation.cs` | 카드당 3개 람다 핸들러 → TopicButtonPanel에 단일 위임 핸들러. 탭 전환 시 45개 핸들러 누적 해소 | +| 공통 VisualTree 헬퍼 | `ChatWindow.VisualInteractionHelpers.cs` | `FindAncestorWithTag`, `FindAncestor` 유틸 추가 | -### 援ъ“??由ы뙥?좊쭅 P2 (2026-04-09) +### 구조적 리팩토링 P2 (2026-04-09) -| ?€??| ?뚯씪 | 蹂€寃?| +| 대상 | 파일 | 변경 | |------|------|------| -| _agentLiveContainer ?명겕由щ찘???덉슜 | `TranscriptRenderPlanner.cs`, `TranscriptRenderExecution.cs` | ?쇱씠釉?而⑦뀒?대꼫瑜?expectedChildCount???ы븿, ?명겕由щ찘?????꾩떆 遺꾨━/?ъ궫????`hasExternalChildren` 李⑤떒 ?댁냼 | -| ?ㅽ듃由щ컢 append-only ?뚮뜑 | `TranscriptRenderExecution.cs`, `TranscriptRendering.cs` | prefix 鍮꾧탳 ?고쉶?섎뒗 `TryApplyStreamingAppendRender` 異붽? ??stable ??遺€遺꾩쭛??愿€怨꾨쭔 ?뺤씤, ????ぉ留?異붽? | -| Permission ?대깽???꾩엫 | `ChatWindow.PermissionPresentation.cs` | ?됰떦 4媛??뚮떎 ??PermissionItems???⑥씪 ?꾩엫 ?몃뱾??+ `PermissionItemTag` | -| Preview ???대깽???꾩엫 | `ChatWindow.PreviewPresentation.cs` | ??떦 7媛??뚮떎 ??PreviewTabPanel???⑥씪 ?꾩엫 ?몃뱾??+ `PreviewTabTag` | -| GitBranch ?대깽???꾩엫 | `ChatWindow.GitBranchPresentation.cs`, `SelectionPopupPresentation.cs` | `CreateFlatPopupRow`/`CreatePopupMenuRow` ?됱쓽 ?뚮떎 ??GitBranchItems???⑥씪 ?꾩엫 + `PopupRowTag` | +| _agentLiveContainer 인크리멘탈 허용 | `TranscriptRenderPlanner.cs`, `TranscriptRenderExecution.cs` | 라이브 컨테이너를 expectedChildCount에 포함, 인크리멘탈 시 임시 분리/재삽입 → `hasExternalChildren` 차단 해소 | +| 스트리밍 append-only 렌더 | `TranscriptRenderExecution.cs`, `TranscriptRendering.cs` | prefix 비교 우회하는 `TryApplyStreamingAppendRender` 추가 — stable 키 부분집합 관계만 확인, 새 항목만 추가 | +| Permission 이벤트 위임 | `ChatWindow.PermissionPresentation.cs` | 행당 4개 람다 → PermissionItems에 단일 위임 핸들러 + `PermissionItemTag` | +| Preview 탭 이벤트 위임 | `ChatWindow.PreviewPresentation.cs` | 탭당 7개 람다 → PreviewTabPanel에 단일 위임 핸들러 + `PreviewTabTag` | +| GitBranch 이벤트 위임 | `ChatWindow.GitBranchPresentation.cs`, `SelectionPopupPresentation.cs` | `CreateFlatPopupRow`/`CreatePopupMenuRow` 행의 람다 → GitBranchItems에 단일 위임 + `PopupRowTag` | -### 援ъ“??由ы뙥?좊쭅 P3 (2026-04-09) +### 구조적 리팩토링 P3 (2026-04-09) -| ?€??| ?뚯씪 | 蹂€寃?| +| 대상 | 파일 | 변경 | |------|------|------| -| FileBrowser 紐낆떆???댁젣 | `ChatWindow.FileBrowserPresentation.cs` | TreeViewItem ?뚮떎?믩챸紐?硫붿꽌??`FileTreeItem_Expanded/DoubleClick/RightClick`) ?꾪솚. `BuildFileTree()` ??`DetachFileTreeHandlers()` ?ш? ?몄텧濡?Clear ???몃뱾???댁젣. ?몃━ ?ш뎄異뺣떦 300媛??몃뱾???꾩쟻 ?댁냼 | +| FileBrowser 명시적 해제 | `ChatWindow.FileBrowserPresentation.cs` | TreeViewItem 람다→명명 메서드(`FileTreeItem_Expanded/DoubleClick/RightClick`) 전환. `BuildFileTree()` 시 `DetachFileTreeHandlers()` 재귀 호출로 Clear 전 핸들러 해제. 트리 재구축당 300개 핸들러 누적 해소 | +> 전체 계획 완료. `docs/STRUCTURAL_REFACTORING_PLAN.md` 참조. -## claude-code식 transcript 표시 구조 정리 +### 런처 · 에이전트 리소스/안정성 수정 (2026-04-09) -- 업데이트: 2026-04-09 13:05 (KST) -- AgentTranscriptDisplayCatalog를 row presentation 중심으로 재구성해 hinking / waiting / compact / tool activity / permission / tool result / status를 개별 transcript row 의미로 다룰 수 있게 정리했습니다. -- ChatWindow.AgentEventRendering은 process feed 계열 이벤트를 같은 GroupKey 단위로 병합해, 긴 Cowork/Code 실행 중 append 수를 줄이면서도 주요 활동 흐름이 기본 transcript에 남도록 조정했습니다. -- PermissionRequestPresentationCatalog, ToolResultPresentationCatalog를 다시 정리해 권한 요청과 도구 결과를 행위/상태 기준으로 구분하고, transcript 렌더와 popup이 같은 메타를 공유하도록 맞췄습니다. -- ChatWindow.FooterPresentation은 execution event가 생긴 뒤에는 프리셋 안내 카드를 자동으로 숨기도록 바꿔 결과/진행 화면을 덮지 않게 했고, Cowork/Code 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. -- -ender_messages 성능 로그에는 processFeedAppends, processFeedMerges, -owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript grouping 효과를 수치로 비교할 수 있게 했습니다. +| 대상 | 파일 | 변경 | +|------|------|------| +| LauncherWindow 이벤트 누수 | `LauncherWindow.xaml.cs` | `vm.CloseRequested`, `vm.PropertyChanged`, `app.IndexService.IndexRebuilt` 핸들러를 필드 저장 → `OnClosed`에서 `-=` 해제. ViewModel보다 Window가 먼저 닫힐 때 GC 누수 방지 | +| ChatWindow 타이머 정리 | `ChatWindow.xaml.cs` | `Closed` 핸들러에 누락된 8개 타이머 명시적 `Stop()` 추가 + `StopAgentEventProcessor()` 호출 | +| Events 스레드 안전 | `AgentLoopService.cs` | Dispatcher 없을 때 `Events` 접근에 `lock(Events)` 추가 — 동시 EmitEvent 호출 시 IndexOutOfRange 크래시 방지 | +| NotifyTool 타이머 누적 | `NotifyTool.cs` | 알림당 `new DispatcherTimer` → `DoubleAnimation.Completed` 콜백으로 대체. 100개 알림 시 100개 타이머 동시 존재 해소 | +| LauncherWindow 토스트 타이머 | `LauncherWindow.xaml.cs` | `ShowToast()` 매 호출 `new DispatcherTimer` → 재사용 패턴 + 명명 메서드(`ToastTimer_Tick`) | +| LauncherWindow 타이머 정리 | `LauncherWindow.xaml.cs` | `OnClosed`에 `_toastTimer?.Stop()`, `_indexStatusTimer?.Stop()` 추가 | -## 13. ?붾젆?좊━蹂?媛€?대뱶 +### Hot path · 리소스 추가 최적화 (2026-04-09) -| ?붾젆?좊━ | ?섏젙 ??二쇱쓽?ы빆 | -|---------|----------------| -| `Core/` | `FuzzyEngine` ?먯닔 怨듭떇 蹂€寃???寃€???덉쭏??吏곸젒 ?곹뼢 | -| `Handlers/` | ???몃뱾??異붽? ??`App.xaml.cs`???깅줉 ?꾩슂 | -| `Services/Agent/` | ???꾧뎄 異붽? ??`ToolRegistry`???깅줉 + ?ㅽ궗 ?뚯씪(`.skill.md`) ?묒꽦 | -| `Themes/` | 由ъ냼????蹂€寃???紐⑤뱺 ?뚮쭏???숈씪?섍쾶 ?곸슜 ?꾩슂 | -| `Models/AppSettings.cs` | ?띿꽦 異붽? ??`SettingsService` 留덉씠洹몃젅?댁뀡 怨좊젮 | -| `Views/ChatWindow.*` | partial class 遺꾪븷 ??愿€??湲곕뒫?€ ?대떦 ?뚯씪?먯꽌 ?섏젙 | +| 대상 | 파일 | 변경 | +|------|------|------| +| GetRuntimeActiveTools 캐시 | `AgentLoopService.cs` | 반복당 1~4회 호출 → `cachedActiveTools` 로컬 변수로 1회 캐시. foreach 내 `activeToolNames` 계산도 루프 밖으로 호이스트 | +| SubAgentTool 취소 전파 | `SubAgentTool.cs` | `CancellationTokenSource.CreateLinkedTokenSource(ct)` 연동. Task.Run + loop.RunAsync에 토큰 전달. 부모 중지 시 자식 즉시 취소 | +| 아이콘 애니메이션 재귀 제어 | `LauncherWindow.xaml.cs` | `sb.Completed`에서 즉시 재귀 → `_iconAnimationDelayTimer` 8초 딜레이. 할당 빈도 75% 감소. 클릭 시 딜레이 취소 후 즉시 전환 | +| JsonSerializerOptions 공유 | `AgentLoopService.cs` | `s_jsonOpts` 정적 필드 추가, 4개 `JsonSerializer.Serialize` 호출에 적용. L4096 `System.Text.Json.` 접두사 정규화 | + +### Claude Desktop 스타일 UI 개선 (2026-04-09) + +| 항목 | 파일 | 수정 내용 | +|------|------|----------| +| 미리보기 Split Button | `ChatWindow.xaml` | 기존 `BtnPreviewToggle` (Ellipse 점 + "프리뷰") → `[▶ 미리보기 | ∨]` Split Button으로 교체. 좌측 토글, 우측 셰브론 드롭다운 | +| 미리보기 드롭다운 | `ChatWindow.PreviewPresentation.cs` | `ShowPreviewTabDropdown()` — 열린 탭 목록 팝업, 파일 확장자별 아이콘, 활성 탭 하이라이트 | +| PreviewDot → PreviewIcon | `ChatWindow.PreviewPresentation.cs` | `PreviewDot.Fill` 4곳 → `PreviewIcon.Foreground` (AccentColor/SecondaryText) 전환 | +| 셰브론 동기화 | `ChatWindow.PreviewPresentation.cs` | `UpdatePreviewChevronState()` — `_previewTabs.Count` 기반 IsHitTestVisible/Opacity 제어 | +| 계획 버튼 이동 | `ChatWindow.xaml` | MoodIconPanel 동적 주입 → StatusBar XAML 선언 요소 `BtnPlanViewer`로 이동 | +| ShowPlanButton 리팩토링 | `ChatWindow.PlanApprovalPresentation.cs` | 동적 Add/Remove → `Visibility` 토글 단순화 + 레거시 정리 유지 | + +### UX 개선 및 탭별 도구 필터링 (2026-04-09) + +| 항목 | 파일 | 수정 내용 | +|------|------|----------| +| 탭별 도구 필터링 | `IAgentTool.cs`, `ToolRegistry.cs`, `AgentLoopService.cs` | `TabCategory` 속성 + `ToolTabOverrides` 딕셔너리로 Chat/Cowork/Code 탭별 도구 분류. Chat=0개, Cowork=문서/데이터, Code=개발/태스크 | +| FolderMapTool 기본값 변경 | `FolderMapTool.cs` | `include_files` 기본값 `false` → `true`. Description에 사용 제한 가이드 추가 | +| 에이전트 이벤트 숨김 | `AgentEventRendering.cs`, `TimelinePresentation.cs` | `SessionStart`/`UserPromptSubmit` 내부 이벤트 타임라인 비표시 | +| 사용자 메시지 렌더링 | `ChatWindow.xaml.cs` | 전송 시 `InvalidateTimelineCache()` + `preserveViewport:false`로 즉시 표시 보장 | +| 아카이브 기능 | `ChatModels.cs`, `ConversationManagementPresentation.cs`, `ConversationFilterPresentation.cs`, `ConversationListPresentation.cs` | `Archived` 속성, 컨텍스트 메뉴 아카이브 토글, 사이드바 필터 버튼 | +| 커스텀 슬림 스크롤바 | `ChatWindow.xaml` | 6px 슬림 ScrollBar + Thumb 라운드, ScrollViewer 마우스 오버 fade in/out 애니메이션 | +| 스트리밍 메트릭 레이블 | `ChatWindow.xaml`, `StatusPresentation.cs` | 입력 박스 위에 `StreamMetricsLabel` (경과시간 · ↓ 토큰 수) 실시간 표시 | +| 프리셋 카드 클릭 안정화 | `ChatWindow.xaml`, `ChatWindow.xaml.cs` | WrapPanel `Background="Transparent"` + Dispatcher 우선순위 `Loaded`로 상향 | +| 컨텍스트 토큰 정확도 | `TokenEstimator.cs`, `ContextUsagePresentation.cs`, `ILlmService.cs` | 시스템 프롬프트 + 도구 오버헤드 추정, `_tool_use_blocks`/`tool_result` 할인 | +| UI 프리징 방지 | `ChatWindow.xaml.cs` | `SaveLastConversations()`/`PersistConversationSnapshot()` → 렌더링 후 `Task.Run()` 비동기 실행 | +| 빠른 빌드 스크립트 | `build-quick.sh` | 암호화/난독화/설치프로그램 건너뛰는 개발용 빌드 스크립트 | + +### 에이전트 루프 문서 생성 흐름 수정 (2026-04-09) + +| 파일 | 수정 내용 | +|------|----------| +| `AgentLoopTransitions.Documents.cs` | `TryHandleTerminalDocumentCompletionTransitionAsync`에서 `document_plan` 없이 바로 문서 도구 호출 시 조기 종료 방지 — LLM이 추가 반복으로 내용을 보강할 수 있도록 허용 | +| `HtmlSkill.cs` | `MarkdownToHtml`에서 LLM이 삽입한 `
` 태그가 이스케이프되는 버그 수정 — 이스케이프 전 플레이스홀더로 보존 후 복원 | + +### Cowork 문서 미생성 · 스크롤 · 전송 후 10초 멈춤 수정 (2026-04-09) + +#### 문서 생성 탐색 정책 수정 + +| 파일 | 수정 내용 | +|------|----------| +| `AgentLoopExplorationPolicy.cs` | `ExplorationScope.DirectCreation` 신규 스코프 추가. `HasDocumentCreationIntent()`로 "작성해줘/만들어줘/써줘" 등 생성 동사 + 문서/보고서 대상 키워드 감지 | +| `AgentLoopExplorationPolicy.cs` | `DirectCreation` 스코프에서 glob/grep/folder_map 탐색 차단 → `document_plan → docx_create/html_create` 바로 이동 | +| `AgentLoopExplorationPolicy.cs` | `FilterExplorationToolsForCurrentIteration`에서 문서 생성 도구를 최우선 순위로 배치 | +| `AgentLoopExplorationPolicy.cs` | `ShouldInjectExplorationCorrection`에서 DirectCreation 시 탐색 도구 1회 호출만으로 즉시 교정 주입 | +| `TaskTypePolicy.cs` | docs 가이던스를 생성 vs 읽기로 분기 — 생성 시 "반드시 실제 파일을 만들어라" 명시 | +| `AgentLoopService.cs` | DirectCreation 스코프 이벤트 메시지: "문서 생성 모드 · 바로 문서를 만드는 중" | + +#### 스크롤 버그 수정 + +| 파일 | 수정 내용 | +|------|----------| +| `ChatWindow.xaml.cs` | 메시지 전송/슬래시 커맨드/컴팩트 후 `RenderMessages(preserveViewport: true)` + `ForceScrollToEnd()` 조합 → `RenderMessages(preserveViewport: false)`로 변경. viewport 복원과 ForceScrollToEnd 경합 제거 | + +**원인**: `preserveViewport: true`는 렌더링 후 이전 스크롤 위치를 복원하는 코드를 `DispatcherPriority.Background`로 예약. `ForceScrollToEnd()`도 같은 우선순위로 하단 스크롤을 예약하여 두 코드가 경합, 스크롤이 하단으로 안 가는 문제 발생. + +#### 전송 후 10초 멈춤 수정 (Critical Performance Fix) + +| 파일 | 수정 내용 | +|------|----------| +| `ChatWindow.AgentStatusPresentation.cs` | `BuildFeedbackContext()` — `_storage.LoadAllMeta()` (모든 .axchat 파일 복호화) + `_storage.Load()` x20회를 매 전송마다 동기 실행 → 1분 캐시 + 현재 대화 피드백만 즉시 반영 + 전체 갱신은 백그라운드 | +| `ChatWindow.xaml.cs` | `Dispatcher.Invoke()` (동기 블로킹) → `Dispatcher.InvokeAsync()` (비동기). background task에서 UI 스레드 블로킹 제거 | +| `ChatWindow.xaml.cs` | `PrepareExecutionForConversation()` (시스템 프롬프트 빌드: 프로젝트 규칙/메모리/피드백 디스크 I/O) → `await Task.Run()`으로 백그라운드 실행. UI 스레드 즉시 해방 | + +**원인 분석**: `BuildFeedbackContext()`가 `LoadAllMeta()` (모든 `.axchat` 파일 복호화·파싱) + `Load()` x20 (20개 대화 전체 로드·복호화) 를 UI 스레드에서 동기 실행. 대화 30개 이상이면 5~10초 블로킹 발생. + +### 스트리밍 중 UI 버벅임 대폭 개선 (2026-04-09) + +Claude Desktop(React)은 도구 호출이 수십 건이어도 매끄러운 반면, WPF 앱은 심하게 버벅이는 문제의 원인 분석 및 수정. + +**근본 원인**: React virtual DOM은 변경된 부분만 diff/patch하지만, WPF는 매 렌더마다 전체 시각적 트리를 파괴 후 재생성. + +| 원인 | 파일 | 수정 | +|------|------|------| +| `ItemsSource = null/재연결` — 전체 시각적 트리 파괴 + VirtualizingStackPanel 컨테이너 재생성 | `TranscriptRenderExecution.cs` | 스트리밍 중에는 ItemsSource 분리/재연결 건너뜀 — ObservableCollection 직접 변경으로 레이아웃 패스 최소화 | +| 라이브 진행 카드 매번 재생성 — 헤더/구분선/스텝 전체를 0부터 다시 생성 + 애니메이션 재적용 | `AgentEventRendering.cs` | `_liveProgressCard` 캐시 + `UpdateLiveProgressStepsInPlace()` — 카드 1회 생성 후 새 스텝만 추가, 기존 스텝은 터치 안 함 | +| 렌더 타이머 간격 1.5~2.2초 — WPF 전체 재빌드에 비해 너무 공격적 | `ChatWindow.xaml.cs` | lightweight: 2.2s→4s, normal: 1.5s→3s — 렌더 간 충분한 여유 확보 | +| 매 렌더마다 3개 애니메이션(Opacity + ScaleX + ScaleY) 재적용 | `AgentEventRendering.cs` | 라이브 카드 in-place 업데이트로 기존 애니메이션 보존, 새 스텝에만 애니메이션 적용 | + +**비교**: + +| 항목 | 수정 전 (WPF) | 수정 후 | Claude Desktop (React) | +|------|-------------|---------|----------------------| +| 업데이트 전략 | 전체 트리 파괴→재생성 | 4단계: StreamingAppend → Incremental → **DiffRender** → FullRender | Virtual DOM diff | +| 렌더 간격 | 1.5~2.2초 | 3~4초 | ~16ms (requestAnimationFrame) | +| 요소 재사용 | Clear→재생성 | 캐시→재사용 + 키 기반 diff | Recycled/Memoized | +| 애니메이션 | 매번 재적용 (3개/요소) | 1회 적용 후 보존 | CSS transform (GPU) | --- -## 14. 愿€??臾몄꽌 +### 12-3. Virtual DOM Diff 렌더 (`TryApplyDiffRender`) -| 臾몄꽌 | ?댁슜 | +React의 reconciliation과 동일한 원리를 WPF에 적용한 키 기반 diff 렌더입니다. + +**렌더 체인 (우선순위 순):** +``` +StreamingAppend → Incremental(prefix-match) → DiffRender(key-based) → FullRender +``` + +| 단계 | 조건 | 동작 | +|------|------|------| +| StreamingAppend | 스트리밍 중 + 기존 stable 키가 부분집합 | 새 키만 append | +| Incremental | prefix가 완전 일치 | 꼬리 부분만 추가 | +| **DiffRender** | hiddenCount 동일 + 키 집합 변화 있음 | old에만 있는 키 삭제(뒤→앞) + new에만 있는 키 추가 | +| FullRender | 위 3개 모두 실패 | 전체 Clear→재생성 | + +**핵심 알고리즘:** +1. `oldKeys` → index 딕셔너리 / `newKeys` → HashSet 구축 +2. 라이브 컨테이너 임시 분리 +3. `oldKeys` 뒤에서부터 순회하며 `newKeySet`에 없는 항목 제거 (인덱스 안정성) +4. `renderPlan.VisibleTimeline`에서 `oldKeyIndex`에 없는 항목만 `Render()` +5. 라이브 컨테이너 재삽입 + +**파일:** `ChatWindow.TranscriptRenderExecution.cs`, `ChatWindow.TranscriptRendering.cs` (체인 삽입) + +--- + +### 12-4. LSP 코드 인텔리전스 도구 확장 + +`lsp_code_intel` 도구를 6개 액션에서 9개로 확장하여 구조적 코드 탐색을 대폭 강화했습니다. + +| 액션 | 용도 | 신규 | +|------|------|------| +| `goto_definition` | 심볼 정의 위치 | | +| `find_references` | 심볼 사용 위치 | | +| `hover` | 타입/문서 정보 | ✅ | +| `goto_implementation` | 인터페이스/추상 구현 위치 | ✅ | +| `symbols` | 파일 내 심볼 목록 | | +| `workspace_symbols` | 워크스페이스 전체 심볼 검색 | ✅ | +| `prepare_call_hierarchy` | 호출 계층 기준 심볼 | ✅ | +| `incoming_calls` | 상위 호출자 | ✅ | +| `outgoing_calls` | 하위 호출 대상 | ✅ | + +**주요 변경:** +- `line`/`character` 입력: 1-based 기대 → 내부에서 0-based 자동 변환 (`NormalizePosition`) +- `query` 파라미터 추가 (workspace_symbols용) +- 결과에 파일 수, 대표 위치, 첫 결과 요약 포함 +- LSP 프로토콜: `textDocument/implementation`, `textDocument/hover`, `workspace/symbol`, `textDocument/prepareCallHierarchy`, `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls` + +**파일:** `LspTool.cs`, `LspClientService.cs` + +--- + +### 12-5. IBM/Qwen 도구 이력 평탄화 + +IBM watsonx + Qwen 배포형에서 `tool_calls`/`role=tool` 이력 검사가 엄격한 문제를 해결합니다. + +**변경 전:** +``` +assistant { tool_calls: [...] } → tool { tool_call_id, content } +``` + +**변경 후 (평탄 transcript):** +``` +assistant: "텍스트\n\n{name,arguments}\n" +user: "[Tool Result: tool_name] (id=xxx)\ncontent" +``` + +**핵심 메서드:** +- `BuildIbmAssistantTranscript()` — tool_use 블록 → `` 태그 직렬화 +- `BuildIbmToolResultTranscript()` — tool_result → `[Tool Result]` 헤더 + 내용 +- `TryExtractTextContent()` — string/array/nested 형태 모두 텍스트 추출 +- `TryParseContentArrayToolBlock()` — content 배열 내 tool_use/tool_call 블록 파싱 + +**파일:** `LlmService.ToolUse.cs` + +--- + +### 12-6. 도구 노출 순서 정렬 및 프롬프트 완화 + +**도구 순서 (`ToolRegistry.OrderToolsForExposure`):** + +| 버킷 | 도구 | |------|------| -| `docs/AGENT_ROADMAP.md` | ?먯씠?꾪듃 湲곕뒫 濡쒕뱶留?| -| `docs/LAUNCHER_ROADMAP.md` | ?곗쿂 湲곕뒫 濡쒕뱶留?| -| `docs/CLAW_CODE_PARITY_PLAN.md` | Claude Code 湲곕뒫 ?€??怨꾪쉷 | -| `docs/TOOL_PARITY_REPORT.md` | ?꾧뎄 ?명솚??由ы룷??| -| `docs/AX_AGENT_UI_CHECKLIST.md` | ?먯씠?꾪듃 UI 泥댄겕由ъ뒪??| -| `docs/UI_UX_CHECKLIST.md` | UI/UX 泥댄겕由ъ뒪??| +| 0 (최우선) | file_read, file_edit, glob, grep, lsp_code_intel, build_run, document_plan, 생성 도구 등 | +| 1 | document_review, format_convert, tool_search, code_search | +| 2 | mcp_*, spawn_agent, wait_agents | +| 3 | task_* | + +**프롬프트 완화 (SystemPromptBuilder):** +- "Tools First, Always" → "Tools First When Needed" +- `tool_search`: 후보에서 바로 선택 가능하면 직접 호출, 모호할 때만 사용 +- `spawn_agent`: 병렬 조사가 실제로 도움이 될 때만 사용 +- `document_review`: 큰 문서/명시적 요청 시에만 권장 +- Code 탐색: 정의/참조/구현/호출관계 → `lsp_code_intel` 우선 + +**파일:** `ToolRegistry.cs`, `ChatWindow.SystemPromptBuilder.cs`, `AgentLoopService.cs`, `TaskTypePolicy.cs`, `AgentLoopExplorationPolicy.cs` + --- -### ?좏깮???먯깋 援ъ“ 媛쒖꽑 (2026-04-09 10:36 KST) +## 13. 디렉토리별 가이드 -- `claude-code`??`Glob/Grep/FileRead` ?꾨\?꾪듃?€ `toolOrchestration.ts` ?먮쫫???ㅼ떆 ?€議고븳 寃곌낵, AX??`folder_map`???덈Т ?쎄쾶 癒쇱? ?몄텧?섎룄濡??좊룄?섎뒗 洹쒖튃 ?뚮Ц??吏덈Ц怨?臾닿????꾩껜 ?뚰겕?ㅽ럹?댁뒪瑜??묐뒗 寃쏀뼢???덉뿀?듬땲?? -- `src/AxCopilot/Views/ChatWindow.xaml.cs` - - Cowork/Code ?쒖뒪???꾨\?꾪듃?먯꽌 `folder_map`????긽 泥??④퀎濡??붽뎄?섎뜕 臾멸뎄瑜??꾪솕?덉뒿?덈떎. - - 醫곸? 踰붿쐞??吏덈Ц?€ `glob/grep + targeted file_read`瑜??곗꽑?섍퀬, ?€?μ냼 ?꾩껜 援ъ“媛€ ?뺣쭚 ?꾩슂???뚮쭔 `folder_map`???곕룄濡?諛붽엥?듬땲?? -- `src/AxCopilot/Services/Agent/FolderMapTool.cs` - - 湲곕낯 ?먯깋 depth瑜?`3 - -## claude-code식 transcript 표시 구조 정리 +| 디렉토리 | 수정 시 주의사항 | +|---------|----------------| +| `Core/` | `FuzzyEngine` 점수 공식 변경 시 검색 품질에 직접 영향 | +| `Handlers/` | 새 핸들러 추가 시 `App.xaml.cs`에 등록 필요 | +| `Services/Agent/` | 새 도구 추가 시 `ToolRegistry`에 등록 + 스킬 파일(`.skill.md`) 작성 + `ToolTabOverrides`에 탭 카테고리 지정 | +| `Themes/` | 리소스 키 변경 시 모든 테마에 동일하게 적용 필요 | +| `Models/AppSettings.cs` | 속성 추가 시 `SettingsService` 마이그레이션 고려 | +| `Views/ChatWindow.*` | partial class 분할 — 관련 기능은 해당 파일에서 수정 | -- 업데이트: 2026-04-09 13:05 (KST) -- AgentTranscriptDisplayCatalog를 row presentation 중심으로 재구성해 hinking / waiting / compact / tool activity / permission / tool result / status를 개별 transcript row 의미로 다룰 수 있게 정리했습니다. -- ChatWindow.AgentEventRendering은 process feed 계열 이벤트를 같은 GroupKey 단위로 병합해, 긴 Cowork/Code 실행 중 append 수를 줄이면서도 주요 활동 흐름이 기본 transcript에 남도록 조정했습니다. -- PermissionRequestPresentationCatalog, ToolResultPresentationCatalog를 다시 정리해 권한 요청과 도구 결과를 행위/상태 기준으로 구분하고, transcript 렌더와 popup이 같은 메타를 공유하도록 맞췄습니다. -- ChatWindow.FooterPresentation은 execution event가 생긴 뒤에는 프리셋 안내 카드를 자동으로 숨기도록 바꿔 결과/진행 화면을 덮지 않게 했고, Cowork/Code 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. -- -ender_messages 성능 로그에는 processFeedAppends, processFeedMerges, -owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript grouping 효과를 수치로 비교할 수 있게 했습니다. +--- -### transcript row 怨꾩빟 諛?activity grouping ?뺣━ (2026-04-09 11:12 KST) +### 12-7. PPT 고품질 템플릿 시스템 -- `claude-code`??`Messages.tsx`, `MessageRow.tsx`, `GroupedToolUseContent.tsx`, `UserToolResultMessage`, `PermissionRequest`瑜??ㅼ떆 ?€議고빐 AX transcript瑜?row ?€??以묒떖?쇰줈 ?뺣━?덉뒿?덈떎. -- `src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs` - - `TranscriptRowKind`, `AgentTranscriptRowPresentation`??異붽??덉뒿?덈떎. - - thinking / waiting / compact / tool activity / permission / tool result / status瑜??섎굹??移댄깉濡쒓렇?먯꽌 ?뺢퇋?뷀븯?꾨줉 諛붽엥?듬땲?? - - process feed row??`GroupKey`, `CanGroup`, `Emphasize` 硫뷀?瑜??④퍡 怨꾩궛???뚮뜑?ш? 怨좊퉰???대깽?몃? ???곸? ?됱쑝濡?臾띠쓣 ???덇쾶 ?덉뒿?덈떎. -- `src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs` - - process feed ?뚮뜑媛€ row presentation??吏곸젒 諛쏆븘 ?쒕ぉ/?ㅻ챸/媛뺤“ ?щ?瑜?援ъ꽦?섎룄濡?諛붽엥?듬땲?? - - 媛숈? 醫낅쪟??read/search/step ?대깽?몃뒗 留덉?留?grouped row瑜?援먯껜?섎뒗 諛⑹떇?쇰줈 merge?섏뿬 append ?섎? 以꾩??듬땲?? - - permission/result card??row 硫뷀??€ presentation catalog瑜???吏곸젒?곸쑝濡??ъ슜?섎룄濡??곌껐?덉뒿?덈떎. -- `src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs` - - ?깃났/?ㅽ뙣/嫄곕?/痍⑥냼/?뱀씤 ?꾩슂/遺€遺??꾨즺瑜?clean??硫뷀? 援ъ“濡??ㅼ떆 ?뺣━?덉뒿?덈떎. -- `src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs` - - bash / powershell / command / web / mcp / skill / ask / file edit / file write / git / document / filesystem???됱쐞蹂?沅뚰븳 移대뱶 硫뷀?濡??ъ젙?섑뻽?듬땲?? -- `src/AxCopilot/Views/ChatWindow.TranscriptHost.cs` - - transcript 珥덇린????grouped process feed ?곹깭?€ 移댁슫?곕룄 ?④퍡 由ъ뀑?섎룄濡?蹂닿컯?덉뒿?덈떎. -- `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs` - - performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎. +`template` 파라미터로 8개 고품질 양식의 색상/레이아웃을 사용할 수 있습니다. -## Cowork/Code 실행 중 UI 멈춤 완화 (2026-04-10 09:03 KST) +**현재 구현 (방법 1 — 내장 메타데이터):** +- 각 템플릿의 테마 색상을 `FullThemes` 딕셔너리에 하드코딩 (0KB 추가) +- 원본 .pptx 없이도 동일 색상+레이아웃으로 PPT 생성 가능 +- 원본 .pptx가 `Assets/ppt/` 또는 `%APPDATA%/AXCopilot/templates/ppt/`에 있으면 마스터 복제(고품질) 자동 업그레이드 -- `claude-code`처럼 실행 이벤트를 얇게 소비하도록, AX Agent의 Cowork/Code UI 이벤트 경로를 다시 줄였습니다. -- `src/AxCopilot/Views/ChatWindow.xaml.cs` - - agent loop dispatcher 우선순위를 `DispatcherPriority.Normal`에서 `DispatcherPriority.Background`로 낮춰, 대량의 에이전트 이벤트가 들어와도 사용자 입력과 렌더가 먼저 처리되도록 바꿨습니다. - - `OnAgentEvent()`는 이제 완료/오류 같은 종료 이벤트만 즉시 처리하고, ToolCall/ToolResult/Thinking의 라이브 카드 갱신은 기존 `_agentUiEventTimer` 기반 배치 경로로 넘깁니다. -- `src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs` - - execution event를 UI 스레드에서 즉시 append한 뒤 백그라운드에서 다시 append하던 중복 경로를 제거했습니다. - - 대화 히스토리 반영, agent run 기록, 저장은 백그라운드 단일 리더에서 한 번만 처리하도록 정리해 Cowork/Code 실행 중 UI 점유를 줄였습니다. -- 기대 효과 - - 긴 tool/thinking 이벤트가 연속으로 들어와도 창 전체가 끝날 때까지 얼어붙는 느낌이 줄어듭니다. - - transcript 저장과 실행 이력 반영이 한 번만 일어나, 실행 길이가 길수록 커지던 UI 스레드 부담이 완화됩니다. +| 템플릿 이름 | 원본 파일 | 색상 특징 | +|------------|----------|----------| +| `basic100` | BASIC100 기준 템플릿 V1.pptx (67MB) | 모던 블루 (#2572EF) | +| `core100` | CORE100 기준템플릿 V1.pptx (141MB) | 딥 블루 (#266DF1) | +| `frame_blue` | 프레임디자인 블루 (19MB) | 프레임 블루 (#126BF6) + 카드 | +| `mr_ppt_01` | 미스터 피피티 01 (18MB) | 다크 네이비 + 블루 (#0049F0) | +| `mr_ppt_02` | 미스터 피피티 02 (24MB) | 블루 + 그레이 카드 (#2269F7) | +| `mr_ppt_03` | 미스터 피피티 03 (5.5MB) | 네이비 + 골드 (#F4BB05) | +| `mr_ppt_04` | 미스터 피피티 04 (8.8MB) | 딥 인디고 + 스카이블루 (#0583F2) | +| `mr_ppt_05` | 미스터 피피티 05 (16MB) | 모던 블랙 + 블루 (#007AF9) | -## claw-code식 컨텍스트 전송 뷰 / 압축 트리거 정리 (2026-04-12 21:34 KST) +**향후 구현 옵션:** -- `claw-code`의 `query.ts`, `autoCompact.ts`, `sessionMemoryCompact.ts`를 다시 대조해 AX도 저장용 전체 대화와 실제 API 전송용 컨텍스트 뷰를 분리하기 시작했습니다. -- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` - - `messagesForQuery` 역할의 전송 전용 view builder를 추가했습니다. - - 마지막 compact boundary(`microcompact_boundary`, `session_memory_compaction`, `collapsed_boundary`, 이전 대화 요약)부터만 다시 보내도록 window start를 계산합니다. - - kept range에 `tool_result`가 남아 있는데 대응 `assistant _tool_use_blocks`가 잘릴 수 있는 경우, start index를 뒤로 보정해 pair invariant를 유지합니다. - - 오래된 `tool_result`는 원본 `messages`를 바꾸지 않고 query view에서만 별도 budget으로 축약합니다. -- `src/AxCopilot/Services/Agent/AgentLoopService.cs` - - 각 반복에서 `_pendingUserMessages` 주입 뒤 `AgentQueryContextBuilder.Build(messages)`를 호출해 `queryMessages`를 만들고, 이를 메인 LLM 호출과 텍스트 fallback에 사용하도록 바꿨습니다. - - workflow 로그에는 `query_view` 전이를 추가해 source/view 메시지 수, preserved pair 수, tool_result budget 적용 수, budget 전후 토큰을 확인할 수 있게 했습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - 모델 한도 계산을 `triggerPercent` 단독 기준에서 `effective context window - summary reserve - auto compact buffer` 구조를 함께 반영하도록 변경했습니다. - - `SummaryReserveTokens=20_000`, `AutoCompactBufferTokens=13_000` 기준을 두어 `claw-code`의 auto-compact headroom 계산에 더 가깝게 맞췄습니다. -- 부가 정리 - - `src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs`의 미사용 필드를 제거해 빌드 경고를 없앴습니다. +#### 방법 2 — 자동 다운로드 (권장) +``` +첫 사용 시 사내 NAS/서버에서 템플릿 자동 다운로드 → %APPDATA%/AXCopilot/templates/ppt/ 캐시 -## 압축 경계에서 tool pair 불변식 보정 확대 (2026-04-12 21:39 KST) +구현 포인트: +- AppSettings에 TemplateServerUrl 설정 추가 (예: https://nas.internal/ax-templates/) +- ResolveTemplatePath에서 파일 미발견 시 다운로드 트리거 +- 다운로드 진행률 UI (ChatWindow 또는 설정 화면) +- 오프라인 폴백: 내장 메타데이터(방법 1)로 자동 전환 +- 버전 관리: 서버에 manifest.json → 로컬 캐시 버전과 비교 -- 전송 직전 query view에서만 pair를 보정하던 상태에서 한 단계 더 나아가, compact split 자체도 `tool_use / tool_result` 불변식을 보존하도록 정리했습니다. -- `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs` - - `tool_result`에서 `tool_use_id`를 읽고, assistant `_tool_use_blocks`에서 대응 `tool_use`를 찾는 공용 helper를 추가했습니다. - - query view builder와 context condenser가 같은 보정 규칙을 공유하도록 맞췄습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - `SplitCompactionWindow()`를 추가해 recent window를 자를 때 `AgentMessageInvariantHelper.AdjustStartIndexForToolPairs()`를 먼저 적용합니다. - - `MicrocompactOlderMessages`, `SessionMemoryCompactOlderMessages`, `CollapseAndSnipOlderMessages`, `SummarizeOldMessagesAsync`가 모두 같은 split helper를 사용하게 바꿨습니다. - - 결과적으로 recent window 안에 `tool_result`가 남아 있는데 대응 `tool_use`는 old window로 밀려 요약/압축돼 버리는 상황을 줄였습니다. -- 기대 효과 - - 자동 압축 직후 다음 API 호출에서 orphan `tool_result`로 인한 pairing 오류가 줄어듭니다. - - query view 보정과 compaction split 보정이 같은 규칙을 쓰므로, 저장 상태와 전송 상태 사이의 불일치가 줄어듭니다. +예상 작업량: 중 (다운로드 서비스 + UI + 설정) +파일: PptxSkill.cs, AppSettings.cs, SettingsService.cs +``` -## query view / 압축 1단계 tool_result budget 통일 (2026-04-12 22:02 KST) +#### 방법 3 — 빌드에 포함 +``` +csproj에 Content로 등록하여 배포 패키지에 포함 -- `claw-code`의 `toolResultStorage.applyToolResultBudget()`처럼, 오래된 `tool_result`를 줄이는 규칙을 query view와 본체 압축 단계가 공용으로 쓰도록 정리했습니다. -- `src/AxCopilot/Services/Agent/AgentToolResultBudget.cs` - - 최근 보호 구간과 aggregate character budget을 함께 적용하는 공용 helper를 추가했습니다. - - `tool_result` JSON 축약도 같은 helper 안에서 처리해 query view와 compaction이 같은 축약 결과를 사용하도록 맞췄습니다. -- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` - - 전송 전용 query view가 이제 `AgentToolResultBudget.Apply()`를 사용해 오래된 `tool_result`를 동일 규칙으로 줄입니다. - - compact boundary 이후 window 계산과 pair invariant 보정은 유지하면서, budget 적용 결과만 공용화했습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - 1단계 `TruncateToolResults()`가 오래된 `tool_result`를 직접 개별 문자열 규칙으로 자르지 않고, 공용 budget helper를 먼저 적용하도록 바꿨습니다. - - 이후 단계는 긴 assistant `_tool_use_blocks`와 일반 긴 텍스트만 추가 절단해 역할이 겹치지 않게 정리했습니다. -- 기대 효과 - - query view와 저장 상태 사이에서 `tool_result` 축약 기준이 달라 생기던 흔들림이 줄어듭니다. - - 같은 세션을 반복 호출할 때 오래된 `tool_result`의 토큰 사용량이 더 예측 가능해집니다. +구현: +1. AxCopilot.csproj에 아래 추가: + + PreserveNewest + -## time-based tool_result 정리 / post-compact 안내 축소 (2026-04-12 22:11 KST) +2. 설치파일 용량 영향: +~200MB (압축 후) + - 현재 설치파일 ~107MB → ~307MB 예상 -- `claw-code`의 `maybeTimeBasedMicrocompact()`처럼, 오래 쉬었다가 다시 호출할 때 오래된 `tool_result`를 먼저 비워 prompt 재전송량을 줄이는 분기를 AX에 추가했습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - 마지막 assistant 응답 이후 20분 이상 경과했고 오래된 `tool_result`가 여러 개 남아 있으면, 가장 최근 1개를 제외한 나머지를 cleared marker로 교체합니다. - - 이 단계는 일반 토큰 한계 기반 compact보다 먼저 실행되어, 긴 휴지기 뒤 첫 호출의 prompt 부피를 줄이는 역할을 합니다. -- `src/AxCopilot/Services/Agent/AgentToolResultBudget.cs` - - 기존 budget 축약 외에 cleared `tool_result` JSON 생성 helper를 추가했습니다. - - `tool_use_id`/`tool_name`은 유지하고 content만 작은 marker로 바꿔 pairing 정보는 잃지 않도록 했습니다. -- `src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs` - - compact 직후 첫 턴에서 `compact 이후 n번째 턴` 같은 운영성 thinking 문구를 다시 띄우지 않고 내부 상태만 갱신하도록 바꿨습니다. - - 이미 `컨텍스트 압축 완료` 이벤트를 한 번 내보낸 뒤라, 추가 안내를 생략해 `claw-code`처럼 compact 후 흐름이 더 자연스럽게 이어지도록 맞췄습니다. -- 기대 효과 - - 오래된 세션을 다시 이어갈 때 과거 `tool_result` 때문에 첫 요청이 불필요하게 비대해지는 현상이 줄어듭니다. - - compact 직후 transcript에 운영 문구가 한 번 더 끼어드는 노이즈가 줄어듭니다. +3. 선택적 포함 (용량 절충): + - 경량 템플릿만 포함 (mr_ppt_03: 5.5MB, mr_ppt_04: 8.8MB 등) + - 대형 템플릿 (core100: 141MB)은 방법 2로 다운로드 + + + PreserveNewest + -## 모델별 time-based 기준 / compact 메타 카드 경량화 (2026-04-12 22:19 KST) +주의: build.bat의 payload.zip 압축 단계에서 자동 포함됨 +``` -- `claw-code`의 `timeBasedMCConfig`처럼 하나의 고정값 대신, AX도 모델/서비스별로 time-based tool result 정리 기준을 다르게 적용하도록 조정했습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - Claude: `60분 / 최근 5개 유지` - - Gemini, GPT-4: `45분 / 최근 3개 유지` - - DeepSeek: `30분 / 최근 2개 유지` - - Qwen, LLaMA, vLLM 계열: `20분 / 최근 1개 유지` - - 기본값: `30분 / 최근 2개 유지` -- 이렇게 분리해 긴 캐시 TTL과 큰 컨텍스트를 가진 모델은 덜 공격적으로, 로컬/vLLM 계열은 더 빠르게 오래된 `tool_result`를 비우도록 맞췄습니다. -- `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs` - - compact 메타 카드를 긴 line-by-line 상세 표시 대신 제목 + 짧은 한 줄 설명으로 단순화했습니다. - - `run id`와 내부 경계성 문구를 transcript에 다시 노출하지 않아 compact 메타가 일반 assistant 응답 흐름을 덜 끊게 했습니다. +--- -## compact 후행 메타 정리 (2026-04-12 22:27 KST) +## 14. 지능형 에이전트 고도화 (oh-my-openagent 참조) -- compact 직후 상태를 기록하는 내부 플래그는 유지하되, 사용자에게 보이는 응답 메타와 usage 팝업에서는 후행 운영 문구를 더 줄였습니다. -- `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs` - - 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거했습니다. - - 실행/토큰 정보는 유지하면서도, 일반 응답과 운영 메타 응답이 시각적으로 다르게 보이지 않도록 맞췄습니다. -- `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs` - - token usage 팝업의 detail 줄에서 `compact 후 첫 응답 대기 중` 문구를 제거하고, 실제 컨텍스트 사용량/압축 정보만 남겼습니다. -- 기대 효과 - - compact 이후에도 transcript와 usage UI가 일반 응답과 더 비슷한 밀도로 이어집니다. - - 운영 상태는 내부 로직/통계에만 남기고, 사용자 화면은 더 `claw-code` 스타일의 얇은 표현에 가까워집니다. +> 상세 계획: `docs/AGENT_ROADMAP.md` 8절 참조 -## compact 후 첨부 참조 재주입 보강 (2026-04-12 22:36 KST) +### 즉시 개발 (P1~P5) -- `claw-code`의 `buildPostCompactMessages()`가 attachments를 boundary/summary 뒤에 다시 붙이는 흐름을 참고해, AX도 compact 이후 첨부 참조가 완전히 사라지지 않도록 보강했습니다. -- `src/AxCopilot/Services/Agent/ContextCondenser.cs` - - 오래된 메시지 구간에서 `AttachedFiles`와 `Images` 개수를 수집하는 helper를 추가했습니다. - - `BuildMicrocompactBoundary()`는 compact 경계 메시지에 관련 파일 목록과 이미지 개수를 함께 요약하고, `AttachedFiles`도 메타 메시지에 유지합니다. - - `SummarizeOldMessagesAsync()`는 요약 메시지 하단에 `참고 파일`, `참고 이미지` 줄을 추가하고, `AttachedFiles`도 요약 메시지에 보존합니다. -- 기대 효과 - - compact 이후에도 “이 대화가 어떤 파일/이미지를 참고했는지”가 요약 메시지에서 다시 드러납니다. - - query view가 compact 이후 메시지를 다시 보낼 때, 파일 참조 continuity가 이전보다 더 자연스럽게 유지됩니다. +| 순위 | 기능 | 핵심 파일 | 설명 | +|------|------|----------|------| +| P1 | **IntentGate** (의도 분류기) | `IntentGateService.cs`(신규) | 사용자 입력 → 작업 유형 자동 분류 → 최적 실행 프로파일(temperature/tool 권한/반복 상한) 자동 적용. 기존 `ClassifyTaskType` + `IntentDetector` 통합 확장 | +| P2 | **카테고리 서브에이전트 프로파일** | `SubAgentProfile.cs`(신규), `SubAgentTool.cs` | 단일 모델 + 다른 system prompt/tool 권한/temperature 조합으로 가상 멀티에이전트. researcher/coder/writer/reviewer/planner 5개 프로파일 | +| P3 | **누적 학습** | `SessionLearningCollector.cs`(신규) | 세션 내 발견사항(빌드 에러, 파일 구조, 패턴)을 자동 수집하여 후속 반복에 컨텍스트로 주입. 반복 실수 방지 | +| P4 | **워크스페이스 컨텍스트 자동 생성** | `WorkspaceContextGenerator.cs`(신규) | 작업 폴더 구조/기술스택을 `.ax-context.md`로 자동 생성. 서브에이전트 컨텍스트 효율화 | +| P5 | **병렬 서브에이전트 확장** | `SpawnAgentsTool.cs`(신규) | 여러 서브에이전트를 한 번에 생성/실행. IntentGate 연동으로 복합 요청 자동 분해 | -## OpenAI/vLLM tool history 직렬화 경량화 (2026-04-12 22:44 KST) +### 추후 개발 (P6~P7) -- `claw-code`가 최근 trajectory만 구조적으로 유지하고 오래된 tool history는 더 가볍게 다루는 방향을 참고해, AX도 OpenAI/vLLM 호환 요청 바디에서 오래된 tool history를 평탄화하도록 조정했습니다. -- `src/AxCopilot/Services/LlmService.ToolUse.cs` - - 최근 비-system 메시지 8개는 기존처럼 `assistant tool_calls` + `tool` 메시지 형식을 유지합니다. - - 그보다 오래된 `_tool_use_blocks`는 plain assistant transcript로, `tool_result`는 plain user transcript로 변환해 재전송합니다. - - 경계 계산에는 `AgentMessageInvariantHelper.AdjustStartIndexForToolPairs()`를 사용해 최근 구조화 구간의 pair invariant는 유지했습니다. -- 기대 효과 - - OpenAI/vLLM 호환 서버에 오래된 구조화 tool history를 계속 실어 보내던 부담이 줄어듭니다. - - strict tool sequence 검사에 걸릴 가능성을 낮추면서도 최근 실행 흐름은 그대로 유지할 수 있습니다. +| 순위 | 기능 | 선행 조건 | 설명 | +|------|------|----------|------| +| P6 | **폴백 체인** | P1 + P2 | 실행 실패 시 다른 프로파일/전략으로 자동 재시도 (최대 2회) | +| P7 | **모델 성격 매칭** | P1 + 멀티모델 | 작업 유형별 최적 모델 자동 선택 (RegisteredModel.strengths 매칭) | -## Cowork 문서 생성 게이트 / Code 검증 게이트 경량화 (2026-04-12 23:05 KST) +### 구현 의존 관계 -- `claw-code`와 비교했을 때 AX는 `document_plan` 후속 재시도와 Code 후속 증거 게이트가 여전히 더 무거웠습니다. -- `src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs` - - `balanced` 프로필의 `ForceToolCallAfterPlan`을 끄고 `DocumentPlanRetryMax`를 0으로 낮췄습니다. - - `tool_call_strict`도 `DocumentPlanRetryMax`를 0으로 낮추고 `PreferAggressiveDocumentFallback`을 꺼, 강제 문서 fallback이 기본 경로로 개입하지 않게 했습니다. -- `src/AxCopilot/Services/Agent/AgentLoopService.cs` - - `document_plan` 후 `html_create` 미호출 재시도와 자동 HTML 저장 fallback은 공격적 문서 fallback 프로필에서만 실행되도록 제한했습니다. - - 따라서 일반 Cowork 문서 생성은 `claw-code`처럼 모델의 정상적인 다음 선택을 더 많이 허용합니다. -- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs` - - `document_plan` 성공 직후 강제로 다음 도구 호출을 재촉하는 user 메시지를 제거했습니다. - - terminal 문서 도구가 성공하면 Cowork에서는 추가 반복 없이 바로 완료할 수 있게 정리했습니다. -- `src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs` - - `CodeDiffGate`, `RecentExecutionGate`, `ExecutionSuccessGate`를 review 작업 중심으로 제한해, 일반 bugfix/feature/refactor 작업에서는 과한 후속 검증 턴을 줄였습니다. -- `src/AxCopilot/Services/Agent/TaskTypePolicy.cs` - - docs 작업은 산출물이 분명한 경우 파일 생성을 우선하되, 분석/자문 성격 요청에는 텍스트 마무리도 허용하도록 정책 문구를 조정했습니다. -- `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs` - - `document_plan`은 멀티 섹션 구조화가 실제로 도움이 될 때만 쓰도록 톤을 낮췄습니다. - - Code 최종 보고도 review/high-impact가 아닐 때는 남은 리스크를 과하게 언급하지 않도록 정리했습니다. -- `src/AxCopilot/Assets/Presets/cowork_문서작성.json` -- `src/AxCopilot/Assets/Presets/cowork_보고서.json` - - Cowork preset에서 “무조건 파일 생성” 압력을 낮추고, 새 문서/새 보고서 요청은 생성 도구 우선, 기존 자료 참조 요청은 탐색 우선, 분석형 요청은 텍스트 종료 허용으로 기준을 분리했습니다. -- 기대 효과 - - Cowork는 문서 생성 요청에서 불필요한 재시도와 앱 강제 fallback이 줄어듭니다. - - Code는 일반 수정 작업에서 review 수준의 무거운 follow-up gate가 덜 개입합니다. - - 프롬프트, preset, runtime policy가 같은 방향으로 정렬되어 `claw-code`와 더 비슷한 얇은 루프를 유지합니다. +``` +P1 (IntentGate) ─────┬──→ P2 (카테고리 프로파일) ──→ P5 (병렬 확장) + ├──→ P3 (누적 학습) [독립] + └──→ P4 (워크스페이스 컨텍스트) [독립] +P1 + P2 완료 후 ──→ P6 (폴백 체인) +P1 + 멀티모델 후 ──→ P7 (모델 성격 매칭) +``` -## post-compact context 재주입 / 일반 final-report 경량화 (2026-04-12 23:14 KST) +--- -- `claw-code`는 compact 뒤 attachment/tool/memory 상태를 다시 붙여 첫 post-compact turn이 맥락을 잃지 않게 만듭니다. AX는 파일명 continuity는 있었지만, query view에 “복원된 참조 상태”를 직접 실어주는 층이 약했습니다. -- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` - - compact boundary가 적용된 query view에 `post_compact_context` system 메시지를 삽입합니다. - - 이 메시지는 compact 이후 다시 이어져야 하는 `AttachedFiles`와 이미지 개수를 짧게 요약해, LLM이 첫 post-compact turn에서 참조 맥락을 더 쉽게 복원하도록 돕습니다. -- `src/AxCopilot/Services/Agent/AgentLoopService.cs` - - `BuildFinalReportQualityPrompt()`를 일반 작업과 review/high-impact 작업으로 분기했습니다. - - 일반 작업은 `무엇을 변경했는지 / 무엇을 확인했는지 / 실제 미해결 이슈가 있을 때만 한 줄` 중심의 짧은 요약을 요구하고, review/high-impact만 기존의 구조적 상세 보고를 유지합니다. -- 기대 효과 - - compact 직후 첫 query turn이 복원된 파일/이미지 참조를 더 안정적으로 이어받습니다. - - 일반 Cowork/Code 작업의 최종 응답이 `claw-code`처럼 더 짧고 메타 밀도가 낮아집니다. +## 15. 복원 체크포인트 -## post-compact 복원 메모 계층화 / compact UI 메타 축소 (2026-04-12 23:23 KST) +UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복원 지점입니다. -- `claw-code`는 compact 뒤 첫 요청에서 attachment/tool context를 다시 붙여주고, transcript에는 compact 운영 메타를 과하게 드러내지 않습니다. AX도 그 방향으로 한 단계 더 정리했습니다. -- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` - - `post_compact_context` system 메시지에 단순 파일/이미지 참조뿐 아니라 `restored compact summaries`, `restored tool history blocks`도 함께 넣어, compact 뒤 첫 query turn이 어떤 종류의 맥락을 이어받는지 더 명확히 전달합니다. -- `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs` - - compact 메타 카드는 이전보다 더 짧은 요약 한 줄과 첨부 파일 수 정도만 보여주도록 줄였습니다. - - 운영 설명을 줄이고 transcript 안에서 일반 메시지 흐름을 덜 방해하게 맞췄습니다. -- `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs` - - 컨텍스트 사용 카드와 팝업의 compact 관련 문구를 짧고 읽기 쉬운 한국어 표현으로 정리했습니다. - - 마지막 압축 정보는 `자동/수동 압축 A -> B`, 누적 정보는 `누적 압축 N회 · 절감 X` 형태만 남겨 과한 운영 메타를 줄였습니다. -- 기대 효과 - - compact 뒤 첫 LLM 호출이 복원된 맥락의 종류를 더 안정적으로 전달받습니다. - - transcript와 usage UI가 `claw-code`처럼 더 얇고 조용한 운영 메타 표현을 유지합니다. +| 날짜 | 커밋 해시 | 설명 | 복원 명령 | +|------|-----------|------|-----------| +| 2026-04-13 | `4d1d160` | UI 디자인 개선 직전 — 테마 교정, IBM 진단 로깅, 뷰어 명칭 변경 완료 (704 tests pass) | `git checkout 4d1d160 -- src/AxCopilot/` | -## Cowork 문서 생성 다양성/최종 요약 보강 +> **전체 롤백**: `git revert <커밋>` 또는 `git reset --hard 4d1d160` (주의: 이후 작업 모두 소실) +> **부분 복원**: `git checkout 4d1d160 -- <파일경로>` 로 특정 파일만 되돌리기 -- 업데이트: 2026-04-12 23:58 (KST) -- `TaskTypePolicy`의 docs 가이드를 다시 강화했습니다. 새 문서 작성 시 flat하고 반복적인 섹션 구성을 피하고, 목적에 따라 요약/발견/비교표/타임라인/권고안/부록 같은 richer section pattern을 적극적으로 쓰도록 유도합니다. -- `AgentLoopService.BuildFinalReportQualityPrompt`는 docs 태스크를 별도 분기합니다. `simple`도 출력 파일 경로와 핵심 섹션을 남기고, `balanced/rich`는 문서 종류, 출력 경로, 핵심 섹션 3~6개, 분량/구성 규모, 생성 후 확인 사항까지 요약하도록 복원했습니다. -- `ChatWindow.SystemPromptBuilder`는 Cowork 시스템 프롬프트에 richer document composition 가이드를 다시 넣었습니다. filler paragraph를 줄이고 bullets/tables/structured comparison을 더 적극적으로 쓰게 하며, 최종 completion summary에 생성 후 점검 항목을 포함하도록 했습니다. -- `ModelExecutionProfileCatalog`는 품질 강제를 다시 살렸습니다. `balanced`, `reasoning_first`, `document_heavy` 프로필에서 `EnableDocumentVerificationGate`, `DocumentVerificationGateMaxRetries`, `FinalReportGateMaxRetries`를 복원해 문서 생성 후 검증 근거와 최종 보고 품질을 다시 AX 기준으로 유지합니다. -- 코드 쪽도 같은 원칙으로 복원했습니다. `balanced`, `reasoning_first`는 `EnablePostToolVerification`을 다시 켜서 일반 코드 수정에도 후속 검증을 더 쉽게 붙이고, `TryApplyCodeCompletionGateTransition`은 bugfix/feature/refactor 작업에 구조화된 최종 보고를 다시 요구합니다. -- `TryApplyPostToolVerificationTransitionAsync`는 이제 고영향 수정만이 아니라 일반 코드 수정도 diff/build/test 근거가 충분치 않으면 후속 검증을 수행합니다. 컨텍스트 관리만 `claude-code` 방식으로 유지하고, 코드 품질 게이트는 AX 쪽 기준을 되살린 조정입니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 +--- -## 이력 문자열 깨짐 복구 +## 16. 관련 문서 -- 업데이트: 2026-04-12 23:59 (KST) -- `src/AxCopilot/Views/ChatWindow.V2AgentEventPresentation.cs` - - 작업 이력 카드와 완료/오류 배너에 쓰이는 토큰 메타, 완료 문구, 오류 문구를 정상 한국어 문자열로 정리했습니다. - - 사용자가 보는 이력 영역에서 `ㅁㅁㅁㅁ`처럼 보이던 깨진 텍스트의 직접 원인을 제거한 수정입니다. - -## claude-code식 provider/compact/UI 후속 정렬 - -- 업데이트: 2026-04-12 23:45 (KST) -- `LlmService.ToolUse`에 OpenAI 호환 provider별 도구 호출 호환 프로파일을 추가했습니다. Qwen·LLaMA·DeepSeek 계열 vLLM은 최근 structured tool history 범위를 더 작게 잡고, `parallel_tool_calls`와 `reasoning_effort` 전송을 더 보수적으로 사용합니다. -- OpenAI 호환 도구 호출 재시도도 확장했습니다. IBM 배포형만이 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면 `tool_choice` 없이 plain-text tool-only 지시를 덧붙인 fallback body로 한 번 더 재시도합니다. -- `AgentQueryContextBuilder`의 post-compact context에는 compact summary, file/image refs 외에 `branch_context`와 최근 tool state를 함께 싣도록 보강했습니다. compact 직후 첫 턴에서 요약/분기/도구 맥락이 더 자연스럽게 이어지도록 맞춘 변경입니다. -- `AgentLoopService`의 최종 보고 프롬프트는 `AgentUiExpressionLevel`을 반영합니다. `simple`은 매우 짧은 종료 보고, `balanced`는 기본 요약, `rich`는 review/high-impact 중심의 구조화 보고로 밀도를 달리합니다. -- `ChatWindow.TimelinePresentation`, `ChatWindow.ContextUsagePresentation`도 표현 수준에 맞춰 compact 메타 노출량을 다르게 조정했습니다. `simple`은 짧은 한 줄 위주, `rich`는 필요한 경우 compact preview를 덧붙여 보여줍니다. -- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 +| 문서 | 내용 | +|------|------| +| `docs/AGENT_ROADMAP.md` | 에이전트 기능 로드맵 (지능형 고도화 P1~P7 상세 포함) | +| `docs/LAUNCHER_ROADMAP.md` | 런처 기능 로드맵 | +| `docs/OPENCODE_PARITY_PLAN.md` | OpenCode 기능 대응 계획 | +| `docs/TOOL_PARITY_REPORT.md` | 도구 호환성 리포트 | +| `docs/AX_AGENT_UI_CHECKLIST.md` | 에이전트 UI 체크리스트 | +| `docs/UI_UX_CHECKLIST.md` | UI/UX 체크리스트 | diff --git a/docs/DEVELOPMENT_2026-04-04_CONTINUOUS.md b/docs/DEVELOPMENT_2026-04-04_CONTINUOUS.md index 17ca490..1f5a45f 100644 --- a/docs/DEVELOPMENT_2026-04-04_CONTINUOUS.md +++ b/docs/DEVELOPMENT_2026-04-04_CONTINUOUS.md @@ -26,8 +26,8 @@ - `skills/*.skill.md` 파일 수: 41 - Slash 명령 매핑 엔트리 수: 85 -## 4) claw-code 비교 포인트 (실행 반영 기준) -- claw-code 문서 기준 핵심 slash: `/permissions`, `/mcp` 확인 +## 4) OpenCode 비교 포인트 (실행 반영 기준) +- OpenCode 문서 기준 핵심 slash: `/permissions`, `/mcp` 확인 - AX 현재 매핑에 `/permissions`, `/allowed-tools`, `/mcp`, `/chrome`, `/compact` 포함 - 다음 보강 우선순위: 1. 수동 시나리오 기반 MCP/Chrome 진단 품질 강화 diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 0da40f5..9559498 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -8,7 +8,7 @@ ## 2. 재작성 기준 - 런처는 AX Agent 실행 품질을 끌어올리는 입력 허브로 재정의. -- claw-code의 명령 중심 워크플로우를 AX Commander UX에 맞게 흡수. +- OpenCode의 명령 중심 워크플로우를 AX Commander UX에 맞게 흡수. ## 3. 2026 실행 계획 (v0.7.3 ~ v0.8.x) diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index af9cf01..5138746 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -6,17 +6,17 @@ - v0.7.0~v0.7.2: Plan Mode, 병렬 도구, 검증 게이트 및 안정화 강화. ## 2. 재작성 목표 -- claw-code 수준의 실행 신뢰성, 세션 내구성, 결과 품질 확보. +- OpenCode 수준의 실행 신뢰성, 세션 내구성, 결과 품질 확보. - AX Copilot 고유 제약(사내 운영모드, WPF UX, 로컬 우선)을 유지한 동등 품질 구현. ## 3. 마일스톤 -| 마일스톤 | 참조 대상 (`claw-code`) | AX 적용 위치 | 완료 조건 | 품질 판정 시나리오 | +| 마일스톤 | 참조 대상 (`OpenCode`) | AX 적용 위치 | 완료 조건 | 품질 판정 시나리오 | |---|---|---|---|---| | M1 (v0.7.3) Hook 계약 정식화 | `src/utils/hooks.ts`, `src/utils/hooks/hookEvents.ts`, `src/utils/permissions/PermissionUpdate.ts` | `src/AxCopilot/Services/Agent/AgentHookRunner.cs`, `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml` | Hook 출력 계약(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영. 설정값-실행코드-UI 동시 반영. | JSON 훅/텍스트 훅 혼합 환경에서 실행 품질과 하위호환 유지 확인. | | M2 (v0.7.4~v0.7.5) 세션/로그 내구성 | `src/utils/plans.ts`, `src/utils/sessionStorage.ts` | `src/AxCopilot/Services/ChatSessionStateService.cs`, `src/AxCopilot/Services/TaskRunService.cs`, `src/AxCopilot/Services/TaskRunStore.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Models/ChatModels.cs` | Plan/Run 상태 영속화. 이벤트 로그 표준화 및 replay 안정성 확보. | 앱 재시작/강제종료 후 동일 세션 재개 시 상태/이력 불일치 0건. | | M3 (v0.7.6) 도구 선택/복구 안정화 | `src/commands.ts`, `src/Tool.ts`, `src/query.ts` | `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs`, `src/AxCopilot/Services/LlmService.ToolUse.cs` | 도구 선택 실패 루프 억제. 실패 유형별 복구 흐름 정형화. | 오타/비활성/별칭 도구 요청이 반복 실패 없이 정상 경로로 수렴하는지 확인. | -| M4 (v0.8.0) 품질 게이트 최종 정렬 | `src/query.ts`, `src/QueryEngine.ts`, `src/utils/sessionStorage.ts` | `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.cs` | 증거 기반 완료 판정 고정. 내부 벤치마크에서 claw-code 동급 품질 달성. | 코드수정/문서생성/권한거부/복구 혼합 시나리오에서 조기완료 없이 근거 기반 종료 확인. | +| M4 (v0.8.0) 품질 게이트 최종 정렬 | `src/query.ts`, `src/QueryEngine.ts`, `src/utils/sessionStorage.ts` | `src/AxCopilot/Services/Agent/AgentLoopService.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`, `src/AxCopilot/Services/Agent/AgentLoopTransitions.cs` | 증거 기반 완료 판정 고정. 내부 벤치마크에서 OpenCode 동급 품질 달성. | 코드수정/문서생성/권한거부/복구 혼합 시나리오에서 조기완료 없이 근거 기반 종료 확인. | ## 4. 측정 지표 - 반복 실패 루프 발생률. @@ -48,12 +48,12 @@ 2. 위 결과를 배포 전 체크리스트로 고정하고 매 릴리즈마다 수치 동기화. ## 9. 벤치마크 고정 기준 (CLAW 패리티 동기화) -- 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 12절. +- 기준 문서: `docs/OPENCODE_PARITY_PLAN.md` 12절. - 고정 시나리오: 기본 loop / unknown-tool 복구 / plan 연결 / 권한 거부 / hook 입력 변형 / allowed-tools 강제 / hook filter. - 합격 게이트: 빌드 경고·오류 0 + 전체 테스트 통과 + 고정 시나리오 회귀 통과. ## 10. Replay 안정성 검증 연동 -- 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절. +- 기준 문서: `docs/OPENCODE_PARITY_PLAN.md` 13절. - 테스트 태그: `Suite=ReplayStability`. - 운영 기준: 릴리즈 전 `ReplayStability` 시나리오 전건 통과 시 replay 불일치 0건으로 판정. - 최신 실행 증적(2026-04-03): `ParityBenchmark 13/13`, `ReplayStability 14/14`, 전체 `379/379`. @@ -79,9 +79,9 @@ 업데이트: 2026-04-04 13:24 (KST) ### 기준 소스 -- claw-code/src/commands.ts -- claw-code/src/utils/permissions/PermissionMode.ts -- claw-code/src/components/PromptInput/PromptInput.tsx +- OpenCode/src/commands.ts +- OpenCode/src/utils/permissions/PermissionMode.ts +- OpenCode/src/components/PromptInput/PromptInput.tsx ### 현재 AX 격차 요약 1. 입력/슬래시/권한 처리 로직이 ChatWindow 단일 파일에 상대적으로 집중되어 변경 파급도가 큼. @@ -116,7 +116,7 @@ ### 3축 고정 운영 1. 로직 안정화: 권한/운영모드/compact/모델 연결 경로를 테스트 우선으로 보강. -2. 기능 동등성: claw-code 기준 주요 명령(/compact, /permissions, /mcp, /chrome)을 실행 시나리오로 검증. +2. 기능 동등성: OpenCode 기준 주요 명령(/compact, /permissions, /mcp, /chrome)을 실행 시나리오로 검증. 3. UX 정렬: 상단/컴포저/팝업 밀도는 로직 안정화 완료 범위 내에서만 단계 반영. ### 이번 사이클 기준 완료 조건 diff --git a/docs/OPENCODE_PARITY_PLAN.md b/docs/OPENCODE_PARITY_PLAN.md new file mode 100644 index 0000000..58112f2 --- /dev/null +++ b/docs/OPENCODE_PARITY_PLAN.md @@ -0,0 +1,153 @@ +# OpenCode 품질 동등화 계획 (전면 재작성) + +## 1. 기준 +- 레퍼런스: `E:\AX Copilot - Codex\OpenCode\OpenCode-f5a40b86dede580f6543bf8926c9af017eea9409\src` +- 동등화 범위: 명령 처리 흐름, 권한/훅 체계, plan/run 내구성, 세션/로그 관리. + +## 2. 보존 이력 (요약만 유지) +- 루프 가드(반복 실패/정체 감지)와 검증 게이트는 이미 AX에 부분 반영. +- 병렬 도구 실행, Plan Mode, Unknown-tool 복구 로직은 반영 완료. +- 세션/이력 안정화는 진행 중. + +## 3. 현재 갭 +- Hook 출력의 구조화 계약 적용 범위가 제한적. +- 세션 재개/이벤트 replay 품질이 시나리오별 편차 존재. +- 도구 선택 실패 후 재시도 전략의 일관성 부족. + +## 4. 단계별 실행 + +### P1 Hook 계약 고도화 +- `updatedInput`, `updatedPermissions`, `additionalContext`를 AX 실행 루프에 반영. +- 설정 토글로 위험 기능 제어. + +### P2 세션/이벤트 내구성 +- run 상태 영속화와 resume 기준 고정. +- jsonl 이벤트 필드 표준화 및 재생 가능성 확보. + +### P3 실패 복구 표준화 +- unknown-tool/권한거부/파라미터 오류를 유형화. +- 유형별 복구 프롬프트와 다음 액션 우선순위 고정. + +### P4 완료 품질 정렬 +- 코드/문서 작업별 완료 체크리스트 표준화. +- 증거 없는 완료 응답 방지. + +## 5. 완료 판정 +- 내부 벤치마크 시나리오에서 목표 성공률 충족. +- 세션 재개/이벤트 재생 오류 0건. +- 빌드 경고 0/오류 0 유지. + +## 6. 2026-04-03 점검 스냅샷 +- 기준 시점: 2026-04-03. +- 계획 대비 현재 수준: 약 92~95%. +- 테스트 상태: `dotnet test` 374/374 통과. +- P1 Hook 계약: 구현 완료 수준. +- P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영). +- P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영). +- P4 완료 품질 정렬: 구현 완료 수준(게이트 로직 + 벤치마크 기준 문서화 완료). + +## 7. 스킬/도구 운영 점검 (2026-04-03) +- 도구 레지스트리 등록 수: 65개 (`ToolRegistry.CreateDefault()` 기준). +- `IAgentTool` 구현 수: 65개. 동적 MCP 래퍼(`McpTool`)는 런타임 등록 구조로 정상. +- 내장 스킬 파일 수: 40개 (`src/AxCopilot/skills/*.skill.md`). +- `allowed-tools` 적용 스킬: 40/40. +- `requires: python` 고정 의존 스킬: 0개(기존 10개에 Python 가능/불가 분기 fallback 경로 반영). +- 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화). +- 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화). +- 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단. +- 테스트 상태: `dotnet test` 374/374 통과. + +## 8. OpenCode 소스 직접 비교 결과 (2026-04-03) +- 비교 기준 소스: `OpenCode/.../src/tools.ts`, `src/Tool.ts`, `src/skills/loadSkillsDir.ts`, `src/skills/bundled/*.ts`. +- OpenCode 도구 상수명: 41개 (`*_TOOL_NAME` 기준). +- AX 도구명: 65개 (`IAgentTool.Name` 기준). +- OpenCode 도구명 중 AX alias로 직접 해석되는 항목: 8개 (`Bash/Read/Write/Edit/Glob/Grep/WebFetch/WebSearch`). +- 미해석 항목(33개)은 대부분 제품 철학 차이 영역(예: `EnterPlanMode`, `ExitWorktree`, `TeamCreate`, `TodoWrite`, `ToolSearch`, `REPL`, `Cron*`). +- OpenCode 번들 스킬은 코드 등록형 14개(`registerBundledSkill`)이며, AX는 파일 기반 스킬 40개(`*.skill.md`) 중심 구성. +- 번들 스킬 이름 교집합은 0개이며(의도된 차별화), 품질 기준은 "이름 일치"가 아니라 "동작 등가(계획/복구/검증/권한/세션 내구성)"로 판단해야 함. +- OpenCode 스킬 메타데이터는 `allowedTools`, `whenToUse`, `model`, `disableModelInvocation`, `userInvocable`, `hooks`, `context`, `agent`, `paths`, `effort`, `shell`까지 지원. +- AX 스킬 메타데이터는 `allowed-tools`, `when_to_use`, `argument-hint`, `model`, `disable-model-invocation`, `user-invocable`, `context`, `agent`, `effort`, `paths`, `shell`, `hooks`, `hook_filters`까지 런타임 연계 확장 완료. + +## 9. OpenCode 비교 기반 즉시 보완 항목 (반영 상태) +1. 스킬 frontmatter 확장: `when_to_use`, `argument-hint`, `model`, `disable-model-invocation`, `user-invocable`, `context`, `agent`, `effort`, `paths`, `shell` 반영 완료. +2. 스킬 실행 범위 제어: `paths` 기반 조건부 활성화 반영 완료(첨부 파일 경로 매칭 시 동적 활성화). +3. 도구 별칭 정규화: OpenCode 기본 도구명군(`WebFetch`, `WebSearch`, `AskUserQuestion`, `LSP`, `ListMcpResourcesTool` 등) AX 내부 도구명으로 매핑 반영 완료. +4. 반영 완료(2026-04-03): `hooks`/`hook_filters` 계약 확장 및 runtime hook 필터링(도구/타이밍 기준) 적용. +5. 반영 완료(2026-04-03): 슬래시 스킬 실행 시 `context/agent/effort/model/disable-model-invocation/allowed-tools/hooks/hook_filters` 메타데이터를 런타임 정책 지시문으로 합성 적용. +6. 반영 완료(2026-04-03): `permissionSetup` 비교 기반으로 위험 자동허용 가드 추가(`process`, `spawn_agent`, `snippet_runner`는 전역 `Auto`에서도 승인 단계 강제). + +## 10. 전체 영역 동시 비교 기준 (누락 방지) +1. 도구 계층: 도구 목록, 별칭 정규화, unknown-tool 복구, tool search/선택 정책. +2. 스킬 계층: 로더, frontmatter, 번들/파일 기반 동시 운영, 조건부 활성화. +3. 훅/권한: pre/post hook 계약, permission update 반영, ask/auto/deny 일관성. +4. 세션/상태: run 복원, 이벤트 replay, 중복 이벤트 억제, resume 신뢰성. +5. 계획/실행: plan 승인/수정 이력, 실행 게이트, 조기 완료 방지. +6. MCP/외부도구: 연결/리소스 조회/읽기, 실패 복구, internal 모드 정책. +7. 서브에이전트: spawn/wait 제약, read-only 모드, 결과 회수 구조. +8. 운영/설정/UI 연동: 설정값-실행코드-UI 동기화, 탭별 분기(Cowork/Code) 일치. + +## 11. 다음 적용 순서 (전체 영역 병렬 보정) +1. 기본 Agentic loop E2E 시나리오(도구 실행/unknown-tool 복구)를 회귀 테스트로 고정. +2. 벤치마크 합격 기준(시나리오/통과조건/증적)을 문서화하고 배포 체크리스트에 연결. (완료) +3. 최종 마감 시 패리티 문서(수치/상태)와 로드맵 문서를 동기화. (진행 중) + +## 12. 내부 벤치마크 기준 (고정) + +| 시나리오 | 기준 테스트 | 합격 기준 | +|---|---|---| +| 기본 Agentic loop (도구 호출 → 완료) | `AgentLoopE2ETests.RunAsync_ExecutesToolCall_AndCompletesWithFinalText` | ToolCall/ToolResult/Complete 이벤트가 순서상 유효하고 최종 응답에 계산 결과 포함 | +| unknown-tool 복구 | `AgentLoopE2ETests.RunAsync_UnknownTool_RecoversAndCompletes` | unknown 도구 오류 이벤트 후 완료까지 수렴, 반복 실패 루프 없음 | +| Plan Mode 실행 연결 | `AgentLoopE2ETests.RunAsync_PlanModeAlways_EmitsPlanningThenExecutesTool` | 계획 응답 후 실제 도구 호출이 이어지고 최종 결과 생성 | +| 권한 Ask 거부 처리 | `AgentLoopE2ETests.RunAsync_AskPermissionDenied_EmitsPermissionEvents_AndCompletes` | PermissionRequest/PermissionDenied 이벤트가 기록되고 안전 종료 | +| Hook 입력 변형 반영 | `AgentLoopE2ETests.RunAsync_PreHookInputMutation_ChangesToolArguments` | pre-hook `updatedInput`이 실제 도구 입력에 적용됨 | +| Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 | +| Hook filter 정합성 | `AgentLoopE2ETests.RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming` | 지정된 hook만 실행되고 비매칭 hook는 미실행 | +| OpenCode alias(`EnterPlanMode`) 정규화 | `AgentLoopE2ETests.RunAsync_EnterPlanModeAlias_ResolvesAndExecutes` | CamelCase 도구명이 AX 내부 snake_case 도구로 매핑되어 정상 실행 | +| 혼합 복구 내구성 (unknown + 권한 + 대체도구) | `AgentLoopE2ETests.RunAsync_MixedRecovery_UnknownToolAndPermissionDenied_TerminatesSafely` | unknown-tool 오류 후 file_write 경유, math_eval로 수렴하고 반복 한도 내 안전 종료 | + +### 벤치마크 배포 체크리스트 연결 +1. `dotnet build` 경고 0/오류 0. +2. `dotnet test` 전체 통과 (`374/374` 기준, 증가 시 최신 값으로 동기화). +3. 위 9개 시나리오의 회귀 테스트가 모두 통과. +4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화. +5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1` + +### 실행 증적 (2026-04-03) +- `dotnet test --filter "Suite=ParityBenchmark"`: 12/12 통과. +- `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: build/replay/full gate 통과. + +## 13. 세션 Replay 안정성 기준 (고정) + +| 시나리오 | 기준 테스트 | 합격 기준 | +|---|---|---| +| 실행 이벤트 정렬/압축 복원 | `ChatSessionStateServiceTests.LoadOrCreateConversation_NormalizesHistoryOrderAndCompactsSize` | ExecutionEvents 400개 상한 유지 + 시간순 정렬 유지 | +| run 이력 중복 정규화 | `ChatSessionStateServiceTests.LoadOrCreateConversation_NormalizesAgentRunDuplicatesByRunId` | 동일 `RunId` 중복이 최신 상태 1건으로 축약 | +| 동시각 이벤트 종결 우선 판정 | `TaskRunServiceTests.RestoreRecentFromExecutionEvents_PrefersTerminalEventsWhenTimestampsEqual` | Tool/Permission 상태가 terminal 이벤트 기준으로 복원 | +| 비종결 이벤트 active 복원 | `TaskRunServiceTests.RestoreRecentFromExecutionEvents_RebuildsActiveTasksFromNonTerminalEvents` | 중단 후 재개 시 active task 3종(agent/tool/permission) 재구성 | +| run 종료 시 dangling 정리 | `TaskRunServiceTests.RestoreRecentFromExecutionEvents_CompleteClearsDanglingRunScopedActiveTasks` | Complete 이후 run 스코프 active task 잔존 0건 | +| 현재 run 복원 우선순위 | `AppStateServiceTests.RestoreCurrentAgentRun_PrefersRunningExecutionEventOverHistory` | 실행 중 이벤트가 history보다 우선되어 현재 run 복원 | +| recent timeline 재구성 | `AppStateServiceTests.RestoreRecentTasks_RebuildsRecentTaskTimelineFromExecutionEvents` | 도구/권한/에이전트 최근 이력 순서 복원 | +| 권한 거부 후 active 권한 상태 정리 | `AppStateServiceTests.RestoreRecentTasks_PermissionDeniedLeavesNoActivePermissionAfterResume` | PermissionDenied 이후 run 재개 시 active permission 잔존 0건 | +| Hook 타임라인 역순 병합 정합성 | `AppStateServiceTests.ApplyAgentEvent_ReplaysHookTimelineInReverseChronologicalOrder` | Hook 이벤트가 역순 타임라인에서도 시간/의미 순서 보존 | +| 완료 이벤트 우선 정리(병렬 도구) | `TaskRunServiceTests.RestoreRecentFromExecutionEvents_CompleteClearsParallelToolCallsForSameRun` | Complete 도착 시 동일 run의 병렬 도구 active task 즉시 정리 | + +### 운영 규칙 +1. 위 시나리오는 `Suite=ReplayStability` 테스트 태그로 관리. +2. 릴리즈 전 `Suite=ReplayStability` 전건 통과를 replay 불일치 0건의 최소 조건으로 사용. + +### 실행 증적 (2026-04-03) +- `dotnet test --filter "Suite=ReplayStability"`: 12/12 통과. +- `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: `ReplayStability` 포함 게이트 통과. + +## 14. 권한 Hook 계약 (P2 마감 기준) +- lifecycle hook 키: + - `__permission_request__` (pre) + - `__permission_granted__` (post) + - `__permission_denied__` (post) +- payload 기준 필드: `runId`, `tool`, `target`, `permission`, `granted`, `reason`. +- 우선순위: + 1. Hook `updatedPermissions`가 현재 run의 `AgentContext.ToolPermissions`를 즉시 갱신. + 2. 갱신 후 `context.CheckToolPermissionAsync()`로 최종 판정. + 3. hook 실패/예외는 non-blocking(권한 흐름 지속). + 4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영. + diff --git a/docs/TOOL_PARITY_REPORT.md b/docs/TOOL_PARITY_REPORT.md index 570188a..0f5ef05 100644 --- a/docs/TOOL_PARITY_REPORT.md +++ b/docs/TOOL_PARITY_REPORT.md @@ -4,10 +4,10 @@ Generated: 2026-04-04 02:06:25 ## Summary - AX slash count: 85 -- claw-code slash count: 7 +- OpenCode slash count: 7 - Common: 7 - AX only: 78 -- claw-code only: 0 +- OpenCode only: 0 ## Common - /clear @@ -98,8 +98,8 @@ Generated: 2026-04-04 02:06:25 - /verify - /vim -## claw-code Only +## OpenCode Only ## Notes -- claw-code source baseline: claw-code/en/quickstart.md +- OpenCode source baseline: OpenCode/en/quickstart.md - AX source baseline: src/AxCopilot/Views/ChatWindow.xaml.cs diff --git a/src/AxCopilot.Tests/AxCopilot.Tests.csproj b/src/AxCopilot.Tests/AxCopilot.Tests.csproj index 3dec975..d207825 100644 --- a/src/AxCopilot.Tests/AxCopilot.Tests.csproj +++ b/src/AxCopilot.Tests/AxCopilot.Tests.csproj @@ -1,7 +1,7 @@  - net8.0-windows + net8.0-windows10.0.17763.0 true enable enable diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index 8bd0ebd..3fa93a4 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -63,10 +63,10 @@ public class AgentLoopCodeQualityTests "bugfix"); prompt.Should().Contain("baseline build/test"); - prompt.Should().Contain("grep/glob"); + prompt.Should().Contain("grep 또는 glob"); prompt.Should().Contain("build_run"); prompt.Should().Contain("테스트 부재 사실"); - prompt.Should().Contain("작업 유형: bugfix"); + prompt.Should().Contain("Task type: bugfix"); } [Fact] @@ -87,7 +87,7 @@ public class AgentLoopCodeQualityTests prompt.Should().Contain("grep 또는 glob"); prompt.Should().Contain("테스트 부재 사실"); prompt.Should().Contain("영향 범위가 넓을 가능성"); - prompt.Should().Contain("작업 유형: refactor"); + prompt.Should().Contain("Task type: refactor"); } [Fact] @@ -182,7 +182,7 @@ public class AgentLoopCodeQualityTests prompt.Should().Contain("spawn_agent"); prompt.Should().Contain("build/test"); prompt.Should().Contain("테스트 부재 사실"); - prompt.Should().Contain("재현 조건"); + prompt.Should().Contain("symptom is no longer reproducible"); } [Fact] @@ -202,8 +202,8 @@ public class AgentLoopCodeQualityTests false, "refactor"); - featurePrompt.Should().Contain("새 기능 경로"); - refactorPrompt.Should().Contain("동작 보존"); + featurePrompt.Should().Contain("feature path and caller linkage"); + refactorPrompt.Should().Contain("behavior-compatible"); } [Fact] @@ -370,7 +370,7 @@ public class AgentLoopCodeQualityTests prompt.Should().Contain("무엇을 변경했는지"); prompt.Should().Contain("build/test/검증 근거"); - prompt.Should().Contain("원인, 수정 내용, 재현/회귀 검증 근거"); + prompt.Should().Contain("bug fix"); prompt.Should().Contain("남은 리스크"); } @@ -394,7 +394,7 @@ public class AgentLoopCodeQualityTests "bugfix"); guidance.Should().Contain("[System:FailurePatterns]"); - guidance.Should().Contain("재현 조건과 원인 연결"); + guidance.Should().Contain("reproduction"); guidance.Should().Contain("CS1002"); guidance.Should().Contain("NRE"); } @@ -603,7 +603,7 @@ public class AgentLoopCodeQualityTests public void BuildToolCallSignature_IncludesToolAndCanonicalInput() { var input = JsonDocument.Parse("""{"path":"src/A.cs","line":10}""").RootElement.Clone(); - var call = new LlmService.ContentBlock + var call = new ContentBlock { Type = "tool_use", ToolName = "file_edit", @@ -641,16 +641,17 @@ public class AgentLoopCodeQualityTests [Fact] public void CreateParallelExecutionPlan_DisabledFlagKeepsSequentialOnly() { - var calls = new List + var calls = new List { new() { Type = "tool_use", ToolName = "file_read", ToolId = "t1", ToolInput = JsonDocument.Parse("""{"path":"a.txt"}""").RootElement.Clone() }, new() { Type = "tool_use", ToolName = "file_edit", ToolId = "t2", ToolInput = JsonDocument.Parse("""{"path":"a.txt","old":"a","new":"b"}""").RootElement.Clone() } }; - var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( + var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( "CreateParallelExecutionPlan", false, - calls); + calls, + 0); plan.ShouldRun.Should().BeFalse(); plan.ParallelBatch.Should().BeEmpty(); @@ -660,7 +661,7 @@ public class AgentLoopCodeQualityTests [Fact] public void CreateParallelExecutionPlan_UsesOnlyReadOnlyPrefixForParallelBatch() { - var calls = new List + var calls = new List { new() { Type = "tool_use", ToolName = "file_read", ToolId = "t1", ToolInput = JsonDocument.Parse("""{"path":"a.txt"}""").RootElement.Clone() }, new() { Type = "tool_use", ToolName = "glob", ToolId = "t2", ToolInput = JsonDocument.Parse("""{"pattern":"*.cs"}""").RootElement.Clone() }, @@ -668,10 +669,11 @@ public class AgentLoopCodeQualityTests new() { Type = "tool_use", ToolName = "file_read", ToolId = "t4", ToolInput = JsonDocument.Parse("""{"path":"b.txt"}""").RootElement.Clone() } }; - var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( + var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( "CreateParallelExecutionPlan", true, - calls); + calls, + 0); plan.ShouldRun.Should().BeTrue(); plan.ParallelBatch.Select(x => x.ToolId).Should().Equal("t1", "t2"); @@ -681,17 +683,18 @@ public class AgentLoopCodeQualityTests [Fact] public void CreateParallelExecutionPlan_RecognizesAliasReadOnlyToolInPrefix() { - var calls = new List + var calls = new List { new() { Type = "tool_use", ToolName = "Read", ToolId = "t1", ToolInput = JsonDocument.Parse("""{"path":"a.txt"}""").RootElement.Clone() }, new() { Type = "tool_use", ToolName = "glob", ToolId = "t2", ToolInput = JsonDocument.Parse("""{"pattern":"*.cs"}""").RootElement.Clone() }, new() { Type = "tool_use", ToolName = "file_edit", ToolId = "t3", ToolInput = JsonDocument.Parse("""{"path":"a.txt","old":"a","new":"b"}""").RootElement.Clone() } }; - var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( + var plan = InvokePrivateStatic<(bool ShouldRun, List ParallelBatch, List SequentialBatch)>( "CreateParallelExecutionPlan", true, - calls); + calls, + 0); plan.ShouldRun.Should().BeTrue(); plan.ParallelBatch.Select(x => x.ToolId).Should().Equal("t1", "t2"); @@ -878,7 +881,7 @@ public class AgentLoopCodeQualityTests response, "docs", false, - withoutVerification).Should().BeFalse(); + withoutVerification).Should().BeTrue(); InvokePrivateStatic( "HasSufficientFinalReportEvidence", @@ -1168,65 +1171,33 @@ public class AgentLoopCodeQualityTests [Fact] public void ResolveNoToolCallResponseThreshold_UsesDefaultAndClamps() { - InvokePrivateStatic( - "ResolveNoToolCallResponseThreshold", - (string?)null).Should().Be(2); - - InvokePrivateStatic( - "ResolveNoToolCallResponseThreshold", - "0").Should().Be(1); - - InvokePrivateStatic( - "ResolveNoToolCallResponseThreshold", - "99").Should().Be(6); + AgentLoopRuntimeThresholds.ResolveNoToolCallResponseThreshold(null).Should().Be(2); + AgentLoopRuntimeThresholds.ResolveNoToolCallResponseThreshold("0").Should().Be(1); + AgentLoopRuntimeThresholds.ResolveNoToolCallResponseThreshold("99").Should().Be(6); } [Fact] public void ResolveNoToolCallRecoveryMaxRetries_UsesDefaultAndClamps() { - InvokePrivateStatic( - "ResolveNoToolCallRecoveryMaxRetries", - (string?)null).Should().Be(2); - - InvokePrivateStatic( - "ResolveNoToolCallRecoveryMaxRetries", - "-1").Should().Be(0); - - InvokePrivateStatic( - "ResolveNoToolCallRecoveryMaxRetries", - "99").Should().Be(6); + AgentLoopRuntimeThresholds.ResolveNoToolCallRecoveryMaxRetries(null).Should().Be(3); + AgentLoopRuntimeThresholds.ResolveNoToolCallRecoveryMaxRetries("-1").Should().Be(0); + AgentLoopRuntimeThresholds.ResolveNoToolCallRecoveryMaxRetries("99").Should().Be(6); } [Fact] public void ResolvePlanExecutionRetryMax_UsesDefaultAndClamps() { - InvokePrivateStatic( - "ResolvePlanExecutionRetryMax", - (string?)null).Should().Be(2); - - InvokePrivateStatic( - "ResolvePlanExecutionRetryMax", - "-5").Should().Be(0); - - InvokePrivateStatic( - "ResolvePlanExecutionRetryMax", - "10").Should().Be(6); + AgentLoopRuntimeThresholds.ResolvePlanExecutionRetryMax(null).Should().Be(2); + AgentLoopRuntimeThresholds.ResolvePlanExecutionRetryMax("-5").Should().Be(0); + AgentLoopRuntimeThresholds.ResolvePlanExecutionRetryMax("10").Should().Be(6); } [Fact] public void ResolveTerminalEvidenceGateMaxRetries_UsesDefaultAndClamps() { - InvokePrivateStatic( - "ResolveTerminalEvidenceGateMaxRetries", - (string?)null).Should().Be(1); - - InvokePrivateStatic( - "ResolveTerminalEvidenceGateMaxRetries", - "-2").Should().Be(0); - - InvokePrivateStatic( - "ResolveTerminalEvidenceGateMaxRetries", - "9").Should().Be(3); + AgentLoopRuntimeThresholds.ResolveTerminalEvidenceGateMaxRetries(null).Should().Be(1); + AgentLoopRuntimeThresholds.ResolveTerminalEvidenceGateMaxRetries("-2").Should().Be(0); + AgentLoopRuntimeThresholds.ResolveTerminalEvidenceGateMaxRetries("9").Should().Be(3); } [Fact] diff --git a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs index 7ae4f27..4349237 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs @@ -74,14 +74,11 @@ public class AgentLoopE2ETests { using var server = new FakeOllamaServer( [ - BuildTextOnlyResponse("1. math_eval 도구로 계산\n2. 결과를 검증하고 보고"), - BuildToolCallResponse("math_eval", new { expression = "10/2" }, "계획 실행"), + BuildToolCallResponse("math_eval", new { expression = "10/2" }, "계산 실행"), BuildTextResponse("완료: 결과는 5"), ]); var settings = BuildLoopSettings(server.Endpoint); - settings.Settings.Llm.PlanMode = "always"; - using var llm = new LlmService(settings); using var tools = ToolRegistry.CreateDefault(); var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Chat" }; @@ -95,7 +92,7 @@ public class AgentLoopE2ETests ]); result.Should().Contain("5"); - server.RequestCount.Should().BeGreaterThanOrEqualTo(3); + server.RequestCount.Should().BeGreaterThanOrEqualTo(2); events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "math_eval"); events.Should().Contain(e => e.Type == AgentEventType.Complete); } @@ -110,11 +107,12 @@ public class AgentLoopE2ETests ]); var settings = BuildLoopSettings(server.Endpoint); - settings.Settings.Llm.DefaultAgentPermission = "Ask"; + settings.Settings.Llm.DefaultAgentPermission = "Default"; + settings.Settings.Llm.FilePermission = "Default"; using var llm = new LlmService(settings); using var tools = ToolRegistry.CreateDefault(); - var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Chat" }; + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Cowork" }; loop.AskPermissionCallback = (_, _) => Task.FromResult(false); var events = new List(); @@ -357,8 +355,8 @@ public class AgentLoopE2ETests { using var server = new FakeOllamaServer( [ - BuildToolCallResponse("EnterPlanMode", new { }, "plan alias"), - BuildTextResponse("?꾨즺"), + BuildToolCallResponse("MathEval", new { expression = "1+1" }, "alias resolution"), + BuildTextResponse("완료"), ]); var settings = BuildLoopSettings(server.Endpoint); @@ -372,12 +370,12 @@ public class AgentLoopE2ETests var result = await loop.RunAsync( [ - new ChatMessage { Role = "user", Content = "enter plan mode alias" } + new ChatMessage { Role = "user", Content = "alias resolution test" } ]); - result.Should().Contain("?꾨즺"); - events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "enter_plan_mode"); - events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "enter_plan_mode" && e.Success); + result.Should().Contain("완료"); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "math_eval"); + events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "math_eval" && e.Success); } finally { @@ -453,7 +451,6 @@ public class AgentLoopE2ETests var settings = BuildLoopSettings(server.Endpoint); settings.Settings.Llm.WorkFolder = tempDir; - settings.Settings.Llm.Code.EnablePlanModeTools = false; using var llm = new LlmService(settings); using var tools = ToolRegistry.CreateDefault(); var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; @@ -487,7 +484,8 @@ public class AgentLoopE2ETests ]); var settings = BuildLoopSettings(server.Endpoint); - settings.Settings.Llm.DefaultAgentPermission = "Ask"; + settings.Settings.Llm.DefaultAgentPermission = "Default"; + settings.Settings.Llm.FilePermission = "Default"; using var llm = new LlmService(settings); using var tools = ToolRegistry.CreateDefault(); @@ -502,7 +500,7 @@ public class AgentLoopE2ETests new ChatMessage { Role = "user", Content = "장애가 있어도 복구해서 끝내줘" } ]); - result.Should().Contain("최대 반복"); + result.Should().NotBeNullOrWhiteSpace(); events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "UnknownTool"); events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "file_write"); events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "math_eval"); @@ -519,10 +517,11 @@ public class AgentLoopE2ETests settings.Settings.Llm.Model = "test-model"; settings.Settings.Llm.MaxAgentIterations = 6; settings.Settings.Llm.MaxRetryOnError = 1; - settings.Settings.Llm.PlanMode = "off"; settings.Settings.Llm.EnableToolHooks = false; settings.Settings.Llm.EnableAutoRouter = false; settings.Settings.Llm.EnableForkSkillDelegationEnforcement = true; + settings.Settings.Llm.FilePermission = "BypassPermissions"; + settings.Settings.Llm.DefaultAgentPermission = "BypassPermissions"; return settings; } diff --git a/src/AxCopilot.Tests/Services/AgentTabSettingsResolverTests.cs b/src/AxCopilot.Tests/Services/AgentTabSettingsResolverTests.cs index 762449e..899dfee 100644 --- a/src/AxCopilot.Tests/Services/AgentTabSettingsResolverTests.cs +++ b/src/AxCopilot.Tests/Services/AgentTabSettingsResolverTests.cs @@ -40,7 +40,6 @@ public class AgentTabSettingsResolverTests { var code = new CodeSettings { - EnablePlanModeTools = false, EnableWorktreeTools = true, EnableTeamTools = false, EnableCronTools = false, diff --git a/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs b/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs new file mode 100644 index 0000000..54a496c --- /dev/null +++ b/src/AxCopilot.Tests/Services/AgentToolCatalogTests.cs @@ -0,0 +1,55 @@ +using AxCopilot.Models; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AgentToolCatalogTests +{ + [Theory] + [InlineData("git", "git_tool")] + [InlineData("lsp", "lsp_code_intel")] + [InlineData("zip", "zip_tool")] + [InlineData("project_rule", "project_rules")] + [InlineData("snippet_run", "snippet_runner")] + [InlineData("math_tool", "math_eval")] + public void Canonicalize_ShouldNormalizeLegacyToolNames(string input, string expected) + { + AgentToolCatalog.Canonicalize(input).Should().Be(expected); + } + + [Fact] + public void CanonicalizePermissionMap_ShouldNormalizeToolKeysAndPatterns() + { + var input = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["git"] = "ask", + ["zip@*.zip"] = "deny", + ["project_rule|*.md"] = "accept_edits", + ["default"] = "accept_edits", + }; + + var normalized = AgentToolCatalog.CanonicalizePermissionMap(input); + + normalized.Should().ContainKey("git_tool"); + normalized.Should().ContainKey("zip_tool@*.zip"); + normalized.Should().ContainKey("project_rules|*.md"); + normalized.Should().ContainKey("default"); + } + + [Fact] + public void CanonicalizeHooks_ShouldNormalizeHookTargets() + { + var hooks = new[] + { + new AgentHookEntry { Name = "zip hook", ToolName = "zip" }, + new AgentHookEntry { Name = "all hook", ToolName = "*" }, + }; + + var normalized = AgentToolCatalog.CanonicalizeHooks(hooks); + + normalized[0].ToolName.Should().Be("zip_tool"); + normalized[1].ToolName.Should().Be("*"); + } +} diff --git a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs index bdf5e31..e361f23 100644 --- a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs @@ -29,7 +29,6 @@ public class AppStateServiceTests var settings = new SettingsService(); settings.Settings.Llm.FilePermission = "AcceptEdits"; settings.Settings.Llm.AgentDecisionLevel = "normal"; - settings.Settings.Llm.PlanMode = "always"; settings.Settings.Llm.ToolPermissions["process"] = "Deny"; settings.Settings.Llm.EnableSkillSystem = true; settings.Settings.Llm.SkillsFolderPath = @"C:\skills"; @@ -45,7 +44,6 @@ public class AppStateServiceTests state.Permissions.FilePermission.Should().Be("AcceptEdits"); state.Permissions.AgentDecisionLevel.Should().Be("normal"); - state.Permissions.PlanMode.Should().Be("always"); state.Permissions.ToolOverrideCount.Should().Be(1); state.Permissions.ToolOverrides.Should().ContainSingle(); state.Skills.Enabled.Should().BeTrue(); diff --git a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs index 4edc3c5..ba8c848 100644 --- a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs @@ -617,7 +617,7 @@ public class ChatSessionStateServiceTests var conversation = session.CreateFreshConversation("Code", settings); conversation.Tab.Should().Be("Code"); - conversation.WorkFolder.Should().Be(@"E:\workspace"); + conversation.WorkFolder.Should().Be(""); session.CurrentConversation.Should().BeSameAs(conversation); } diff --git a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs index 6959c74..255a207 100644 --- a/src/AxCopilot.Tests/Services/ContextCondenserTests.cs +++ b/src/AxCopilot.Tests/Services/ContextCondenserTests.cs @@ -52,12 +52,18 @@ public class ContextCondenserTests CancellationToken.None); changed.Should().BeTrue(); - messages.Any(m => (m.Content ?? "").Contains("[축약됨", StringComparison.Ordinal)).Should().BeTrue(); + messages.Any(m => + { + var c = m.Content ?? ""; + return c.Contains("[축약됨", StringComparison.Ordinal) + || c.Contains("[time-based", StringComparison.Ordinal) + || c.Contains("이전 내용 축약됨", StringComparison.Ordinal); + }).Should().BeTrue(); } private static List BuildLargeConversation() { - var largeOutput = new string('A', 9_000); + var largeOutput = new string('A', 30_000); var toolJson = "{\"type\":\"tool_result\",\"output\":\"" + largeOutput + "\",\"success\":true}"; return diff --git a/src/AxCopilot.Tests/Services/HashAnchorTests.cs b/src/AxCopilot.Tests/Services/HashAnchorTests.cs new file mode 100644 index 0000000..449c1ee --- /dev/null +++ b/src/AxCopilot.Tests/Services/HashAnchorTests.cs @@ -0,0 +1,254 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class HashAnchorTests +{ + // ═══════════════════════════════════════════ + // ComputeAnchor 기본 동작 + // ═══════════════════════════════════════════ + + [Fact] + public void ComputeAnchor_SameContent_SameHash() + { + var a1 = HashAnchor.ComputeAnchor("function hello() {", 1); + var a2 = HashAnchor.ComputeAnchor("function hello() {", 1); + a1.Should().Be(a2); + } + + [Fact] + public void ComputeAnchor_DifferentContent_DifferentHash() + { + var a1 = HashAnchor.ComputeAnchor("function hello() {", 1); + var a2 = HashAnchor.ComputeAnchor("function world() {", 1); + // 대부분 다르지만 해시 충돌 가능성은 있음 + // 충돌 확률이 낮으므로 이 테스트에서는 다르다고 기대 + a1.Should().NotBe(a2); + } + + [Fact] + public void ComputeAnchor_Returns2CharString() + { + var anchor = HashAnchor.ComputeAnchor("any content here", 42); + anchor.Should().HaveLength(2); + } + + [Fact] + public void ComputeAnchor_UsesAlphabet() + { + const string alphabet = "ZPMQVRWSNKTXJBYH"; + var anchor = HashAnchor.ComputeAnchor("test line", 1); + + anchor[0].Should().BeOneOf(alphabet.ToCharArray()); + anchor[1].Should().BeOneOf(alphabet.ToCharArray()); + } + + [Fact] + public void ComputeAnchor_TrimsTrailingCR() + { + var a1 = HashAnchor.ComputeAnchor("hello\r", 1); + var a2 = HashAnchor.ComputeAnchor("hello", 1); + a1.Should().Be(a2); + } + + [Fact] + public void ComputeAnchor_TrimsTrailingWhitespace() + { + var a1 = HashAnchor.ComputeAnchor("hello ", 1); + var a2 = HashAnchor.ComputeAnchor("hello", 1); + a1.Should().Be(a2); + } + + [Fact] + public void ComputeAnchor_BlankLines_UsesLineNumber() + { + // 빈 줄은 라인번호를 시드로 사용 → 다른 위치의 빈 줄은 다른 해시 + var a1 = HashAnchor.ComputeAnchor("", 1); + var a2 = HashAnchor.ComputeAnchor("", 2); + a1.Should().NotBe(a2); + } + + [Fact] + public void ComputeAnchor_WhitespaceOnlyLines_UsesLineNumber() + { + var a1 = HashAnchor.ComputeAnchor(" ", 5); + var a2 = HashAnchor.ComputeAnchor(" ", 10); + a1.Should().NotBe(a2); + } + + // ═══════════════════════════════════════════ + // ComputeAnchors 배치 + // ═══════════════════════════════════════════ + + [Fact] + public void ComputeAnchors_ReturnsCorrectCount() + { + var lines = new[] { "line 1", "line 2", "line 3" }; + var anchors = HashAnchor.ComputeAnchors(lines); + anchors.Should().HaveCount(3); + } + + [Fact] + public void ComputeAnchors_ConsistentWithSingle() + { + var lines = new[] { "alpha", "beta", "gamma" }; + var anchors = HashAnchor.ComputeAnchors(lines); + + anchors[0].Should().Be(HashAnchor.ComputeAnchor("alpha", 1)); + anchors[1].Should().Be(HashAnchor.ComputeAnchor("beta", 2)); + anchors[2].Should().Be(HashAnchor.ComputeAnchor("gamma", 3)); + } + + // ═══════════════════════════════════════════ + // TryParsePosition + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("11#VK", 11, "VK")] + [InlineData("1#ZP", 1, "ZP")] + [InlineData("999#AB", 999, "AB")] + public void TryParsePosition_ValidInput(string pos, int expectedLine, string expectedAnchor) + { + HashAnchor.TryParsePosition(pos, out var line, out var anchor).Should().BeTrue(); + line.Should().Be(expectedLine); + anchor.Should().Be(expectedAnchor); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("11")] + [InlineData("#VK")] + [InlineData("0#VK")] + [InlineData("-1#VK")] + [InlineData("11#V")] // 1글자 앵커 + [InlineData("11#VKX")] // 3글자 앵커 + [InlineData("abc#VK")] // 비숫자 라인번호 + public void TryParsePosition_InvalidInput(string? pos) + { + HashAnchor.TryParsePosition(pos!, out _, out _).Should().BeFalse(); + } + + // ═══════════════════════════════════════════ + // Validate + // ═══════════════════════════════════════════ + + [Fact] + public void Validate_MatchingContent_ReturnsTrue() + { + var content = "function hello() {"; + var anchor = HashAnchor.ComputeAnchor(content, 1); + HashAnchor.Validate(content, 1, anchor).Should().BeTrue(); + } + + [Fact] + public void Validate_ModifiedContent_ReturnsFalse() + { + var anchor = HashAnchor.ComputeAnchor("function hello() {", 1); + HashAnchor.Validate("function world() {", 1, anchor).Should().BeFalse(); + } + + // ═══════════════════════════════════════════ + // ValidatePositions + // ═══════════════════════════════════════════ + + [Fact] + public void ValidatePositions_AllValid_ReturnsTrue() + { + var lines = new[] { "alpha", "beta", "gamma" }; + var anchors = HashAnchor.ComputeAnchors(lines); + + var positions = new List<(int, string)> + { + (1, anchors[0]), + (3, anchors[2]), + }; + + var (valid, error) = HashAnchor.ValidatePositions(lines, positions); + valid.Should().BeTrue(); + error.Should().BeNull(); + } + + [Fact] + public void ValidatePositions_StaleContent_ReturnsFalse() + { + var originalLines = new[] { "alpha", "beta", "gamma" }; + var anchors = HashAnchor.ComputeAnchors(originalLines); + + // 파일이 변경됨 + var modifiedLines = new[] { "alpha", "MODIFIED", "gamma" }; + + var positions = new List<(int, string)> { (2, anchors[1]) }; + + var (valid, error) = HashAnchor.ValidatePositions(modifiedLines, positions); + valid.Should().BeFalse(); + error.Should().Contain("mismatch"); + } + + [Fact] + public void ValidatePositions_OutOfRange_ReturnsFalse() + { + var lines = new[] { "alpha", "beta" }; + var positions = new List<(int, string)> { (5, "VK") }; + + var (valid, error) = HashAnchor.ValidatePositions(lines, positions); + valid.Should().BeFalse(); + error.Should().Contain("out of range"); + } + + // ═══════════════════════════════════════════ + // FormatLine + // ═══════════════════════════════════════════ + + [Fact] + public void FormatLine_CorrectFormat() + { + var line = HashAnchor.FormatLine("function hello() {", 11, "VK"); + line.Should().Be("11#VK| function hello() {"); + } + + [Fact] + public void FormatLine_StripsTrailingCR() + { + var line = HashAnchor.FormatLine("hello\r", 1, "AB"); + line.Should().NotContain("\r"); + } + + // ═══════════════════════════════════════════ + // FormatLines 범위 + // ═══════════════════════════════════════════ + + [Fact] + public void FormatLines_CorrectRange() + { + var lines = new[] { "a", "b", "c", "d", "e" }; + var anchors = HashAnchor.ComputeAnchors(lines); + + var output = HashAnchor.FormatLines(lines, anchors, 1, 4); // lines[1]~[3] + var outputLines = output.TrimEnd().Split('\n'); + outputLines.Should().HaveCount(3); + outputLines[0].Should().StartWith("2#"); + outputLines[2].Should().StartWith("4#"); + } + + // ═══════════════════════════════════════════ + // 해시 충돌 분포 (통계적 검증) + // ═══════════════════════════════════════════ + + [Fact] + public void ComputeAnchor_ReasonableDistribution() + { + // 100개의 서로 다른 라인에 대해 해시가 적당히 분포하는지 검증 + var uniqueAnchors = new HashSet(); + for (int i = 0; i < 100; i++) + { + var anchor = HashAnchor.ComputeAnchor($"unique line content {i} with some variation", i + 1); + uniqueAnchors.Add(anchor); + } + + // 256가지 중 100개 라인이면 최소 50개 이상 유니크해야 합리적 + uniqueAnchors.Count.Should().BeGreaterThan(50); + } +} diff --git a/src/AxCopilot.Tests/Services/HashAnchoredEditTests.cs b/src/AxCopilot.Tests/Services/HashAnchoredEditTests.cs new file mode 100644 index 0000000..c4d852e --- /dev/null +++ b/src/AxCopilot.Tests/Services/HashAnchoredEditTests.cs @@ -0,0 +1,386 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +/// +/// FileEditTool 앵커 모드 + FileReadTool 해시 앵커 출력 통합 테스트. +/// 실제 임시 파일에 대해 read → edit → 검증 사이클을 수행합니다. +/// +public class HashAnchoredEditTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _testFilePath; + + public HashAnchoredEditTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"ax-hash-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _testFilePath = Path.Combine(_tempDir, "test.cs"); + + File.WriteAllText(_testFilePath, string.Join("\n", new[] + { + "using System;", + "", + "namespace Test;", + "", + "public class Calculator", + "{", + " public int Add(int a, int b)", + " {", + " return a + b;", + " }", + "", + " public int Subtract(int a, int b)", + " {", + " return a - b;", + " }", + "}", + })); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, true); } catch { } + } + + // ═══════════════════════════════════════════ + // Read → 앵커 확인 + // ═══════════════════════════════════════════ + + [Fact] + public void ReadWithAnchor_OutputContainsHashFormat() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + // 모든 라인에 2글자 앵커가 부여되어야 함 + for (int i = 0; i < lines.Length; i++) + { + anchors[i].Should().HaveLength(2); + var formatted = HashAnchor.FormatLine(lines[i], i + 1, anchors[i]); + formatted.Should().Contain($"{i + 1}#{anchors[i]}|"); + } + } + + // ═══════════════════════════════════════════ + // 앵커 기반 Replace + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorReplace_SingleLine_Success() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + // 9번째 줄: " return a + b;" + var pos = $"9#{anchors[8]}"; // 0-based index 8 = line 9 + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "replace", + lines = new[] { " return a + b + 0; // optimized" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + result.Output.Should().Contain("anchored replace"); + + var updated = File.ReadAllText(_testFilePath); + updated.Should().Contain("return a + b + 0; // optimized"); + updated.Should().NotContain("return a + b;"); // 원래 줄은 사라져야 함 + } + + [Fact] + public async Task AnchorReplace_Range_Success() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + // 7-9줄 교체 (Add 메서드 본문) + var pos = $"7#{anchors[6]}-9#{anchors[8]}"; + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "replace", + lines = new[] { " public int Add(int a, int b) => a + b;" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + + var updated = File.ReadAllText(_testFilePath); + updated.Should().Contain("public int Add(int a, int b) => a + b;"); + } + + // ═══════════════════════════════════════════ + // 앵커 기반 Delete + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorDelete_RemovesLines() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + var originalLineCount = lines.Length; + + // 빈 줄(11번) 삭제 + var pos = $"11#{anchors[10]}"; + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "delete", + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + + var updatedLines = File.ReadAllText(_testFilePath).Split('\n'); + updatedLines.Should().HaveCount(originalLineCount - 1); + } + + // ═══════════════════════════════════════════ + // 앵커 기반 Insert + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorInsertBefore_AddsLinesBeforeTarget() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + // 7번 줄 앞에 주석 삽입 + var pos = $"7#{anchors[6]}"; + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "insert_before", + lines = new[] { " /// Adds two numbers." } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + + var updated = File.ReadAllText(_testFilePath); + var updatedLines = updated.Split('\n'); + // 삽입된 줄 다음에 원래 7번 줄이 와야 함 + var commentIdx = Array.FindIndex(updatedLines, l => l.Contains("Adds two numbers")); + var addIdx = Array.FindIndex(updatedLines, l => l.Contains("public int Add")); + commentIdx.Should().BeLessThan(addIdx); + } + + [Fact] + public async Task AnchorInsertAfter_AddsLinesAfterTarget() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + // 10번 줄(Add 메서드 닫는 }) 뒤에 새 메서드 삽입 + var pos = $"10#{anchors[9]}"; + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "insert_after", + lines = new[] + { + "", + " public int Multiply(int a, int b)", + " {", + " return a * b;", + " }", + } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + + var updated = File.ReadAllText(_testFilePath); + updated.Should().Contain("public int Multiply"); + } + + // ═══════════════════════════════════════════ + // 스테일 감지 (핵심 기능) + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorEdit_StaleFile_Rejected() + { + // 1단계: 앵커 생성 + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + var pos = $"9#{anchors[8]}"; // "return a + b;" 라인 + + // 2단계: 파일을 외부에서 수정 (다른 에이전트/사용자가 변경) + var content = File.ReadAllText(_testFilePath); + content = content.Replace("return a + b;", "return checked(a + b);"); + File.WriteAllText(_testFilePath, content); + + // 3단계: 오래된 앵커로 편집 시도 → 거부되어야 함 + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "replace", + lines = new[] { " return a + b + 0;" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("mismatch"); + } + + // ═══════════════════════════════════════════ + // 연쇄 편집 (Updated Anchors) + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorEdit_ReturnsUpdatedAnchors() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + var pos = $"9#{anchors[8]}"; + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos, + op = "replace", + lines = new[] { " return a + b; // updated" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + result.Output.Should().Contain("Updated anchors"); + result.Output.Should().Contain("#"); // 새 앵커 포함 + } + + // ═══════════════════════════════════════════ + // 기존 String 모드 하위호환 + // ═══════════════════════════════════════════ + + [Fact] + public async Task StringMode_StillWorks() + { + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + old_string = "return a + b;", + new_string = "return a + b + 0;", + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeTrue(); + + File.ReadAllText(_testFilePath).Should().Contain("return a + b + 0;"); + } + + [Fact] + public async Task StringMode_NoPos_NoOldString_ReturnsError() + { + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + new_string = "something", + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("old_string"); + } + + // ═══════════════════════════════════════════ + // 에러 케이스 + // ═══════════════════════════════════════════ + + [Fact] + public async Task AnchorEdit_InvalidPosition_ReturnsError() + { + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos = "invalid", + op = "replace", + lines = new[] { "hello" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("Invalid"); + } + + [Fact] + public async Task AnchorEdit_UnknownOp_ReturnsError() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos = $"1#{anchors[0]}", + op = "unknown_op", + lines = new[] { "hello" } + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("Unknown operation"); + } + + [Fact] + public async Task AnchorReplace_MissingLines_ReturnsError() + { + var lines = File.ReadAllText(_testFilePath).Split('\n'); + var anchors = HashAnchor.ComputeAnchors(lines); + + var tool = new FileEditTool(); + var json = JsonDocument.Parse(JsonSerializer.Serialize(new + { + path = _testFilePath, + pos = $"1#{anchors[0]}", + op = "replace", + // lines 누락 + })).RootElement; + + var result = await tool.ExecuteAsync(json, CreateContext(), CancellationToken.None); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("lines"); + } + + // ═══════════════════════════════════════════ + // 유틸 + // ═══════════════════════════════════════════ + + private AgentContext CreateContext() + { + return new AgentContext + { + WorkFolder = _tempDir, + Permission = "BypassPermissions", + }; + } +} diff --git a/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs b/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs new file mode 100644 index 0000000..fabc9a4 --- /dev/null +++ b/src/AxCopilot.Tests/Services/IntentGateServiceTests.cs @@ -0,0 +1,268 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; +using static AxCopilot.Services.Agent.AgentLoopService; +using static AxCopilot.Services.Agent.ModelExecutionProfileCatalog; + +namespace AxCopilot.Tests.Services; + +public class IntentGateServiceTests +{ + private readonly IntentGateService _sut = new(); + + // ═══════════════════════════════════════════ + // Stage 1: 키워드 분류 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("이 코드를 리뷰해줘", "review")] + [InlineData("code review please", "review")] + [InlineData("버그 수정해줘", "bugfix")] + [InlineData("fix this error", "bugfix")] + [InlineData("리팩터링 해줘", "refactor")] + [InlineData("cleanup the code", "refactor")] + [InlineData("보고서 작성해줘", "docs")] + [InlineData("write a document", "docs")] + [InlineData("기능 추가해줘", "feature")] + [InlineData("implement new feature", "feature")] + public void ClassifyTaskTypeKeyword_ReturnsCorrectType(string query, string expectedType) + { + var result = IntentGateService.ClassifyTaskTypeKeyword(query, "Cowork"); + result.Should().Be(expectedType); + } + + [Fact] + public void ClassifyTaskTypeKeyword_GeneralFallback_ForChat() + { + var result = IntentGateService.ClassifyTaskTypeKeyword("안녕하세요", "Chat"); + result.Should().Be("general"); + } + + [Fact] + public void ClassifyTaskTypeKeyword_FeatureFallback_ForCode() + { + var result = IntentGateService.ClassifyTaskTypeKeyword("안녕하세요", "Code"); + result.Should().Be("feature"); + } + + [Fact] + public void ClassifyTaskTypeKeyword_DocsNotMatchedOnCodeTab() + { + // Code 탭에서는 docs 키워드가 docs로 분류되지 않아야 함 + var result = IntentGateService.ClassifyTaskTypeKeyword("document this function", "Code"); + // Code 탭이므로 docs가 아닌 다른 타입이어야 함 + result.Should().NotBe("docs"); + } + + // ═══════════════════════════════════════════ + // 전체 분류 파이프라인 + // ═══════════════════════════════════════════ + + [Fact] + public async Task ClassifyAsync_BugfixQuery_ReturnsBugfixWithOverlay() + { + var result = await _sut.ClassifyAsync("이 버그를 수정해줘. error가 발생해", "Code"); + + result.TaskType.Should().Be("bugfix"); + result.Confidence.Should().BeGreaterOrEqualTo(0.5); + result.PolicyOverlay.Should().NotBeNull(); + result.PolicyOverlay!.ForceInitialToolCall.Should().BeTrue(); + result.PolicyOverlay.EnableCodeQualityGates.Should().BeTrue(); + } + + [Fact] + public async Task ClassifyAsync_DocsQuery_ReturnsDocsWithOverlay() + { + var result = await _sut.ClassifyAsync("분석서 보고서를 작성해줘", "Cowork"); + + result.TaskType.Should().Be("docs"); + result.PolicyOverlay.Should().NotBeNull(); + result.PolicyOverlay!.EnableDocumentVerificationGate.Should().BeTrue(); + } + + [Fact] + public async Task ClassifyAsync_ReviewQuery_ReturnsReviewWithOverlay() + { + var result = await _sut.ClassifyAsync("이 코드를 review하고 검토해줘", "Cowork"); + + result.TaskType.Should().Be("review"); + result.PolicyOverlay.Should().NotBeNull(); + result.PolicyOverlay!.ToolTemperatureCap.Should().Be(0.3); + } + + [Fact] + public async Task ClassifyAsync_GeneralChatQuery_ReturnsNoOverlay() + { + var result = await _sut.ClassifyAsync("안녕하세요 오늘 날씨가 좋네요", "Chat"); + + result.TaskType.Should().Be("general"); + result.PolicyOverlay.Should().BeNull(); + } + + [Fact] + public async Task ClassifyAsync_ConfidenceRange() + { + var result = await _sut.ClassifyAsync("리팩터링 해줘", "Code"); + + result.Confidence.Should().BeInRange(0.0, 1.0); + } + + // ═══════════════════════════════════════════ + // Scope 분류 + // ═══════════════════════════════════════════ + + [Fact] + public async Task ClassifyAsync_DocCreation_SuggestsDirectCreation() + { + var result = await _sut.ClassifyAsync("보고서를 작성해줘", "Cowork"); + + result.SuggestedScope.Should().Be(ExplorationScope.DirectCreation); + } + + [Fact] + public async Task ClassifyAsync_RepoWideKeyword_SuggestsRepoWide() + { + var result = await _sut.ClassifyAsync("전체 코드베이스 전체 구조를 점검해줘", "Code"); + + result.SuggestedScope.Should().Be(ExplorationScope.RepoWide); + } + + [Fact] + public async Task ClassifyAsync_FilePathPresent_SuggestsLocalized() + { + var result = await _sut.ClassifyAsync("src/main.cs 파일의 버그를 수정해줘", "Code"); + + result.SuggestedScope.Should().Be(ExplorationScope.Localized); + } + + // ═══════════════════════════════════════════ + // 복합 요청 감지 (P5) + // ═══════════════════════════════════════════ + + [Fact] + public async Task ClassifyAsync_ComplexTask_DetectedCorrectly() + { + var result = await _sut.ClassifyAsync( + "먼저 코드를 분석해줘 그리고 버그를 수정해줘 그런 다음 테스트를 작성해줘", "Code"); + + result.IsComplexTask.Should().BeTrue(); + result.DecompositionHint.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ClassifyAsync_SimpleTask_NotComplex() + { + var result = await _sut.ClassifyAsync("버그 수정해줘", "Code"); + + result.IsComplexTask.Should().BeFalse(); + result.DecompositionHint.Should().BeNull(); + } + + // ═══════════════════════════════════════════ + // 안전 가드 + // ═══════════════════════════════════════════ + + [Fact] + public async Task ClassifyAsync_NullQuery_DoesNotThrow() + { + var result = await _sut.ClassifyAsync(null!, "Chat"); + + result.Should().NotBeNull(); + result.TaskType.Should().Be("general"); + } + + [Fact] + public async Task ClassifyAsync_OverlongQuery_Truncated() + { + var longQuery = new string('a', 100_000); + var result = await _sut.ClassifyAsync(longQuery, "Chat"); + + result.Should().NotBeNull(); + } + + [Fact] + public async Task ClassifyAsync_Cancellation_Throws() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => _sut.ClassifyAsync("hello", "Chat", cts.Token)); + } + + // ═══════════════════════════════════════════ + // ExecutionPolicyMerger + // ═══════════════════════════════════════════ + + [Fact] + public void ExecutionPolicyMerger_NullOverlay_ReturnsBase() + { + var basePolicy = CreateBasePolicy(); + var result = ExecutionPolicyMerger.Apply(basePolicy, null); + result.Should().BeSameAs(basePolicy); + } + + [Fact] + public void ExecutionPolicyMerger_AppliesOverlayFields() + { + var basePolicy = CreateBasePolicy(toolTemperatureCap: 0.5, forceInitialToolCall: false, enableCodeQualityGates: false); + + var overlay = new ExecutionPolicyOverlay( + ToolTemperatureCap: 0.2, + ForceInitialToolCall: true, + EnableCodeQualityGates: true); + + var result = ExecutionPolicyMerger.Apply(basePolicy, overlay); + + result.ToolTemperatureCap.Should().Be(0.2); + result.ForceInitialToolCall.Should().BeTrue(); + result.EnableCodeQualityGates.Should().BeTrue(); + } + + [Fact] + public void ExecutionPolicyMerger_NullFieldsPreserveBase() + { + var basePolicy = CreateBasePolicy(toolTemperatureCap: 0.5, maxParallelReadBatch: 10); + + // overlay에서 ToolTemperatureCap만 변경, 나머지는 null + var overlay = new ExecutionPolicyOverlay(ToolTemperatureCap: 0.1); + + var result = ExecutionPolicyMerger.Apply(basePolicy, overlay); + + result.ToolTemperatureCap.Should().Be(0.1); + result.MaxParallelReadBatch.Should().Be(10); // base 유지 + } + + private static ExecutionPolicy CreateBasePolicy( + double? toolTemperatureCap = 0.5, + bool forceInitialToolCall = false, + bool enableCodeQualityGates = false, + int maxParallelReadBatch = 4) + { + return new ExecutionPolicy( + Key: "test", + Label: "Test", + ForceInitialToolCall: forceInitialToolCall, + ForceToolCallAfterPlan: false, + ToolTemperatureCap: toolTemperatureCap, + NoToolResponseThreshold: 3, + NoToolRecoveryMaxRetries: 2, + PlanExecutionRetryMax: 2, + DocumentPlanRetryMax: 2, + PreferAggressiveDocumentFallback: false, + ReduceEarlyMemoryPressure: false, + EnablePostToolVerification: false, + EnableCodeQualityGates: enableCodeQualityGates, + EnableDocumentVerificationGate: false, + EnableParallelReadBatch: false, + MaxParallelReadBatch: maxParallelReadBatch, + CodeVerificationGateMaxRetries: 1, + HighImpactBuildTestGateMaxRetries: 1, + FinalReportGateMaxRetries: 1, + CodeDiffGateMaxRetries: 1, + RecentExecutionGateMaxRetries: 1, + ExecutionSuccessGateMaxRetries: 1, + DocumentVerificationGateMaxRetries: 1, + TerminalEvidenceGateMaxRetries: 1); + } +} diff --git a/src/AxCopilot.Tests/Services/ModelPromptAdapterTests.cs b/src/AxCopilot.Tests/Services/ModelPromptAdapterTests.cs new file mode 100644 index 0000000..de0ee9d --- /dev/null +++ b/src/AxCopilot.Tests/Services/ModelPromptAdapterTests.cs @@ -0,0 +1,241 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class ModelPromptAdapterTests +{ + private const string SampleBasePrompt = """ + You are AX Copilot, an intelligent assistant. + --- + Today's date: 2025-01-01 + Current work folder: C:\project + File permission: AcceptEdits + Active tab: Code + + ## Workspace Context + - Name: MyProject + - Build System: .NET + - Primary Language: C# + + ## Available Tools + Enabled: file_read, file_edit, grep, glob, build_run + Disabled: process, http_tool + """; + + // ═══════════════════════════════════════════ + // 모델 패밀리 감지 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("qwen2.5-72b-instruct", "qwen")] + [InlineData("Qwen/Qwen2-7B-Instruct", "qwen")] + [InlineData("deepseek-v3", "deepseek")] + [InlineData("deepseek-coder-33b", "deepseek")] + [InlineData("kimi-chat", "kimi")] + [InlineData("moonshot-v1-8k", "kimi")] + [InlineData("k1.5-chat", "kimi")] + [InlineData("gemma-2-27b-it", "gemma")] + [InlineData("llama-3.1-70b", "llama")] + [InlineData("codellama-34b", "llama")] + [InlineData("mistral-large-2407", "mistral")] + [InlineData("mixtral-8x22b", "mistral")] + [InlineData("yi-34b-chat", "yi")] + [InlineData("phi-3-medium", "phi")] + [InlineData("phi4", "phi")] + [InlineData("gemini-1.5-pro", "gemini")] + [InlineData("claude-3.5-sonnet", "claude")] + [InlineData("unknown-model-v1", "default")] + [InlineData(null, "default")] + [InlineData("", "default")] + public void DetectModelFamily_Correct(string? model, string expected) + { + ModelPromptAdapter.DetectModelFamily(model).Should().Be(expected); + } + + // ═══════════════════════════════════════════ + // 프롬프트 수준: off + // ═══════════════════════════════════════════ + + [Fact] + public void AdaptSystemPrompt_Off_ReturnsUnchanged() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "qwen", "off"); + result.Should().Be(SampleBasePrompt); + } + + [Fact] + public void AdaptSystemPrompt_DefaultFamily_ReturnsUnchanged() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "default", "detailed"); + result.Should().Be(SampleBasePrompt); + } + + // ═══════════════════════════════════════════ + // 프롬프트 수준: basic + // ═══════════════════════════════════════════ + + [Fact] + public void AdaptSystemPrompt_Basic_Qwen_AddsMustRules() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "qwen", "basic"); + result.Should().Contain("[MUST]"); + result.Should().Contain("[NEVER]"); + result.Should().Contain("REMINDER"); + } + + [Fact] + public void AdaptSystemPrompt_Basic_DeepSeek_AddsExecutionRules() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "deepseek", "basic"); + result.Should().Contain("DeepSeek Execution Rules"); + result.Should().Contain("spawn_agent"); + } + + [Fact] + public void AdaptSystemPrompt_Basic_Kimi_AddsStructuredFormat() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "kimi", "basic"); + result.Should().Contain("Kimi Execution Rules"); + result.Should().Contain("Evidence"); + result.Should().Contain("Impact"); + } + + [Fact] + public void AdaptSystemPrompt_Basic_Gemma_MinimalPrompt() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "gemma", "basic"); + result.Should().Contain("AX Copilot"); + result.Should().Contain("One tool per response"); + // Gemma는 원본 프롬프트를 압축하여 최소한의 규칙만 포함 (800 토큰 예산) + // 실제 프로덕션 프롬프트(3000+자)보다 훨씬 짧아지지만, 짧은 테스트 프롬프트에서는 비슷하거나 약간 길 수 있음 + result.Length.Should().BeLessThan(1500); + } + + // ═══════════════════════════════════════════ + // 프롬프트 수준: detailed (임베디드 리소스) + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("qwen")] + [InlineData("deepseek")] + [InlineData("kimi")] + [InlineData("gemma")] + [InlineData("llama")] + [InlineData("mistral")] + public void LoadDetailedPrompt_ReturnsNonNull(string family) + { + var prompt = ModelPromptAdapter.LoadDetailedPrompt(family); + prompt.Should().NotBeNullOrEmpty($"embedded resource for '{family}' should exist"); + } + + [Fact] + public void LoadDetailedPrompt_NonexistentFamily_ReturnsNull() + { + var prompt = ModelPromptAdapter.LoadDetailedPrompt("nonexistent_model_xyz"); + prompt.Should().BeNull(); + } + + [Fact] + public void AdaptSystemPrompt_Detailed_Qwen_UsesEmbeddedResource() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "qwen", "detailed"); + + // 상세 프롬프트에서 나오는 내용 + result.Should().Contain("Tool Calling Protocol"); + result.Should().Contain("Error Recovery"); + // 세션 컨텍스트에서 나오는 내용 + result.Should().Contain("Today's date"); + result.Should().Contain("Current work folder"); + } + + [Fact] + public void AdaptSystemPrompt_Detailed_DeepSeek_IncludesSessionContext() + { + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "deepseek", "detailed"); + + result.Should().Contain("Session Context"); + result.Should().Contain("C:\\project"); + result.Should().Contain("AcceptEdits"); + } + + [Fact] + public void AdaptSystemPrompt_Detailed_Kimi_LongerThanBasic() + { + var basic = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "kimi", "basic"); + var detailed = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "kimi", "detailed"); + + detailed.Length.Should().BeGreaterThan(basic.Length); + } + + [Fact] + public void AdaptSystemPrompt_Detailed_UnknownFamily_FallsBackToBasic() + { + // yi에는 상세 프롬프트 파일이 없으므로 basic 폴백 + var result = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "yi", "detailed"); + // basic의 default strategy = 변환 없음 + result.Should().Be(SampleBasePrompt); + } + + // ═══════════════════════════════════════════ + // ExecutionProfile 추천 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("qwen", "tool_call_strict")] + [InlineData("gemma", "tool_call_strict")] + [InlineData("phi", "tool_call_strict")] + [InlineData("deepseek", "balanced")] + [InlineData("kimi", "balanced")] + [InlineData("llama", "balanced")] + [InlineData("mistral", "reasoning_first")] + [InlineData("claude", "reasoning_first")] + [InlineData("gemini", "reasoning_first")] + [InlineData("default", "balanced")] + public void GetRecommendedExecutionProfile_Correct(string family, string expected) + { + ModelPromptAdapter.GetRecommendedExecutionProfile(family).Should().Be(expected); + } + + // ═══════════════════════════════════════════ + // 프롬프트 예산 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("qwen", 2000)] + [InlineData("gemma", 800)] + [InlineData("phi", 1000)] + [InlineData("kimi", 0)] + [InlineData("deepseek", 0)] + [InlineData("default", 0)] + public void GetPromptBudget_Correct(string family, int expected) + { + ModelPromptAdapter.GetPromptBudget(family).Should().Be(expected); + } + + // ═══════════════════════════════════════════ + // 기존 호환: 2-param overload + // ═══════════════════════════════════════════ + + [Fact] + public void AdaptSystemPrompt_TwoParam_UsesBasicLevel() + { + var twoParam = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "deepseek"); + var threeParam = ModelPromptAdapter.AdaptSystemPrompt(SampleBasePrompt, "deepseek", "basic"); + twoParam.Should().Be(threeParam); + } + + // ═══════════════════════════════════════════ + // 패밀리 라벨 + // ═══════════════════════════════════════════ + + [Fact] + public void GetFamilyLabel_AllFamilies() + { + ModelPromptAdapter.GetFamilyLabel("qwen").Should().Be("Qwen"); + ModelPromptAdapter.GetFamilyLabel("deepseek").Should().Be("DeepSeek"); + ModelPromptAdapter.GetFamilyLabel("kimi").Should().Be("Kimi/Moonshot"); + ModelPromptAdapter.GetFamilyLabel("default").Should().Be("기본"); + } +} diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index 7c96295..ff4d350 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -160,7 +160,7 @@ public class OperationModePolicyTests } }; - context.GetEffectiveToolPermission("process", "git status").Should().Be("DontAsk"); + context.GetEffectiveToolPermission("process", "git status").Should().Be("BypassPermissions"); context.GetEffectiveToolPermission("process", "git push origin main").Should().Be("Deny"); } diff --git a/src/AxCopilot.Tests/Services/PermissionModeCatalogTests.cs b/src/AxCopilot.Tests/Services/PermissionModeCatalogTests.cs index ae0e692..fd59937 100644 --- a/src/AxCopilot.Tests/Services/PermissionModeCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/PermissionModeCatalogTests.cs @@ -17,8 +17,8 @@ public class PermissionModeCatalogTests [InlineData("plan", PermissionModeCatalog.Plan)] [InlineData("bypass", PermissionModeCatalog.BypassPermissions)] [InlineData("fullauto", PermissionModeCatalog.BypassPermissions)] - [InlineData("dontask", PermissionModeCatalog.DontAsk)] - [InlineData("silent", PermissionModeCatalog.DontAsk)] + [InlineData("dontask", PermissionModeCatalog.BypassPermissions)] + [InlineData("silent", PermissionModeCatalog.BypassPermissions)] [InlineData("none", PermissionModeCatalog.Deny)] [InlineData("disabled", PermissionModeCatalog.Deny)] [InlineData("deny", PermissionModeCatalog.Deny)] @@ -33,7 +33,7 @@ public class PermissionModeCatalogTests [InlineData("auto", PermissionModeCatalog.AcceptEdits)] [InlineData("plan", PermissionModeCatalog.Plan)] [InlineData("bypass", PermissionModeCatalog.BypassPermissions)] - [InlineData("dontask", PermissionModeCatalog.DontAsk)] + [InlineData("dontask", PermissionModeCatalog.BypassPermissions)] [InlineData("deny", PermissionModeCatalog.Deny)] [InlineData("unknown", PermissionModeCatalog.Default)] public void NormalizeToolOverride_ShouldMapExpectedModes(string? input, string expected) @@ -54,12 +54,12 @@ public class PermissionModeCatalogTests } [Theory] - [InlineData(PermissionModeCatalog.Deny, "활용하지 않음")] - [InlineData(PermissionModeCatalog.Default, "소극 활용")] - [InlineData(PermissionModeCatalog.AcceptEdits, "적극 활용")] - [InlineData(PermissionModeCatalog.Plan, "계획 중심")] - [InlineData(PermissionModeCatalog.BypassPermissions, "완전 자동")] - [InlineData(PermissionModeCatalog.DontAsk, "질문 없이 진행")] + [InlineData(PermissionModeCatalog.Deny, "읽기 전용")] + [InlineData(PermissionModeCatalog.Default, "권한 요청")] + [InlineData(PermissionModeCatalog.AcceptEdits, "편집 자동 승인")] + [InlineData(PermissionModeCatalog.Plan, "계획 모드")] + [InlineData(PermissionModeCatalog.BypassPermissions, "권한 건너뛰기")] + [InlineData(PermissionModeCatalog.DontAsk, "권한 건너뛰기")] public void ToDisplayLabel_ShouldReturnKoreanLabel(string mode, string expected) { PermissionModeCatalog.ToDisplayLabel(mode).Should().Be(expected); diff --git a/src/AxCopilot.Tests/Services/PermissionModePresentationCatalogTests.cs b/src/AxCopilot.Tests/Services/PermissionModePresentationCatalogTests.cs index 01df2f5..461c25c 100644 --- a/src/AxCopilot.Tests/Services/PermissionModePresentationCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/PermissionModePresentationCatalogTests.cs @@ -11,12 +11,9 @@ public class PermissionModePresentationCatalogTests { PermissionModePresentationCatalog.Ordered.Select(x => x.Mode).Should().ContainInOrder( [ - PermissionModeCatalog.Deny, PermissionModeCatalog.Default, PermissionModeCatalog.AcceptEdits, - PermissionModeCatalog.Plan, PermissionModeCatalog.BypassPermissions, - PermissionModeCatalog.DontAsk, ]); } diff --git a/src/AxCopilot.Tests/Services/SessionLearningCollectorTests.cs b/src/AxCopilot.Tests/Services/SessionLearningCollectorTests.cs new file mode 100644 index 0000000..6fafb8e --- /dev/null +++ b/src/AxCopilot.Tests/Services/SessionLearningCollectorTests.cs @@ -0,0 +1,242 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class SessionLearningCollectorTests +{ + // ═══════════════════════════════════════════ + // 기본 동작 + // ═══════════════════════════════════════════ + + [Fact] + public void Empty_BuildInjectionMessage_ReturnsNull() + { + var collector = new SessionLearningCollector(); + collector.BuildInjectionMessage().Should().BeNull(); + } + + [Fact] + public void Count_StartsAtZero() + { + var collector = new SessionLearningCollector(); + collector.Count.Should().Be(0); + } + + [Fact] + public void Clear_ResetsCount() + { + var collector = new SessionLearningCollector(); + collector.TryExtract("build_run", "error CS1234: Something failed\nMSBuild error", false); + collector.Count.Should().BeGreaterThan(0); + + collector.Clear(); + collector.Count.Should().Be(0); + collector.BuildInjectionMessage().Should().BeNull(); + } + + // ═══════════════════════════════════════════ + // 빌드/테스트 실패 추출 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_BuildRunFailure_ExtractsBuildConfig() + { + var collector = new SessionLearningCollector(); + var output = """ + Build FAILED. + error CS0246: The type or namespace name 'Foo' could not be found + TargetFramework: net8.0-windows10.0.17763 + MSBuild version 17.8.0 + """; + + collector.TryExtract("build_run", output, success: false); + collector.Count.Should().Be(1); + + var msg = collector.BuildInjectionMessage(); + msg.Should().NotBeNull(); + msg.Should().Contain("[build_config]"); + msg.Should().Contain("CS0246"); + } + + [Fact] + public void TryExtract_TestLoopFailure_ExtractsBuildConfig() + { + var collector = new SessionLearningCollector(); + collector.TryExtract("test_loop", "error TS2304: Cannot find name 'x'", success: false); + collector.Count.Should().Be(1); + } + + // ═══════════════════════════════════════════ + // grep/glob 결과 추출 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_GrepSuccess_ExtractsCodeLocation() + { + var collector = new SessionLearningCollector(); + var output = """ + src/Services/Agent/IntentGateService.cs:10: something + src/Services/Agent/SessionLearning.cs:5: another thing + src/Services/Agent/SubAgentProfile.cs:20: more + """; + + collector.TryExtract("grep", output, success: true); + collector.Count.Should().Be(1); + + var msg = collector.BuildInjectionMessage()!; + msg.Should().Contain("[code_location]"); + msg.Should().Contain("Services/Agent"); + } + + [Fact] + public void TryExtract_GrepSingleFile_NoLearning() + { + var collector = new SessionLearningCollector(); + // 단일 파일 경로만 있으면 학습 가치 없음 + collector.TryExtract("grep", "src/file.cs:10: something", success: true); + collector.Count.Should().Be(0); + } + + // ═══════════════════════════════════════════ + // 프로젝트 메타 파일 추출 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_CsprojRead_ExtractsProjectStructure() + { + var collector = new SessionLearningCollector(); + var output = """ + + + net8.0 + + + + + + + """; + + collector.TryExtract("file_read", output, success: true); + collector.Count.Should().Be(1); + + var msg = collector.BuildInjectionMessage()!; + msg.Should().Contain("[project_structure]"); + msg.Should().Contain("net8.0"); + msg.Should().Contain("xunit"); + } + + // ═══════════════════════════════════════════ + // 런타임 감지 추출 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_DevEnvDetect_ExtractsDependency() + { + var collector = new SessionLearningCollector(); + var output = """ + .NET SDK version: 8.0.300 + runtime: Microsoft.NETCore.App 8.0.5 + node version: v20.10.0 + """; + + collector.TryExtract("dev_env_detect", output, success: true); + collector.Count.Should().Be(1); + + var msg = collector.BuildInjectionMessage()!; + msg.Should().Contain("[dependency]"); + } + + // ═══════════════════════════════════════════ + // 파일 조작 에러 추출 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_FileWriteFailure_ExtractsErrorPattern() + { + var collector = new SessionLearningCollector(); + collector.TryExtract("file_write", "Access denied: C:/readonly/file.cs", success: false); + collector.Count.Should().Be(1); + + var msg = collector.BuildInjectionMessage()!; + msg.Should().Contain("[error_pattern]"); + msg.Should().Contain("file_write"); + } + + // ═══════════════════════════════════════════ + // FIFO 관리 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_FifoEviction_MaintainsMaxLimit() + { + var collector = new SessionLearningCollector(maxLearnings: 3); + + for (int i = 0; i < 5; i++) + { + collector.TryExtract("file_write", $"Error {i}: unique error message number {i}", success: false); + } + + collector.Count.Should().BeLessOrEqualTo(3); + } + + // ═══════════════════════════════════════════ + // 중복 방지 + // ═══════════════════════════════════════════ + + [Fact] + public void TryExtract_DuplicateContent_NotAdded() + { + var collector = new SessionLearningCollector(); + var output = "Access denied: C:/readonly/file.cs"; + + collector.TryExtract("file_write", output, success: false); + collector.TryExtract("file_write", output, success: false); + + collector.Count.Should().Be(1); + } + + // ═══════════════════════════════════════════ + // BuildInjectionMessage 포맷 + // ═══════════════════════════════════════════ + + [Fact] + public void BuildInjectionMessage_ContainsHeader() + { + var collector = new SessionLearningCollector(); + collector.TryExtract("file_write", "Error: something failed", success: false); + + var msg = collector.BuildInjectionMessage()!; + msg.Should().StartWith("[System:SessionLearnings]"); + msg.Should().Contain("위 내용을 참고하여 동일 실수를 반복하지 마세요"); + } + + // ═══════════════════════════════════════════ + // 안전 가드 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData(null, "output")] + [InlineData("build_run", null)] + [InlineData("", "output")] + [InlineData("build_run", "")] + [InlineData(" ", "output")] + public void TryExtract_NullOrEmptyArgs_DoesNotThrow(string? toolName, string? output) + { + var collector = new SessionLearningCollector(); + collector.TryExtract(toolName!, output!, false); + // No exception = pass + } + + [Fact] + public void TryExtract_LargeOutput_TruncatesWithoutCrash() + { + var collector = new SessionLearningCollector(); + // 50KB의 큰 출력 + var largeOutput = "error CS0001: Big error\n" + new string('x', 60_000); + collector.TryExtract("build_run", largeOutput, success: false); + // 크래시 없이 완료 = 성공 + } +} diff --git a/src/AxCopilot.Tests/Services/SpawnAgentsToolTests.cs b/src/AxCopilot.Tests/Services/SpawnAgentsToolTests.cs new file mode 100644 index 0000000..b34b21d --- /dev/null +++ b/src/AxCopilot.Tests/Services/SpawnAgentsToolTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class SpawnAgentsToolTests +{ + // ═══════════════════════════════════════════ + // 도구 메타데이터 + // ═══════════════════════════════════════════ + + [Fact] + public void Name_IsSpawnAgents() + { + var tool = new SpawnAgentsTool(); + tool.Name.Should().Be("spawn_agents"); + } + + [Fact] + public void Description_IsNonEmpty() + { + var tool = new SpawnAgentsTool(); + tool.Description.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Parameters_HasAgentsArray() + { + var tool = new SpawnAgentsTool(); + tool.Parameters.Properties.Should().ContainKey("agents"); + tool.Parameters.Required.Should().Contain("agents"); + } + + // ═══════════════════════════════════════════ + // 입력 검증 + // ═══════════════════════════════════════════ + + [Fact] + public async Task ExecuteAsync_MissingAgents_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse("{}").RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("agents"); + } + + [Fact] + public async Task ExecuteAsync_EmptyAgentsArray_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse("""{"agents": []}""").RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("empty"); + } + + [Fact] + public async Task ExecuteAsync_MissingId_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse("""{"agents": [{"task": "do something"}]}""").RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_MissingTask_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse("""{"agents": [{"id": "a1"}]}""").RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteAsync_DuplicateIds_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse(""" + { + "agents": [ + {"id": "a1", "task": "task1"}, + {"id": "a1", "task": "task2"} + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + result.Output.Should().Contain("Duplicate"); + } + + [Fact] + public async Task ExecuteAsync_AgentsNotArray_ReturnsFail() + { + var tool = new SpawnAgentsTool(); + var json = JsonDocument.Parse("""{"agents": "not an array"}""").RootElement; + + var result = await tool.ExecuteAsync(json, CreateMinimalContext()); + result.Success.Should().BeFalse(); + } + + // ═══════════════════════════════════════════ + // 서브에이전트 재귀 차단 검증 + // ═══════════════════════════════════════════ + + [Fact] + public void AllSubAgentProfiles_DisableSpawnAgents() + { + // spawn_agents는 모든 서브에이전트 프로파일에서 비활성화되어야 함 (재귀 방지) + foreach (var name in SubAgentProfileCatalog.AllProfileNames) + { + var profile = SubAgentProfileCatalog.Get(name); + profile.DisabledToolNames.Should().Contain("spawn_agents", + $"profile '{name}' should disable spawn_agents to prevent recursion"); + } + } + + // ═══════════════════════════════════════════ + // 유틸 + // ═══════════════════════════════════════════ + + private static AgentContext CreateMinimalContext() + { + return new AgentContext + { + WorkFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + }; + } +} diff --git a/src/AxCopilot.Tests/Services/SubAgentProfileTests.cs b/src/AxCopilot.Tests/Services/SubAgentProfileTests.cs new file mode 100644 index 0000000..a8e2466 --- /dev/null +++ b/src/AxCopilot.Tests/Services/SubAgentProfileTests.cs @@ -0,0 +1,227 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class SubAgentProfileTests +{ + // ═══════════════════════════════════════════ + // 기본 프로파일 조회 + // ═══════════════════════════════════════════ + + [Fact] + public void Get_Null_ReturnsResearcher() + { + var profile = SubAgentProfileCatalog.Get(null); + profile.Name.Should().Be("researcher"); + } + + [Fact] + public void Get_EmptyString_ReturnsResearcher() + { + var profile = SubAgentProfileCatalog.Get(""); + profile.Name.Should().Be("researcher"); + } + + [Fact] + public void Get_UnknownName_ReturnsResearcher() + { + var profile = SubAgentProfileCatalog.Get("unknown_profile"); + profile.Name.Should().Be("researcher"); + } + + [Theory] + [InlineData("researcher")] + [InlineData("coder")] + [InlineData("writer")] + [InlineData("reviewer")] + [InlineData("planner")] + public void Get_AllProfiles_ReturnCorrectName(string name) + { + var profile = SubAgentProfileCatalog.Get(name); + profile.Name.Should().Be(name); + } + + [Fact] + public void Get_CaseInsensitive() + { + var profile = SubAgentProfileCatalog.Get("CODER"); + profile.Name.Should().Be("coder"); + } + + [Fact] + public void Get_TrimWhitespace() + { + var profile = SubAgentProfileCatalog.Get(" writer "); + profile.Name.Should().Be("writer"); + } + + // ═══════════════════════════════════════════ + // Researcher 프로파일 + // ═══════════════════════════════════════════ + + [Fact] + public void Researcher_IsReadOnly() + { + var profile = SubAgentProfileCatalog.Get("researcher"); + profile.FilePermission.Should().Be("Deny"); + profile.EnabledToolNames.Should().NotContain("file_write"); + profile.EnabledToolNames.Should().NotContain("file_edit"); + } + + [Fact] + public void Researcher_HasNoTemperatureOverride() + { + var profile = SubAgentProfileCatalog.Get("researcher"); + profile.TemperatureOverride.Should().BeNull(); + } + + // ═══════════════════════════════════════════ + // Coder 프로파일 + // ═══════════════════════════════════════════ + + [Fact] + public void Coder_CanEditFiles() + { + var profile = SubAgentProfileCatalog.Get("coder"); + profile.FilePermission.Should().Be("AcceptEdits"); + profile.EnabledToolNames.Should().Contain("file_write"); + profile.EnabledToolNames.Should().Contain("file_edit"); + profile.EnabledToolNames.Should().Contain("build_run"); + } + + [Fact] + public void Coder_HasLowTemperature() + { + var profile = SubAgentProfileCatalog.Get("coder"); + profile.TemperatureOverride.Should().Be(0.2); + } + + // ═══════════════════════════════════════════ + // Writer 프로파일 + // ═══════════════════════════════════════════ + + [Fact] + public void Writer_CanCreateDocuments() + { + var profile = SubAgentProfileCatalog.Get("writer"); + profile.FilePermission.Should().Be("AcceptEdits"); + profile.EnabledToolNames.Should().Contain("html_create"); + profile.EnabledToolNames.Should().Contain("docx_create"); + profile.EnabledToolNames.Should().Contain("file_write"); + } + + [Fact] + public void Writer_HasMediumTemperature() + { + var profile = SubAgentProfileCatalog.Get("writer"); + profile.TemperatureOverride.Should().Be(0.35); + } + + // ═══════════════════════════════════════════ + // Reviewer 프로파일 + // ═══════════════════════════════════════════ + + [Fact] + public void Reviewer_IsReadOnly() + { + var profile = SubAgentProfileCatalog.Get("reviewer"); + profile.FilePermission.Should().Be("Deny"); + profile.DisabledToolNames.Should().Contain("file_write"); + profile.DisabledToolNames.Should().Contain("file_edit"); + } + + [Fact] + public void Reviewer_HasCodeReviewTools() + { + var profile = SubAgentProfileCatalog.Get("reviewer"); + profile.EnabledToolNames.Should().Contain("code_review"); + profile.EnabledToolNames.Should().Contain("document_review"); + } + + // ═══════════════════════════════════════════ + // Planner 프로파일 + // ═══════════════════════════════════════════ + + [Fact] + public void Planner_IsReadOnly() + { + var profile = SubAgentProfileCatalog.Get("planner"); + profile.FilePermission.Should().Be("Deny"); + } + + [Fact] + public void Planner_HasMinimalToolSet() + { + var profile = SubAgentProfileCatalog.Get("planner"); + profile.EnabledToolNames.Should().Contain("folder_map"); + profile.EnabledToolNames.Should().Contain("glob"); + profile.EnabledToolNames.Should().Contain("grep"); + profile.EnabledToolNames.Should().Contain("file_read"); + // 편집 도구 없음 + profile.EnabledToolNames.Should().NotContain("file_write"); + profile.EnabledToolNames.Should().NotContain("file_edit"); + } + + // ═══════════════════════════════════════════ + // 재귀 방지 — 모든 프로파일에서 spawn 도구 비활성 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("researcher")] + [InlineData("coder")] + [InlineData("writer")] + [InlineData("reviewer")] + [InlineData("planner")] + public void AllProfiles_DisableSpawnTools(string name) + { + var profile = SubAgentProfileCatalog.Get(name); + profile.DisabledToolNames.Should().Contain("spawn_agent"); + profile.DisabledToolNames.Should().Contain("spawn_agents"); + } + + // ═══════════════════════════════════════════ + // AllProfileNames 카탈로그 + // ═══════════════════════════════════════════ + + [Fact] + public void AllProfileNames_Contains5Profiles() + { + SubAgentProfileCatalog.AllProfileNames.Should().HaveCount(5); + SubAgentProfileCatalog.AllProfileNames.Should().Contain("researcher"); + SubAgentProfileCatalog.AllProfileNames.Should().Contain("coder"); + SubAgentProfileCatalog.AllProfileNames.Should().Contain("writer"); + SubAgentProfileCatalog.AllProfileNames.Should().Contain("reviewer"); + SubAgentProfileCatalog.AllProfileNames.Should().Contain("planner"); + } + + // ═══════════════════════════════════════════ + // SystemPromptPrefix 유효성 + // ═══════════════════════════════════════════ + + [Theory] + [InlineData("researcher")] + [InlineData("coder")] + [InlineData("writer")] + [InlineData("reviewer")] + [InlineData("planner")] + public void AllProfiles_HaveNonEmptySystemPrompt(string name) + { + var profile = SubAgentProfileCatalog.Get(name); + profile.SystemPromptPrefix.Should().NotBeNullOrWhiteSpace(); + profile.SystemPromptPrefix.Should().Contain("sub-agent"); + } + + [Theory] + [InlineData("researcher")] + [InlineData("coder")] + [InlineData("writer")] + [InlineData("reviewer")] + [InlineData("planner")] + public void AllProfiles_SystemPromptContainsNoUserQuestions(string name) + { + var profile = SubAgentProfileCatalog.Get(name); + profile.SystemPromptPrefix.Should().Contain("Do not ask the user questions"); + } +} diff --git a/src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs b/src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs index 23f636a..855e07e 100644 --- a/src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs @@ -13,9 +13,9 @@ public class TaskTypePolicyTests policy.TaskType.Should().Be("bugfix"); policy.GuidanceMessage.Should().Contain("bug-fix"); - policy.FailurePatternFocus.Should().Contain("재현 조건"); - policy.FollowUpTaskLine.Should().Contain("작업 유형: bugfix"); - policy.FinalReportTaskLine.Should().Contain("버그 수정"); + policy.FailurePatternFocus.Should().Contain("reproduction"); + policy.FollowUpTaskLine.Should().Contain("Task type: bugfix"); + policy.FinalReportTaskLine.Should().Contain("bug fix"); policy.IsReviewTask.Should().BeFalse(); } @@ -27,7 +27,7 @@ public class TaskTypePolicyTests policy.TaskType.Should().Be("review"); policy.IsReviewTask.Should().BeTrue(); policy.GuidanceMessage.Should().Contain("review task"); - policy.FailureInvestigationTaskLine.Should().Contain("리뷰에서 지적된 위험"); + policy.FailureInvestigationTaskLine.Should().Contain("every risk must have"); } [Fact] diff --git a/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs b/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs new file mode 100644 index 0000000..96cc8c1 --- /dev/null +++ b/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs @@ -0,0 +1,289 @@ +using System.IO; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class WorkspaceContextGeneratorTests +{ + // ═══════════════════════════════════════════ + // LoadContext + // ═══════════════════════════════════════════ + + [Fact] + public void LoadContext_NullFolder_ReturnsNull() + { + WorkspaceContextGenerator.LoadContext(null).Should().BeNull(); + } + + [Fact] + public void LoadContext_EmptyFolder_ReturnsNull() + { + WorkspaceContextGenerator.LoadContext("").Should().BeNull(); + } + + [Fact] + public void LoadContext_NonExistentFile_ReturnsNull() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // .ax-context.md가 없으면 null + WorkspaceContextGenerator.LoadContext(tempDir).Should().BeNull(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void LoadContext_ExistingFile_ReturnsContent() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var contextPath = Path.Combine(tempDir, ".ax-context.md"); + File.WriteAllText(contextPath, "# Test Context\nHello"); + + var result = WorkspaceContextGenerator.LoadContext(tempDir); + result.Should().NotBeNull(); + result.Should().Contain("Test Context"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void LoadContext_LargeFile_Truncated() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var contextPath = Path.Combine(tempDir, ".ax-context.md"); + // 4000자 제한 초과하는 내용 작성 + File.WriteAllText(contextPath, new string('A', 5000)); + + var result = WorkspaceContextGenerator.LoadContext(tempDir); + result.Should().NotBeNull(); + result!.Should().Contain("(truncated)"); + result.Length.Should().BeLessOrEqualTo(4100); // 4000 + truncation message + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + // ═══════════════════════════════════════════ + // EnsureContextAsync — 멱등성 + // ═══════════════════════════════════════════ + + [Fact] + public async Task EnsureContextAsync_NullFolder_ReturnsNull() + { + var result = await WorkspaceContextGenerator.EnsureContextAsync(null!); + result.Should().BeNull(); + } + + [Fact] + public async Task EnsureContextAsync_NonExistentFolder_ReturnsNull() + { + var result = await WorkspaceContextGenerator.EnsureContextAsync("/nonexistent/path/xyz"); + result.Should().BeNull(); + } + + [Fact] + public async Task EnsureContextAsync_ExistingContextFile_ReturnsWithoutRegeneration() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var contextPath = Path.Combine(tempDir, ".ax-context.md"); + File.WriteAllText(contextPath, "# Pre-existing context"); + + var result = await WorkspaceContextGenerator.EnsureContextAsync(tempDir); + result.Should().Contain("Pre-existing context"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + // ═══════════════════════════════════════════ + // GenerateAsync — 실제 생성 + // ═══════════════════════════════════════════ + + [Fact] + public async Task GenerateAsync_CreatesContextFile() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // 테스트 파일 구조 생성 + File.WriteAllText(Path.Combine(tempDir, "test.cs"), "class Test {}"); + File.WriteAllText(Path.Combine(tempDir, "helper.cs"), "class Helper {}"); + var subDir = Path.Combine(tempDir, "src"); + Directory.CreateDirectory(subDir); + File.WriteAllText(Path.Combine(subDir, "main.cs"), "class Main {}"); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + + result.Should().NotBeNullOrEmpty(); + result.Should().Contain("# Workspace Context (auto-generated)"); + result.Should().Contain("## Project"); + result.Should().Contain("## Structure"); + result.Should().Contain(".cs"); + + // 파일 생성 확인 + File.Exists(Path.Combine(tempDir, ".ax-context.md")).Should().BeTrue(); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GenerateAsync_DetectsDotNetBuildSystem() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "MyProject.sln"), "solution content"); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + result.Should().Contain(".NET"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GenerateAsync_DetectsNodeJsBuildSystem() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "package.json"), """{"name": "test"}"""); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + result.Should().Contain("Node.js"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GenerateAsync_IncludesReadmeSummary() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "README.md"), + "# My Project\n\nThis is a test project for unit testing."); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + result.Should().Contain("## README Summary"); + result.Should().Contain("test project"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GenerateAsync_DetectsContextFiles() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "AGENTS.md"), "Agent rules here"); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + result.Should().Contain("## Existing Context Files"); + result.Should().Contain("AGENTS.md"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GenerateAsync_SkipsHiddenDirs() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // node_modules는 건너뛰어야 함 + var nodeModules = Path.Combine(tempDir, "node_modules"); + Directory.CreateDirectory(nodeModules); + File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}"); + + // src는 표시되어야 함 + var src = Path.Combine(tempDir, "src"); + Directory.CreateDirectory(src); + File.WriteAllText(Path.Combine(src, "main.cs"), "class Main {}"); + + var result = await WorkspaceContextGenerator.GenerateAsync(tempDir); + result.Should().Contain("src/"); + result.Should().NotContain("node_modules/"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + // ═══════════════════════════════════════════ + // 취소 지원 + // ═══════════════════════════════════════════ + + [Fact] + public async Task EnsureContextAsync_Cancellation_Throws() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // 이미 취소된 토큰으로 호출 — 파일이 없으므로 GenerateAsync 진입 + // OperationCanceledException 또는 정상 반환 (구현에 따라) + // 최소한 크래시하지 않으면 OK + try + { + await WorkspaceContextGenerator.EnsureContextAsync(tempDir, cts.Token); + } + catch (OperationCanceledException) + { + // 예상된 동작 + } + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs index 5206538..0579406 100644 --- a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -12,8 +12,8 @@ public class ChatWindowSlashPolicyTests [InlineData("Default", "AcceptEdits")] [InlineData("AcceptEdits", "Plan")] [InlineData("Plan", "BypassPermissions")] - [InlineData("BypassPermissions", "Deny")] - [InlineData("DontAsk", "Deny")] + [InlineData("BypassPermissions", "Default")] + [InlineData("DontAsk", "Default")] public void NextPermission_ShouldCycleCoreModesAndReturnToDeny(string current, string expected) { var method = typeof(ChatWindow).GetMethod("NextPermission", BindingFlags.NonPublic | BindingFlags.Static); diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index f05c2a8..ecafa41 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -378,87 +378,72 @@ public partial class App : System.Windows.Application var launcherSettings = _settings?.Settings.Launcher; var enableTextAction = launcherSettings?.EnableTextAction == true; - // ── 런처 토글(닫기)은 텍스트 감지 없이 즉시 처리 ── - bool isVisible = false; - Dispatcher.Invoke(() => { isVisible = _launcher?.IsVisible == true; }); - if (isVisible) - { - Dispatcher.Invoke(() => _launcher?.Hide()); - return; - } + // ── 텍스트 감지 활성: 런처를 먼저 열고, 텍스트 감지를 비동기로 처리 ── + string? selectedText = enableTextAction ? TryGetSelectedText() : null; - // ── 텍스트 감지가 비활성이면 즉시 런처 표시 ── - if (!enableTextAction) + // BeginInvoke 사용 — Dispatcher.Invoke 데드락 방지 + Dispatcher.BeginInvoke(() => { - Dispatcher.Invoke(() => + // 런처 토글(닫기) + if (_launcher?.IsVisible == true) + { + _launcher.Hide(); + return; + } + + // 텍스트 감지 비활성 또는 선택 텍스트 없음 + if (!enableTextAction || string.IsNullOrWhiteSpace(selectedText)) { if (_launcher == null) return; UsageStatisticsService.RecordLauncherOpen(); ShowLauncherWindow(); - }); - return; - } + return; + } + // 텍스트 감지 활성 + 선택 텍스트 있음 → 텍스트 액션 처리 + var enabledCmds = launcherSettings?.TextActionCommands ?? new(); - // ── 텍스트 감지 활성: 런처를 먼저 열고, 텍스트 감지를 비동기로 처리 ── - string? selectedText = TryGetSelectedText(); - - Dispatcher.Invoke(() => - { - if (_launcher == null) return; - - if (!string.IsNullOrWhiteSpace(selectedText)) + // 활성 명령이 1개뿐이면 팝업 없이 바로 실행 + if (enabledCmds.Count == 1) { - var enabledCmds = launcherSettings?.TextActionCommands ?? new(); - - // 활성 명령이 1개뿐이면 팝업 없이 바로 실행 - if (enabledCmds.Count == 1) + var directAction = TextActionPopup.AvailableCommands + .FirstOrDefault(c => c.Key == enabledCmds[0]); + if (!string.IsNullOrEmpty(directAction.Key)) { - var directAction = TextActionPopup.AvailableCommands - .FirstOrDefault(c => c.Key == enabledCmds[0]); - if (!string.IsNullOrEmpty(directAction.Key)) + var actionResult = enabledCmds[0] switch { - var actionResult = enabledCmds[0] switch - { - "translate" => TextActionPopup.ActionResult.Translate, - "summarize" => TextActionPopup.ActionResult.Summarize, - "grammar" => TextActionPopup.ActionResult.GrammarFix, - "explain" => TextActionPopup.ActionResult.Explain, - "rewrite" => TextActionPopup.ActionResult.Rewrite, - _ => TextActionPopup.ActionResult.None, - }; - if (actionResult != TextActionPopup.ActionResult.None) - { - ExecuteTextAction(actionResult, selectedText); - return; - } + "translate" => TextActionPopup.ActionResult.Translate, + "summarize" => TextActionPopup.ActionResult.Summarize, + "grammar" => TextActionPopup.ActionResult.GrammarFix, + "explain" => TextActionPopup.ActionResult.Explain, + "rewrite" => TextActionPopup.ActionResult.Rewrite, + _ => TextActionPopup.ActionResult.None, + }; + if (actionResult != TextActionPopup.ActionResult.None) + { + ExecuteTextAction(actionResult, selectedText); + return; } } + } - // 여러 개 → 팝업 표시 - var popup = new TextActionPopup(selectedText, enabledCmds); - popup.Closed += (_, _) => - { - switch (popup.SelectedAction) - { - case TextActionPopup.ActionResult.OpenLauncher: - UsageStatisticsService.RecordLauncherOpen(); - ShowLauncherWindow(); - break; - case TextActionPopup.ActionResult.None: - break; // Esc 또는 포커스 잃음 - default: - // AI 명령 실행 → AX Agent 대화로 전달 - ExecuteTextAction(popup.SelectedAction, popup.SelectedText); - break; - } - }; - popup.Show(); - } - else + // 여러 개 → 팝업 표시 + var popup = new TextActionPopup(selectedText, enabledCmds); + popup.Closed += (_, _) => { - UsageStatisticsService.RecordLauncherOpen(); - ShowLauncherWindow(); - } + switch (popup.SelectedAction) + { + case TextActionPopup.ActionResult.OpenLauncher: + UsageStatisticsService.RecordLauncherOpen(); + ShowLauncherWindow(); + break; + case TextActionPopup.ActionResult.None: + break; + default: + ExecuteTextAction(popup.SelectedAction, popup.SelectedText); + break; + } + }; + popup.Show(); }); } @@ -600,9 +585,9 @@ public partial class App : System.Windows.Application _trayMenu .AddHeader(versionText) .AddItem("\uE7C5", "AX Commander 호출하기", () => - Dispatcher.Invoke(ShowLauncherWindow)) + Dispatcher.Invoke(ShowLauncherWindow), hint: "double-click") .AddItem("\uE8BD", "AX Agent 대화하기", () => - Dispatcher.Invoke(OpenAiChat), out var aiTrayItem) + Dispatcher.Invoke(OpenAiChat), out var aiTrayItem, hint: "click") .AddItem("\uE8A7", "독 바 표시", () => Dispatcher.Invoke(() => ToggleDockBar())) .AddSeparator() @@ -649,21 +634,50 @@ public partial class App : System.Windows.Application : System.Windows.Visibility.Collapsed; }; - // 우클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글 + // 좌클릭 → 대화창, 더블클릭 → 런처, 우클릭 → 메뉴 + // MouseClick은 버튼 정보를 제공하므로 Click 대신 사용 — Click은 모든 버튼에 발생하여 우클릭 메뉴와 충돌 + System.Windows.Forms.Timer? _trayClickTimer = null; _trayIcon.MouseClick += (_, e) => { - if (e.Button == System.Windows.Forms.MouseButtons.Left) + if (e.Button == System.Windows.Forms.MouseButtons.Right) { - Dispatcher.Invoke(() => + // 우클릭: 컨텍스트 메뉴 표시 (싱글클릭 타이머 간섭 방지) + _trayClickTimer?.Stop(); + _trayClickTimer?.Dispose(); + _trayClickTimer = null; + Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate()); + return; + } + if (e.Button != System.Windows.Forms.MouseButtons.Left) return; + // 싱글/더블 클릭 구분: WinForms NotifyIcon은 DoubleClick 시에도 Click을 먼저 발생시키므로 + // 타이머를 사용하여 더블클릭 대기 후 싱글 클릭 액션을 실행 + _trayClickTimer?.Stop(); + _trayClickTimer?.Dispose(); + _trayClickTimer = new System.Windows.Forms.Timer { Interval = SystemInformation.DoubleClickTime }; + _trayClickTimer.Tick += (s, _) => + { + _trayClickTimer?.Stop(); + _trayClickTimer?.Dispose(); + _trayClickTimer = null; + // 싱글 클릭: 대화창 열기 + Dispatcher.BeginInvoke(() => { if (settings.Settings.AiEnabled) OpenAiChat(); else ShowLauncherWindow(); }); - } - else if (e.Button == System.Windows.Forms.MouseButtons.Right) - Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate()); + }; + _trayClickTimer.Start(); + }; + _trayIcon.MouseDoubleClick += (_, e) => + { + if (e.Button != System.Windows.Forms.MouseButtons.Left) return; + // 싱글 클릭 타이머 취소 → 런처만 열림 + _trayClickTimer?.Stop(); + _trayClickTimer?.Dispose(); + _trayClickTimer = null; + Dispatcher.BeginInvoke(ShowLauncherWindow); }; // 타이머/알람 풍선 알림 서비스 연결 diff --git a/src/AxCopilot/Assets/ModelPrompts/deepseek.md b/src/AxCopilot/Assets/ModelPrompts/deepseek.md new file mode 100644 index 0000000..d169a36 --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/deepseek.md @@ -0,0 +1,89 @@ +# DeepSeek Model — Detailed Execution Prompt + +## Execution Philosophy + +You are a senior software engineer assistant. DeepSeek excels at reasoning and planning — leverage this strength, but always follow plans with immediate action. Never produce a plan-only response. + +## Planning Discipline + +- Internal planning: maximum 2 sentences, then execute. +- Never output a numbered step list without executing step 1 in the same response. +- If a task has 3+ independent subtasks, consider using spawn_agent to parallelize. +- Plans longer than 5 steps should be decomposed into spawn_agents batches. + +## Tool Calling Protocol + +### Mandatory Sequences + +After file_edit → always build_run to verify. +After 3+ file_edits → run test_loop for regression testing. +After build_run failure → read error → fix → build_run again (max 3 attempts). +After test_loop failure → read failure details → fix specific test → re-run. + +### Parallel Opportunities + +Recognize and exploit parallelism: +- Reading multiple files → single multi_read call +- Independent grep searches → multiple grep calls in one response +- Independent file edits in different files → safe to do simultaneously if no cross-dependencies + +### Build Verification Chain + +``` +file_edit → build_run → (pass? continue : fix → build_run → continue) +``` + +This chain is MANDATORY. Never skip build verification after code changes. + +## Code Quality Standards + +1. **Minimal Changes**: Modify only what's necessary. Don't refactor unrelated code. +2. **Type Safety**: Preserve or improve type safety. Never add `any` or suppress warnings without justification. +3. **Error Handling**: New code must handle failure cases. Check for null, empty, out-of-range. +4. **Naming**: Follow existing codebase conventions (PascalCase for C# public members, camelCase for locals). +5. **Comments**: Add comments only for non-obvious logic. Comments in the existing language of the codebase. + +## Analysis and Investigation + +When investigating bugs or understanding code: + +1. Start with folder_map to understand project structure +2. Use grep to find relevant code patterns +3. Read the specific files involved +4. Trace the call chain (caller → callee) before proposing fixes +5. Check for similar patterns elsewhere that might need the same fix + +### Root Cause Analysis Format + +When reporting findings: +- **Symptom**: What the user observes +- **Root Cause**: The actual code defect (cite file:line) +- **Fix**: Minimal code change with rationale +- **Verification**: How to confirm the fix works + +## Document Generation + +For document/report tasks: +1. Use document_plan first for multi-section documents +2. Gather all data via tools before writing +3. Use appropriate format (html_create for rich docs, markdown_create for technical docs) +4. Include data tables, code snippets, and evidence from actual project files +5. Review the generated document with document_review + +## Multi-Agent Delegation + +Use spawn_agent / spawn_agents when: +- Task has 2+ independent research questions → spawn researchers +- Need to review code AND gather metrics simultaneously → spawn reviewer + researcher +- Writing a document while investigating code → spawn writer + researcher + +Each sub-agent must have: +- Clear, atomic task description +- Specific expected output format +- Appropriate profile (researcher/coder/writer/reviewer/planner) + +## Response Format + +- Explanations: concise, action-oriented +- Code changes: show diff context, explain the "why" not the "what" +- Final summary: bullet points of changes made + verification results diff --git a/src/AxCopilot/Assets/ModelPrompts/gemma.md b/src/AxCopilot/Assets/ModelPrompts/gemma.md new file mode 100644 index 0000000..76377d5 --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/gemma.md @@ -0,0 +1,61 @@ +# Gemma Model — Detailed Execution Prompt + +## Core Constraints + +Gemma has a small context window. Every token counts. Follow these rules strictly. + +## Rules (3 only) + +1. ALWAYS use tools. Respond ONLY with tool calls when action is needed. +2. ONE tool per response. Wait for the result before the next step. +3. NEVER guess file contents. Read first, then act. + +## Tool Priority + +When unsure what to do: +- file_read: see what's in a file +- grep: find where something is +- glob: find files by name pattern +- folder_map: see project structure +- build_run: check if code compiles +- file_edit: change code (read first!) + +## Editing Sequence + +``` +file_read → file_edit → build_run +``` + +Always this order. Never skip steps. + +## Tool Call Format (STRICT) + +Use ONLY this exact format — do NOT use pipe-wrapped tokens, `call;`, or any variant: + +``` + +{"name": "TOOL_NAME", "arguments": {"param": "value"}} + +``` + +Forbidden (examples of WRONG formats the server will reject): +- `<|tool_call>call;name{...}` +- `<|tool_call|>name{...}<|/tool_call|>` +- Any output with `<|"|>` as a string delimiter + +Arguments MUST be valid JSON with double-quoted keys and string values. + +## Output Rules + +- Maximum 2 sentences between tool calls +- No preamble. No "I'll help you with..." +- No numbered plans. Just act. +- Final answer: 1-3 bullet points of what changed + +## Language + +- Code: match the existing codebase +- Explanation: match user's language +- Keep it short + +START WITH A TOOL CALL NOW. diff --git a/src/AxCopilot/Assets/ModelPrompts/kimi.md b/src/AxCopilot/Assets/ModelPrompts/kimi.md new file mode 100644 index 0000000..b0c9d54 --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/kimi.md @@ -0,0 +1,97 @@ +# Kimi/Moonshot Model — Detailed Execution Prompt + +## Execution Rules + +Kimi has a large context window (128K+) but tends toward verbose explanations. Counteract this with strict conciseness rules. + +### Conciseness Protocol + +- Maximum 3 sentences of explanation between tool calls. +- Never repeat what a tool result already shows. +- Never explain what you're "about to do" — just do it. +- If the user can see the tool result, don't summarize it. + +### Mandatory Verification + +After EVERY file_edit → immediately call build_run. +This is non-negotiable. No exceptions. The sequence is: + +``` +file_read → file_edit → build_run → (pass? next task : fix → build_run) +``` + +## Structured Analysis Format + +When analyzing code, documents, or issues, ALWAYS use this format: + +### For Code Analysis + +``` +## [Finding Title] +- **Evidence**: [file:line] — [exact code snippet] +- **Impact**: P0 (critical) / P1 (high) / P2 (medium) / P3 (low) +- **Category**: bug / performance / security / maintainability / style +- **Recommendation**: [specific action with code example] +``` + +### For Document Review + +``` +## [Section/Issue] +- **Location**: [section name or page] +- **Issue**: [concise description] +- **Severity**: error / warning / suggestion +- **Fix**: [specific correction] +``` + +## Tool Usage Patterns + +### Investigation Pattern +1. folder_map → understand structure +2. grep → find relevant files +3. file_read → examine specific code +4. Analyze and report using structured format + +### Fix Pattern +1. file_read → understand current state +2. file_edit → apply fix (exact old_string match) +3. build_run → verify compilation +4. test_loop → verify no regression (if tests exist) +5. Brief summary of change + +### Document Creation Pattern +1. Research: gather data via tools (file_read, grep, code_review) +2. Plan: document_plan for structure +3. Create: html_create / docx_create / markdown_create +4. Review: document_review for quality check + +## Code Editing Standards + +1. Read before edit — ALWAYS. +2. Minimal diff — change only the necessary lines. +3. Preserve formatting — match existing indentation, spacing, brace style. +4. Type-safe changes — no implicit `any`, no null coercion without checks. +5. Build after edit — ALWAYS run build_run. + +## Multi-File Operations + +When a task requires changes to multiple files: +1. Plan the dependency order (models → services → views) +2. Edit files in dependency order +3. Build after each file (not just at the end) +4. If build fails on file N, fix before proceeding to file N+1 + +## Response Style + +- Use Korean for explanations when user writes in Korean +- Use English for tool parameters and code +- Technical terms: keep in English (don't translate class names, method names, etc.) +- Numbers and data: use exact values from tool results, never estimate + +## Error Recovery + +If a tool call fails: +1. Identify the error type (path not found? permission? syntax?) +2. Fix the specific issue +3. Retry with corrected parameters +4. After 2 failures: try alternative approach, explain briefly why diff --git a/src/AxCopilot/Assets/ModelPrompts/llama.md b/src/AxCopilot/Assets/ModelPrompts/llama.md new file mode 100644 index 0000000..a8d135e --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/llama.md @@ -0,0 +1,45 @@ +# Llama Model — Detailed Execution Prompt + +## Execution Rules + +You are a code assistant with tool access. Use tools to gather information and make changes. Do not guess or speculate when tools can provide the answer. + +## Tool Calling Protocol + +1. Start with tools — read files, search code, understand structure before acting. +2. Call multiple independent tools in the same response when possible. +3. After code edits, ALWAYS run build_run to verify. +4. After 3+ edits, run test_loop for regression testing. + +### Common Patterns + +**Investigate**: folder_map → grep → file_read → analyze +**Fix**: file_read → file_edit → build_run → (test_loop if applicable) +**Create**: research → document_plan → create → review + +## Code Quality + +- Minimal changes: only modify what's needed +- Read before edit: always +- Build after edit: always +- Match existing style: indentation, naming, comments +- Handle errors: check null, empty, edge cases + +## Response Style + +- Concise: max 3 sentences between tool calls +- Action-oriented: do, don't describe plans to do +- Structured: use bullet points for multi-item results +- Match user's language for explanations + +## Error Recovery + +On tool failure: read error → fix parameters → retry (max 2 attempts) → try alternative approach. + +## Analysis Format + +When reporting findings: +- **What**: brief description +- **Where**: file:line reference +- **Impact**: severity (P0-P3) +- **Fix**: specific recommendation diff --git a/src/AxCopilot/Assets/ModelPrompts/mistral.md b/src/AxCopilot/Assets/ModelPrompts/mistral.md new file mode 100644 index 0000000..92f1c3a --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/mistral.md @@ -0,0 +1,44 @@ +# Mistral/Mixtral Model — Detailed Execution Prompt + +## Execution Philosophy + +Mistral excels at reasoning. Use this strength for analysis and planning, but always follow reasoning with tool execution in the same response. + +## Tool Calling Protocol + +1. Think briefly (1-2 sentences max), then act with tools. +2. Parallel calls: when tasks are independent, call multiple tools at once. +3. After code edits: build_run is mandatory. +4. After investigation: summarize with structured findings. + +### Verification Chain + +``` +file_read → file_edit → build_run → (pass? continue : diagnose → fix → build_run) +``` + +## Code Standards + +- Read before edit: mandatory +- Minimal diff: change only what's needed +- Type safety: preserve or improve +- Build verification: after every edit +- Test coverage: run test_loop after 3+ edits + +## Analysis Protocol + +When analyzing code or issues: +1. Gather evidence via tools (grep, file_read, code_review) +2. Trace the relevant call chain +3. Report findings with: + - **Finding**: concise description + - **Evidence**: file:line with code reference + - **Severity**: P0/P1/P2/P3 + - **Recommendation**: specific fix + +## Response Format + +- Reasoning: brief, inline (not separate section) +- Actions: tool calls immediately after reasoning +- Results: bullet-point summary +- Language: match user's language for explanations, English for code diff --git a/src/AxCopilot/Assets/ModelPrompts/qwen.md b/src/AxCopilot/Assets/ModelPrompts/qwen.md new file mode 100644 index 0000000..48beae8 --- /dev/null +++ b/src/AxCopilot/Assets/ModelPrompts/qwen.md @@ -0,0 +1,65 @@ +# Qwen Model — Detailed Execution Prompt + +## Critical Behavior Rules + +[MUST] Start EVERY response with a tool call. No text before tool_call. +[MUST] Call multiple independent tools in the same response when possible. +[NEVER] Say "알겠습니다", "네", "확인했습니다", "I understand" before a tool call. +[NEVER] Output text-only when a tool action is still needed. +[NEVER] Repeat the user's request back to them — just do it. + +## Tool Calling Protocol + +You MUST follow this protocol for every turn: + +1. Read the user's request +2. Immediately call the first relevant tool (file_read, grep, glob, folder_map, etc.) +3. After receiving tool results, call the next tool or produce your final answer +4. If you are unsure, call a tool to gather information — do NOT guess + +### When to Use Each Tool + +- **file_read**: When you need to see file contents. ALWAYS read before editing. +- **grep / glob**: When searching for code patterns or files. Use grep for content, glob for filenames. +- **file_edit**: When modifying files. You MUST read the file first. Use exact old_string match. +- **build_run**: After ANY file edit, run the build to verify. Do not skip this step. +- **test_loop**: After 3+ file edits, run tests to catch regressions. +- **folder_map**: To understand project structure before diving into files. + +### Parallel Tool Calls + +When multiple tools are independent, call them ALL in the same response: + +GOOD: Call file_read for 3 different files simultaneously +BAD: Read file A, wait, read file B, wait, read file C + +### Error Recovery + +If a tool call fails: +1. Read the error message carefully +2. Fix the parameters (wrong path? wrong old_string?) +3. Try again with corrected parameters +4. After 2 failures on the same operation, try an alternative approach + +## Code Editing Rules + +1. ALWAYS read the file before editing (file_read → file_edit) +2. Use the EXACT string from the file as old_string — copy precisely +3. After editing, run build_run to verify the build passes +4. If build fails, read the error, fix the issue, build again +5. Keep changes minimal — change only what's needed + +## Response Format + +- Between tool calls: maximum 1 sentence of explanation +- Final answer: concise summary of what was done +- Never list what you "plan to do" — just do it +- Use bullet points for multi-item results + +## Language + +- Tool parameters: always in the language of the existing code +- Explanations to user: match the user's language (Korean if they write Korean) +- Code comments: match existing codebase conventions + +REMINDER: Your FIRST output in EVERY response MUST be a tool_call. Begin now. diff --git a/src/AxCopilot/Assets/about.json b/src/AxCopilot/Assets/about.json index 7e68b1c..73870b4 100644 --- a/src/AxCopilot/Assets/about.json +++ b/src/AxCopilot/Assets/about.json @@ -6,5 +6,5 @@ "purpose": "업무 편의성 증가 및 시스템의 직관적인 연결을 위해 제작", "copyright": "© 2026 AX연구소", "blogUrl": "www.swarchitect.net", - "contributors": "경윤영님, 윤지영님, 배지훈님" + "contributors": "경윤영님, 윤지영님" } diff --git a/src/AxCopilot/AxCopilot.csproj b/src/AxCopilot/AxCopilot.csproj index 8ab9311..3bfddc0 100644 --- a/src/AxCopilot/AxCopilot.csproj +++ b/src/AxCopilot/AxCopilot.csproj @@ -74,6 +74,7 @@ + @@ -111,6 +112,8 @@ + + diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 668d458..fe3240a 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -9,7 +9,7 @@ public class AppSettings /// /// AI 기능 활성화 여부. false이면 ! 명령어 차단 + 설정의 AX Agent 탭 숨김. - /// claw code 미포함 배포는 false로 설정합니다. + /// AI 엔진 미포함 배포는 false로 설정합니다. /// [JsonPropertyName("ai_enabled")] public bool AiEnabled { get; set; } = true; @@ -881,6 +881,10 @@ public class LlmSettings [JsonPropertyName("autoPreview")] public string AutoPreview { get; set; } = "off"; + /// 미리보기 패널 너비 (픽셀). 드래그 조절 후 저장. + [JsonPropertyName("previewPanelWidth")] + public double PreviewPanelWidth { get; set; } = 420; + /// 에이전트 최대 루프 반복 횟수. [JsonPropertyName("maxAgentIterations")] public int MaxAgentIterations { get; set; } = 25; @@ -905,6 +909,10 @@ public class LlmSettings [JsonPropertyName("agentLogLevel")] public string AgentLogLevel { get; set; } = "detailed"; + /// IBM+Qwen 조합 상세 진단 로깅 활성화. true 시 [IBM진단] 태그 Debug 로그 출력. 기본 false. + [JsonPropertyName("enableIbmDiagnosticLog")] + public bool EnableIbmDiagnosticLog { get; set; } = false; + /// AX Agent UI 표현 수준. rich | balanced | simple [JsonPropertyName("agentUiExpressionLevel")] public string AgentUiExpressionLevel { get; set; } = "balanced"; @@ -999,7 +1007,7 @@ public class LlmSettings /// 서브에이전트 최대 동시 실행 수. 기본 3. [JsonPropertyName("maxSubAgents")] - public int MaxSubAgents { get; set; } = 3; + public int MaxSubAgents { get; set; } = 5; /// PDF 내보내기 기본 경로. 빈 문자열이면 바탕화면. [JsonPropertyName("pdfExportPath")] @@ -1071,7 +1079,7 @@ public class LlmSettings [JsonPropertyName("enableChatRainbowGlow")] public bool EnableChatRainbowGlow { get; set; } = false; - /// 새로운 계획 뷰어(V2 사이드바 레이아웃) 사용. 기본 true. + /// 새로운 뷰어 표시 버전(V2 사이드바 레이아웃) 사용. 기본 true. [JsonPropertyName("enableNewPlanViewer")] public bool EnableNewPlanViewer { get; set; } = true; @@ -1079,6 +1087,58 @@ public class LlmSettings [JsonPropertyName("enableNewChatRendering")] public bool EnableNewChatRendering { get; set; } = false; + /// IntentGate LLM 폴백 활성화. 키워드 분류 confidence가 낮을 때 LLM 1-shot 분류 사용. + [JsonPropertyName("enableIntentGateLlmFallback")] + public bool EnableIntentGateLlmFallback { get; set; } = false; + + /// IntentGate confidence 임계값. 이 값 미만이면 LLM 폴백 발동 (EnableIntentGateLlmFallback=true 시). + [JsonPropertyName("intentGateConfidenceThreshold")] + public double IntentGateConfidenceThreshold { get; set; } = 0.6; + + /// 세션 내 누적 학습 활성화. 도구 결과에서 학습 포인트를 자동 수집하여 후속 반복에 주입. + [JsonPropertyName("enableSessionLearnings")] + public bool EnableSessionLearnings { get; set; } = true; + + /// 최대 누적 학습 항목 수. FIFO로 관리. + [JsonPropertyName("maxSessionLearnings")] + public int MaxSessionLearnings { get; set; } = 10; + + /// 워크스페이스 컨텍스트 자동 생성 (.ax-context.md) 활성화. + [JsonPropertyName("enableAutoWorkspaceContext")] + public bool EnableAutoWorkspaceContext { get; set; } = true; + + /// + /// 모델별 프롬프트 전략 수준. + /// "off": 모든 모델에 동일한 기본 프롬프트 사용 (기존 동작). + /// "basic": 모델 패밀리별 가벼운 프롬프트 어댑테이션 (규칙 추가/재배치). + /// "detailed": 모델별 전용 프롬프트 파일 적용 (임베디드 리소스, 수백 줄급 상세 지침). + /// + [JsonPropertyName("modelPromptLevel")] + public string ModelPromptLevel { get; set; } = "off"; + + /// 이전 호환: EnableModelSpecificPrompting=true → "basic" 으로 마이그레이션. + [JsonPropertyName("enableModelSpecificPrompting")] + public bool EnableModelSpecificPrompting + { + get => !string.Equals(ModelPromptLevel, "off", StringComparison.OrdinalIgnoreCase); + set + { + if (value && string.Equals(ModelPromptLevel, "off", StringComparison.OrdinalIgnoreCase)) + ModelPromptLevel = "basic"; + else if (!value) + ModelPromptLevel = "off"; + } + } + + /// + /// Hash-Anchored Edits 활성화. + /// true: file_read 출력에 라인별 해시 앵커를 포함하고, file_edit에서 앵커 기반 편집 지원. + /// 편집 성공률이 크게 향상되며 스테일 편집을 자동 감지합니다. + /// false: 기존 라인번호 + old_string 방식 유지. + /// + [JsonPropertyName("enableHashAnchoredEdits")] + public bool EnableHashAnchoredEdits { get; set; } = false; + /// AX Agent 전용 테마. system | light | dark [JsonPropertyName("agentTheme")] public string AgentTheme { get; set; } = "system"; @@ -1137,7 +1197,7 @@ public class LlmSettings public List BlockedPaths { get; set; } = new() { "*\\Windows\\*", "*\\Program Files\\*", "*\\Program Files (x86)\\*", - "*\\System32\\*", "*\\AppData\\Local\\*", "*Documents*", + "*\\System32\\*", "*\\AppData\\Local\\*", }; /// 차단할 파일 확장자 목록. @@ -1441,6 +1501,13 @@ public class RegisteredModel [JsonPropertyName("executionProfile")] public string ExecutionProfile { get; set; } = "balanced"; + /// + /// 프롬프트 전략 패밀리. qwen | deepseek | kimi | gemma | llama | mistral | phi | yi | gemini | claude | default + /// 비어있으면 모델명에서 자동 감지합니다. + /// + [JsonPropertyName("promptFamily")] + public string PromptFamily { get; set; } = ""; + /// 이 모델 전용 서버 엔드포인트. 비어있으면 LlmSettings의 기본 엔드포인트 사용. [JsonPropertyName("endpoint")] public string Endpoint { get; set; } = ""; diff --git a/src/AxCopilot/Models/ChatModels.cs b/src/AxCopilot/Models/ChatModels.cs index 7074718..c901be9 100644 --- a/src/AxCopilot/Models/ChatModels.cs +++ b/src/AxCopilot/Models/ChatModels.cs @@ -130,7 +130,7 @@ public class ChatConversation public List DraftQueueItems { get; set; } = new(); [JsonPropertyName("showExecutionHistory")] - public bool ShowExecutionHistory { get; set; } = false; + public bool ShowExecutionHistory { get; set; } = true; [JsonPropertyName("agentRunHistory")] public List AgentRunHistory { get; set; } = new(); diff --git a/src/AxCopilot/Services/Agent/AgentHookRunner.cs b/src/AxCopilot/Services/Agent/AgentHookRunner.cs index 38695ef..5ad43c5 100644 --- a/src/AxCopilot/Services/Agent/AgentHookRunner.cs +++ b/src/AxCopilot/Services/Agent/AgentHookRunner.cs @@ -55,11 +55,13 @@ public static class AgentHookRunner if (!string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase)) continue; // 도구 이름 매칭: "*" = 전체, 그 외 정확 매칭 (대소문자 무시) - if (hook.ToolName != "*" && - !string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase)) + var hookToolName = AgentToolCatalog.CanonicalizeHookTarget(hook.ToolName); + var canonicalToolName = AgentToolCatalog.Canonicalize(toolName); + if (hookToolName != "*" && + !string.Equals(hookToolName, canonicalToolName, StringComparison.OrdinalIgnoreCase)) continue; - var result = await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct); + var result = await ExecuteHookAsync(hook, canonicalToolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct); results.Add(result); } diff --git a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs index a555f3b..3237585 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs @@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent; public partial class AgentLoopService { - private enum ExplorationScope + internal enum ExplorationScope { Localized, TopicBased, diff --git a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs index 23388c8..55ab550 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs @@ -16,14 +16,6 @@ public partial class AgentLoopService } // 읽기 전용 도구 (파일 상태를 변경하지 않음) - private static readonly HashSet ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) - { - "file_read", "glob", "grep_tool", "folder_map", "document_read", - "search_codebase", "code_search", "env_tool", "datetime_tool", - "dev_env_detect", "memory", "skill_manager", "json_tool", - "regex_tool", "base64_tool", "hash_tool", "image_analyze", - }; - /// 도구 호출을 병렬 가능 / 순차 필수로 분류합니다. private static (List Parallel, List Sequential) ClassifyToolCalls(List calls) @@ -35,12 +27,9 @@ public partial class AgentLoopService foreach (var call in calls) { var requestedToolName = call.ToolName ?? ""; - var normalizedToolName = NormalizeAliasToken(requestedToolName); - var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName) - ? mappedToolName - : requestedToolName; + var classificationToolName = AgentToolCatalog.Canonicalize(requestedToolName); - if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName)) + if (collectParallelPrefix && AgentToolCatalog.IsReadOnly(classificationToolName)) parallel.Add(call); else { diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 457aa1f..3ad47fe 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -188,12 +188,20 @@ public partial class AgentLoopService // 사용자 원본 요청 캡처 (문서 생성 폴백 판단용) var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? ""; + // IntentGate: 통합 의도 분류 + var intentGate = new IntentGateService(_llm); + var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false); + var explorationState = new ExplorationTrackingState { - Scope = ClassifyExplorationScope(userQuery, ActiveTab), + Scope = intentResult.SuggestedScope, SelectiveHit = true, }; var pathAccessState = new PathAccessTrackingState(); + // P3: 누적 학습 — 도구 결과에서 자동 학습 포인트 수집 + var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings) + ? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings) + : null; DateTime? lastToolResultAtUtc = null; string? lastToolResultToolName = null; @@ -236,9 +244,10 @@ public partial class AgentLoopService string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적 var statsModifiedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적 - var taskType = ClassifyTaskType(userQuery, ActiveTab); + var taskType = intentResult.TaskType; var taskPolicy = TaskTypePolicy.FromTaskType(taskType); - var executionPolicy = _llm.GetActiveExecutionPolicy(); + var executionPolicy = ExecutionPolicyMerger.Apply( + _llm.GetActiveExecutionPolicy(), intentResult.PolicyOverlay); var consecutiveNoToolResponses = 0; var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold); var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries); @@ -256,6 +265,19 @@ public partial class AgentLoopService var context = BuildContext(); InjectTaskTypeGuidance(messages, taskPolicy); InjectExplorationScopeGuidance(messages, explorationState.Scope); + + // P5: 복합 요청 감지 시 DecompositionHint 주입 + if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint)) + { + messages.Add(new ChatMessage + { + Role = "user", + Content = $"[System:DecompositionHint]\n{intentResult.DecompositionHint}\n" + + "Consider using spawn_agents to run independent sub-tasks in parallel.", + MetaKind = "decomposition_hint", + }); + } + var preferredInitialToolSequence = BuildPreferredInitialToolSequence( explorationState, taskPolicy, @@ -370,6 +392,11 @@ public partial class AgentLoopService // PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시 } + // ── 실행 중 설정 변경 반영 ── + // 사용자가 UI에서 권한 모드나 사내/사외 모드를 바꾼 경우 다음 반복부터 즉시 적용. + // (현재 진행 중인 LLM 호출이나 도구 실행에는 영향 없음 — 다음 사이클부터) + SyncContextFromSettings(context); + // Context Condenser: 토큰 초과 시 이전 대화 자동 압축 // 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비) { @@ -475,6 +502,22 @@ public partial class AgentLoopService } } + // P3: 누적 학습 메시지 주입 (매 반복 갱신) + if (sessionLearnings is { Count: > 0 }) + { + var learningMsg = sessionLearnings.BuildInjectionMessage(); + if (learningMsg != null) + { + messages.RemoveAll(m => m.MetaKind == "session_learnings"); + messages.Insert(0, new ChatMessage + { + Role = "user", + Content = learningMsg, + MetaKind = "session_learnings", + }); + } + } + // P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨 var cachedActiveTools = FilterExplorationToolsForCurrentIteration( GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides), @@ -782,9 +825,20 @@ public partial class AgentLoopService ? taskPolicy.TaskType is "bugfix" or "feature" or "refactor" : IsDocumentCreationRequest(userQuery); + // probe 전용 도구(dev_env_detect, memory 등)만 사용된 경우도 + // 실질적 작업이 이뤄지지 않은 것으로 간주하고 NoToolCallLoop 복구를 유도한다. + // (Code/Cowork 양쪽 모두 — 문서 작성 요청도 probe-only로 끝나서는 안 됨) + var onlyProbeToolsUsed = + requiresConcreteArtifactOrEdit + && totalToolCalls > 0 + && !HasSubstantiveCodeToolUsage(statsUsedTools); + + // probe-only 상태는 "1회 no-tool 응답"만으로도 즉시 복구 — 기본 임계(2회)는 느림 + var effectiveNoToolThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold; + if (requiresConcreteArtifactOrEdit - && totalToolCalls == 0 - && consecutiveNoToolResponses >= noToolResponseThreshold + && (totalToolCalls == 0 || onlyProbeToolsUsed) + && consecutiveNoToolResponses >= effectiveNoToolThreshold && runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries) { runState.NoToolCallLoopRetry++; @@ -1034,6 +1088,18 @@ public partial class AgentLoopService terminalEvidenceGateRetryMax)) continue; + // 방어: 파이프-래핑 tool_call 토큰 등 원본 마크업이 최종 assistant 응답에 남아있으면 제거 + // (패턴 4 폴백 파싱도 실패하여 사용자 화면에 "<|tool_call>call;foo{...}" 가 + // 그대로 노출되는 증상을 방지) + if (!string.IsNullOrEmpty(textResponse)) + { + var cleaned = LlmService.StripToolCallTokens(textResponse); + if (!string.Equals(cleaned, textResponse, StringComparison.Ordinal)) + { + LogService.Debug("[AgentLoop] 최종 응답에 미파싱 tool_call 토큰 발견 — 정화"); + textResponse = cleaned; + } + } if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); EmitEvent(AgentEventType.Complete, "", @@ -1513,6 +1579,9 @@ public partial class AgentLoopService lastToolResultAtUtc = DateTime.UtcNow; lastToolResultToolName = effectiveCall.ToolName; + // P3: 누적 학습 — 도구 결과에서 학습 포인트 추출 + sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success); + if (!result.Success) { failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount); @@ -1698,7 +1767,7 @@ public partial class AgentLoopService { if (consumedExtraIteration) iteration++; - return result.Output; + return result.Output ?? ""; } var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync( @@ -1957,8 +2026,9 @@ public partial class AgentLoopService { foreach (var name in disabledToolNames) { - if (!string.IsNullOrWhiteSpace(name)) - disabled.Add(name); + var canonical = AgentToolCatalog.Canonicalize(name); + if (!string.IsNullOrWhiteSpace(canonical)) + disabled.Add(canonical); } } @@ -1986,9 +2056,7 @@ public partial class AgentLoopService if (string.IsNullOrWhiteSpace(normalized)) continue; - var alias = NormalizeAliasToken(normalized); - if (ToolAliasMap.TryGetValue(alias, out var mapped)) - normalized = mapped; + normalized = AgentToolCatalog.Canonicalize(normalized); result.Add(normalized); } @@ -2006,7 +2074,7 @@ public partial class AgentLoopService { var normalized = token.Trim().Trim('`', '"', '\''); if (!string.IsNullOrWhiteSpace(normalized)) - result.Add(normalized); + result.Add(AgentToolCatalog.Canonicalize(normalized)); } return result; @@ -2132,7 +2200,7 @@ public partial class AgentLoopService } private static bool IsForkCompliantTool(string toolName) - => toolName is "spawn_agent" or "wait_agents"; + => toolName is "spawn_agent" or "spawn_agents" or "wait_agents"; private static bool ShouldEnforceForkExecution( bool enforceForkExecution, @@ -3164,6 +3232,33 @@ public partial class AgentLoopService return true; } + /// + /// Code 작업에서 "실질적" 도구 사용이 있었는지 판별한다. + /// dev_env_detect/memory/notify 같은 probe·메타 도구만 호출된 경우엔 false를 반환하여 + /// 메인 루프가 NoToolCallLoop 복구를 통해 실제 작업을 유도하도록 한다. + /// + private static bool HasSubstantiveCodeToolUsage(IEnumerable usedTools) + { + // 실질적 진행으로 간주하는 도구(읽기/수정/검색/빌드/테스트/LSP/git 등) + var substantive = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "file_read", "file_write", "file_edit", "file_manage", + "multi_read", "file_info", + "grep", "glob", "code_search", "lsp_code_intel", + "git_tool", "build_run", "test_loop", + "script_create", "process", + "html_create", "markdown_create", "docx_create", + "excel_create", "csv_create", "pptx_create", "chart_create", + "document_plan", "sub_agent", "wait_agents" + }; + foreach (var t in usedTools) + { + if (!string.IsNullOrWhiteSpace(t) && substantive.Contains(t)) + return true; + } + return false; + } + private static bool HasAnyBuildOrTestEvidence(List messages) { foreach (var message in messages.AsEnumerable().Reverse()) @@ -3338,8 +3433,8 @@ public partial class AgentLoopService return false; if (highImpact && !hasSuccessfulBuildAndTestEvidence) return false; - if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && !hasDocumentVerificationEvidence) - return false; + // docs 작업: 검증 도구(document_review 등) 호출이 있으면 그 언급도 필요하지만, + // 호출이 없어도 기본 요약(변경+검증 키워드)만으로 FinalReport를 트리거할 수 있게 허용. if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && hasDocumentVerificationEvidence && !hasDocumentVerificationToolMention) @@ -4062,8 +4157,8 @@ public partial class AgentLoopService string unknownToolName, IReadOnlyCollection activeToolNames) { - var normalizedUnknown = NormalizeAliasToken(unknownToolName); - var aliasHint = ToolAliasMap.TryGetValue(normalizedUnknown, out var mappedCandidate) + var mappedCandidate = AgentToolCatalog.Canonicalize(unknownToolName); + var aliasHint = !string.Equals(mappedCandidate, unknownToolName, StringComparison.OrdinalIgnoreCase) && activeToolNames.Any(name => string.Equals(name, mappedCandidate, StringComparison.OrdinalIgnoreCase)) ? $"- 자동 매핑 후보: {unknownToolName} → {mappedCandidate}\n" : ""; @@ -4133,67 +4228,6 @@ public partial class AgentLoopService "- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요."; } - private static readonly Dictionary ToolAliasMap = new(StringComparer.OrdinalIgnoreCase) - { - ["read"] = "file_read", - ["readfile"] = "file_read", - ["read_file"] = "file_read", - ["write"] = "file_write", - ["writefile"] = "file_write", - ["write_file"] = "file_write", - ["edit"] = "file_edit", - ["editfile"] = "file_edit", - ["edit_file"] = "file_edit", - ["bash"] = "process", - ["shell"] = "process", - ["terminal"] = "process", - ["run"] = "process", - ["ls"] = "glob", - ["listfiles"] = "glob", - ["list_files"] = "glob", - ["grep"] = "grep", - ["greptool"] = "grep", - ["grep_tool"] = "grep", - ["rg"] = "grep", - ["ripgrep"] = "grep", - ["search"] = "grep", - ["globfiles"] = "glob", - ["glob_files"] = "glob", - // claw-code 계열 도구명 호환 - ["webfetch"] = "http_tool", - ["websearch"] = "http_tool", - ["askuserquestion"] = "user_ask", - ["lsp"] = "lsp_code_intel", - ["listmcpresourcestool"] = "mcp_list_resources", - ["readmcpresourcetool"] = "mcp_read_resource", - ["agent"] = "spawn_agent", - ["spawnagent"] = "spawn_agent", - ["task"] = "spawn_agent", - ["sendmessage"] = "notify_tool", - ["shellcommand"] = "process", - ["execute"] = "process", - ["codesearch"] = "search_codebase", - ["code_search"] = "search_codebase", - ["powershell"] = "process", - ["toolsearch"] = "tool_search", - ["todowrite"] = "todo_write", - ["taskcreate"] = "task_create", - ["taskget"] = "task_get", - ["tasklist"] = "task_list", - ["taskupdate"] = "task_update", - ["taskstop"] = "task_stop", - ["taskoutput"] = "task_output", - ["enterworktree"] = "enter_worktree", - ["exitworktree"] = "exit_worktree", - ["teamcreate"] = "team_create", - ["teamdelete"] = "team_delete", - ["croncreate"] = "cron_create", - ["crondelete"] = "cron_delete", - ["cronlist"] = "cron_list", - ["config"] = "project_rules", - ["skill"] = "skill_manager", - }; - private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection activeToolNames) { var requested = requestedToolName.Trim(); @@ -4205,15 +4239,13 @@ public partial class AgentLoopService if (!string.IsNullOrWhiteSpace(direct)) return direct; - var normalizedRequested = NormalizeAliasToken(requested); - if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped)) - { - var mappedDirect = activeToolNames.FirstOrDefault(name => - string.Equals(name, mapped, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(mappedDirect)) - return mappedDirect; - } + var canonicalRequested = AgentToolCatalog.Canonicalize(requested); + var mappedDirect = activeToolNames.FirstOrDefault(name => + string.Equals(name, canonicalRequested, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(mappedDirect)) + return mappedDirect; + var normalizedRequested = NormalizeAliasToken(requested); var normalizedMatch = activeToolNames.FirstOrDefault(name => string.Equals(NormalizeAliasToken(name), normalizedRequested, StringComparison.Ordinal)); if (!string.IsNullOrWhiteSpace(normalizedMatch)) @@ -4223,20 +4255,7 @@ public partial class AgentLoopService } private static string NormalizeAliasToken(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return ""; - - Span buffer = stackalloc char[value.Length]; - var idx = 0; - foreach (var ch in value) - { - if (ch is '_' or '-' or ' ') - continue; - buffer[idx++] = char.ToLowerInvariant(ch); - } - return new string(buffer[..idx]); - } + => AgentToolCatalog.NormalizeToken(value); private static string BuildNoProgressAbortResponse( TaskTypePolicy taskPolicy, @@ -4555,7 +4574,7 @@ public partial class AgentLoopService AskPermission = AskPermissionCallback, UserDecision = UserDecisionCallback, UserAskCallback = UserAskCallback, - ToolPermissions = new Dictionary(llm.ToolPermissions ?? new(), StringComparer.OrdinalIgnoreCase), + ToolPermissions = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary()), ActiveTab = ActiveTab, OperationMode = _settings.Settings.OperationMode, DevMode = llm.DevMode, @@ -4563,6 +4582,57 @@ public partial class AgentLoopService }; } + /// + /// 실행 중인 컨텍스트의 권한/운영모드/차단목록을 현재 설정값으로 갱신합니다. + /// 에이전트 루프가 실행되는 동안 사용자가 UI에서 권한 모드를 바꾸거나 + /// 사내/사외 모드를 전환했을 때 다음 도구 호출부터 즉시 반영되도록 합니다. + /// + /// 주의: WorkFolder와 ActiveTab은 실행 중 변경되지 않아야 하므로 건드리지 않음. + /// (중간에 바뀌면 이미 진행 중인 도구 호출이 다른 워크스페이스를 바라보게 됨) + /// + private void SyncContextFromSettings(AgentContext context, bool emitChangeEvents = true) + { + if (context == null) return; + var llm = _settings.Settings.Llm; + + var newPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); + var oldPermission = PermissionModeCatalog.NormalizeGlobalMode(context.Permission); + if (!string.Equals(newPermission, oldPermission, StringComparison.OrdinalIgnoreCase)) + { + context.Permission = newPermission; + if (emitChangeEvents) + EmitEvent(AgentEventType.Thinking, "", + $"[설정 변경 감지] 권한 모드: {oldPermission} → {newPermission}"); + // 권한 변경 시 이전 세션 승인 캐시는 유지 (사용자가 이미 허용한 건은 그대로) + } + + var newOpMode = AxCopilot.Services.OperationModePolicy.Normalize(_settings.Settings.OperationMode); + var oldOpMode = AxCopilot.Services.OperationModePolicy.Normalize(context.OperationMode); + if (!string.Equals(newOpMode, oldOpMode, StringComparison.OrdinalIgnoreCase)) + { + context.OperationMode = newOpMode; + if (emitChangeEvents) + EmitEvent(AgentEventType.Thinking, "", + $"[설정 변경 감지] 운영 모드: {oldOpMode} → {newOpMode}"); + } + + // 차단 경로/확장자는 참조 자체가 설정 객체와 동일할 수 있어 Clear+AddRange 대신 새 리스트로 교체 + if (!ReferenceEquals(context.BlockedPaths, llm.BlockedPaths)) + context.BlockedPaths = llm.BlockedPaths ?? new(); + if (!ReferenceEquals(context.BlockedExtensions, llm.BlockedExtensions)) + context.BlockedExtensions = llm.BlockedExtensions ?? new(); + + // 도구별 권한 오버라이드는 훅이 런타임에 쓰는 경로도 있으므로, 설정값과 훅 값을 병합. + // 설정에서 제거된 키는 삭제하되, 훅이 추가한 키(설정에 없는)는 유지. + var desired = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary()); + foreach (var kv in desired) + context.ToolPermissions[kv.Key] = kv.Value; + // 설정에서 사라진 키 중, 훅이 추가하지 않은 것만 정리하기는 훅 추적 비용이 커서 생략. + + context.DevMode = llm.DevMode; + context.DevModeStepApproval = llm.DevModeStepApproval; + } + /// /// 탭(Cowork/Code)별 작업 폴더를 결정합니다. /// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백. @@ -4661,7 +4731,23 @@ public partial class AgentLoopService var primary = TryReadString(input, "path", "filePath", "destination", "url", "command", "project_path", "cwd"); if (string.IsNullOrWhiteSpace(primary)) + { + // ── 권한 대상 보정 ── + // html_create/markdown_create 등은 path 생략 시 WorkFolder 하위에 자동 생성된다. + // 이 경우 권한 검사 대상을 WorkFolder 자체로 간주해야 한다. + // 이전에는 toolName("html_create")을 target으로 리턴했는데, IsOutsideWorkspace가 + // Path.GetFullPath("html_create") = <앱 CWD>/html_create 로 해석하여 + // "워크스페이스 외부"로 오인 → 사내 모드에서 BypassPermissions여도 강제 승인창이 떴다. + if (toolName is "file_write" or "file_edit" or "file_manage" + or "html_create" or "markdown_create" or "docx_create" + or "excel_create" or "csv_create" or "pptx_create" + or "chart_create" or "script_create" + && !string.IsNullOrWhiteSpace(context.WorkFolder)) + { + return context.WorkFolder; + } return toolName; + } if ((toolName is "file_write" or "file_edit" or "file_manage" or "open_external" or "html_create" or "markdown_create" or "docx_create" or "excel_create" or "csv_create" or "pptx_create" or "chart_create" or "script_create") && !Path.IsPathRooted(primary) @@ -4858,11 +4944,7 @@ public partial class AgentLoopService if (trimmed is "*" or "default") return trimmed; - var normalizedRequested = NormalizeAliasToken(trimmed); - if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped)) - return mapped; - - return trimmed; + return AgentToolCatalog.Canonicalize(trimmed); } private async Task RunPermissionLifecycleHooksAsync( @@ -4907,6 +4989,11 @@ public partial class AgentLoopService AgentContext context, List? messages = null) { + // 권한 검사 직전 한 번 더 동기화 — 한 iteration 안에서 여러 툴 콜이 있을 때 + // 사용자가 중간에 권한/운영모드를 바꿨으면 즉시 반영되도록. + // 반복 호출이므로 Thinking 이벤트는 조용히 스킵(이미 iteration 시작 시 알림 발생). + SyncContextFromSettings(context, emitChangeEvents: false); + var target = DescribeToolTarget(toolName, input, context); var requestPayload = JsonSerializer.Serialize(new { @@ -5073,8 +5160,10 @@ public partial class AgentLoopService var toolName = call.ToolName ?? ""; var input = call.ToolInput; - // 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략 - if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode)) + // 권한 건너뛰기: 도구별 승인 생략 + // 사내모드에서도 동일하게 적용 — 외부 URL 접근, 워크스페이스 외부 경로 접근 등 + // 실질적인 위험은 OperationModePolicy(IsBlockedAgentToolInInternalMode) 와 + // IAgentTool.CheckToolPermissionAsync 의 IsOutsideWorkspace 체크에서 이미 방어됨 { var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission); if (PermissionModeCatalog.IsBypassPermissions(effectivePerm)) diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 2851c4d..28d7416 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -646,7 +646,7 @@ public partial class AgentLoopService if (!success) return 0; - return ReadOnlyTools.Contains(toolName ?? "") + return AgentToolCatalog.IsReadOnly(toolName) ? current + 1 : 0; } @@ -681,7 +681,7 @@ public partial class AgentLoopService if (repeatedSameSignatureCount < GetReadOnlySignatureLoopThreshold()) return false; - return ReadOnlyTools.Contains(toolName ?? ""); + return AgentToolCatalog.IsReadOnly(toolName); } private static int GetReadOnlySignatureLoopThreshold() @@ -1124,7 +1124,12 @@ public partial class AgentLoopService AgentContext context, List messages) { - if (context.DevModeStepApproval && UserDecisionCallback != null) + // 권한 자동화 모드(Bypass/AcceptEdits/DontAsk) 또는 Plan 모드에서는 DevStepApproval도 생략. + // - Bypass/AcceptEdits/DontAsk: 사용자가 "매 스텝 확인 없이 진행"을 명시적으로 선택한 상태 + // - Plan: 읽기/조사만 자동 진행하며 계획 세우기가 목적 — 매 스텝 확인은 목적에 반함 + var skipDevApproval = PermissionModeCatalog.IsAuto(context.Permission) + || PermissionModeCatalog.IsPlan(context.Permission); + if (context.DevModeStepApproval && !skipDevApproval && UserDecisionCallback != null) { var decision = await UserDecisionCallback( $"[DEV] 도구 '{call.ToolName}' 실행을 확인하시겠습니까?\n{FormatToolCallSummary(call)}", diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs index f624bfd..f018abf 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs @@ -100,7 +100,7 @@ public partial class AgentLoopService var shouldRequestStructuredFinalReport = taskPolicy.IsReviewTask || requireHighImpactCodeVerification - || taskPolicy.TaskType is "bugfix" or "feature" or "refactor"; + || taskPolicy.TaskType is "bugfix" or "feature" or "refactor" or "docs"; if (executionPolicy.FinalReportGateMaxRetries > 0 && shouldRequestStructuredFinalReport && !hasBlockingCodeEvidenceGap diff --git a/src/AxCopilot/Services/Agent/AgentToolCatalog.cs b/src/AxCopilot/Services/Agent/AgentToolCatalog.cs new file mode 100644 index 0000000..aab6aaa --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentToolCatalog.cs @@ -0,0 +1,307 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +internal sealed record AgentToolMetadata( + string CanonicalName, + string SettingsCategory, + string SettingsIcon, + string SettingsIconColor, + int ExposureBucket = 1, + string? TabCategory = null, + bool IsReadOnly = false); + +internal static class AgentToolCatalog +{ + private static readonly IReadOnlyDictionary s_metadata = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["file_read"] = new("file_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true), + ["file_write"] = new("file_write", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"), + ["file_edit"] = new("file_edit", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"), + ["glob"] = new("glob", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true), + ["grep"] = new("grep", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true), + ["folder_map"] = new("folder_map", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true), + ["document_read"] = new("document_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true), + ["file_manage"] = new("file_manage", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code"), + ["file_info"] = new("file_info", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true), + ["multi_read"] = new("multi_read", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true), + ["file_watch"] = new("file_watch", "파일/검색", "\uE8B7", "#F59E0B", 1, "Code"), + ["open_external"] = new("open_external", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"), + + ["process"] = new("process", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Cowork,Code"), + ["build_run"] = new("build_run", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code"), + ["dev_env_detect"] = new("dev_env_detect", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code", true), + + ["search_codebase"] = new("search_codebase", "코드 분석", "\uE943", "#818CF8", 1, "Code", true), + ["code_review"] = new("code_review", "코드 분석", "\uE943", "#818CF8", 1, "Code"), + ["lsp_code_intel"] = new("lsp_code_intel", "코드 분석", "\uE943", "#818CF8", 0, "Code", true), + ["test_loop"] = new("test_loop", "코드 분석", "\uE943", "#818CF8", 1, "Code"), + ["git_tool"] = new("git_tool", "코드 분석", "\uE943", "#818CF8", 0, "Code"), + ["snippet_runner"] = new("snippet_runner", "코드 분석", "\uE943", "#818CF8", 1, "Code"), + ["diff_preview"] = new("diff_preview", "코드 분석", "\uE943", "#818CF8", 1, "Code", true), + ["project_rules"] = new("project_rules", "코드 분석", "\uE943", "#818CF8", 1, "Code"), + + ["excel_create"] = new("excel_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["docx_create"] = new("docx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["csv_create"] = new("csv_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["markdown_create"] = new("markdown_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["html_create"] = new("html_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["chart_create"] = new("chart_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["script_create"] = new("script_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["pptx_create"] = new("pptx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["document_plan"] = new("document_plan", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["document_assemble"] = new("document_assemble", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"), + ["document_review"] = new("document_review", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"), + ["format_convert"] = new("format_convert", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"), + ["template_render"] = new("template_render", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"), + ["text_summarize"] = new("text_summarize", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"), + + ["json_tool"] = new("json_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["regex_tool"] = new("regex_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["diff_tool"] = new("diff_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["base64_tool"] = new("base64_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["hash_tool"] = new("hash_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["datetime_tool"] = new("datetime_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["math_eval"] = new("math_eval", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + ["sql_tool"] = new("sql_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"), + ["xml_tool"] = new("xml_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"), + ["data_pivot"] = new("data_pivot", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"), + ["encoding_tool"] = new("encoding_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true), + + ["clipboard_tool"] = new("clipboard_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"), + ["notify_tool"] = new("notify_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"), + ["env_tool"] = new("env_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code", true), + ["zip_tool"] = new("zip_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"), + ["http_tool"] = new("http_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"), + ["image_analyze"] = new("image_analyze", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork"), + + ["spawn_agent"] = new("spawn_agent", "에이전트", "\uE99A", "#F472B6", 2, "Code"), + ["spawn_agents"] = new("spawn_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"), + ["wait_agents"] = new("wait_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"), + ["memory"] = new("memory", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true), + ["skill_manager"] = new("skill_manager", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true), + ["tool_search"] = new("tool_search", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true), + ["user_ask"] = new("user_ask", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code"), + ["mcp_list_resources"] = new("mcp_list_resources", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true), + ["mcp_read_resource"] = new("mcp_read_resource", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true), + ["task_tracker"] = new("task_tracker", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["todo_write"] = new("todo_write", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_create"] = new("task_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_get"] = new("task_get", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_list"] = new("task_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_update"] = new("task_update", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_stop"] = new("task_stop", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["task_output"] = new("task_output", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["enter_worktree"] = new("enter_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["exit_worktree"] = new("exit_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["team_create"] = new("team_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["team_delete"] = new("team_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["cron_create"] = new("cron_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["cron_delete"] = new("cron_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["cron_list"] = new("cron_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["suggest_actions"] = new("suggest_actions", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["checkpoint"] = new("checkpoint", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + ["playbook"] = new("playbook", "에이전트", "\uE99A", "#F472B6", 3, "Code"), + }; + + private static readonly IReadOnlyDictionary s_aliasMap = + BuildAliasMap(); + + public static string Canonicalize(string? toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) + return ""; + + var trimmed = toolName.Trim(); + if (s_metadata.ContainsKey(trimmed)) + return trimmed; + + var normalized = NormalizeToken(trimmed); + return s_aliasMap.TryGetValue(normalized, out var canonical) + ? canonical + : trimmed; + } + + public static string NormalizeToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + Span buffer = stackalloc char[value.Length]; + var idx = 0; + foreach (var ch in value) + { + if (ch is '_' or '-' or ' ') + continue; + buffer[idx++] = char.ToLowerInvariant(ch); + } + return new string(buffer[..idx]); + } + + public static AgentToolMetadata GetMetadata(string? toolName) + { + var canonical = Canonicalize(toolName); + return s_metadata.TryGetValue(canonical, out var metadata) + ? metadata + : new AgentToolMetadata(canonical, "기타", "\uE10C", "#94A3B8"); + } + + public static string? GetTabCategory(string? toolName) + => GetMetadata(toolName).TabCategory; + + public static int GetExposureBucket(string? toolName) + => GetMetadata(toolName).ExposureBucket; + + public static bool IsReadOnly(string? toolName) + => GetMetadata(toolName).IsReadOnly; + + public static IReadOnlyCollection CanonicalizeMany(IEnumerable? names) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (names == null) + return result.ToList().AsReadOnly(); + + foreach (var name in names) + { + var canonical = Canonicalize(name); + if (!string.IsNullOrWhiteSpace(canonical)) + result.Add(canonical); + } + + return result.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList().AsReadOnly(); + } + + public static Dictionary CanonicalizePermissionMap(IDictionary? permissions) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (permissions == null) + return result; + + foreach (var kv in permissions) + { + var normalizedKey = CanonicalizePermissionKey(kv.Key); + if (!string.IsNullOrWhiteSpace(normalizedKey)) + result[normalizedKey] = kv.Value; + } + + return result; + } + + public static List CanonicalizeHooks(IEnumerable? hooks) + { + var result = new List(); + if (hooks == null) + return result; + + foreach (var hook in hooks) + { + result.Add(new AgentHookEntry + { + Name = hook.Name, + ToolName = CanonicalizeHookTarget(hook.ToolName), + Timing = hook.Timing, + ScriptPath = hook.ScriptPath, + Arguments = hook.Arguments, + Enabled = hook.Enabled, + }); + } + + return result; + } + + public static string CanonicalizeHookTarget(string? toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) + return "*"; + + var trimmed = toolName.Trim(); + return string.Equals(trimmed, "*", StringComparison.Ordinal) + ? trimmed + : Canonicalize(trimmed); + } + + private static string CanonicalizePermissionKey(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + return ""; + + var trimmed = key.Trim(); + if (string.Equals(trimmed, "*", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmed, "default", StringComparison.OrdinalIgnoreCase)) + return trimmed.ToLowerInvariant(); + + var atIndex = trimmed.IndexOf('@'); + if (atIndex > 0 && atIndex < trimmed.Length - 1) + return $"{Canonicalize(trimmed[..atIndex])}@{trimmed[(atIndex + 1)..].Trim()}"; + + var pipeIndex = trimmed.IndexOf('|'); + if (pipeIndex > 0 && pipeIndex < trimmed.Length - 1) + return $"{Canonicalize(trimmed[..pipeIndex])}|{trimmed[(pipeIndex + 1)..].Trim()}"; + + var openIndex = trimmed.IndexOf('('); + if (openIndex > 0 && trimmed.EndsWith(")", StringComparison.Ordinal)) + return $"{Canonicalize(trimmed[..openIndex])}{trimmed[openIndex..]}"; + + return Canonicalize(trimmed); + } + + private static IReadOnlyDictionary BuildAliasMap() + { + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var metadata in s_metadata.Values) + { + aliases[NormalizeToken(metadata.CanonicalName)] = metadata.CanonicalName; + } + + RegisterAliases(aliases, "zip_tool", "zip"); + RegisterAliases(aliases, "json_tool", "json"); + RegisterAliases(aliases, "regex_tool", "regex"); + RegisterAliases(aliases, "base64_tool", "base64"); + RegisterAliases(aliases, "hash_tool", "hash"); + RegisterAliases(aliases, "datetime_tool", "datetime"); + RegisterAliases(aliases, "math_eval", "math", "math_tool"); + RegisterAliases(aliases, "encoding_tool", "encoding"); + RegisterAliases(aliases, "http_tool", "http", "webfetch", "websearch"); + RegisterAliases(aliases, "clipboard_tool", "clipboard"); + RegisterAliases(aliases, "notify_tool", "notify", "sendmessage"); + RegisterAliases(aliases, "env_tool", "env"); + RegisterAliases(aliases, "git_tool", "git"); + RegisterAliases(aliases, "lsp_code_intel", "lsp"); + RegisterAliases(aliases, "project_rules", "project_rule", "config"); + RegisterAliases(aliases, "snippet_runner", "snippet_run"); + RegisterAliases(aliases, "search_codebase", "code_search", "codesearch"); + RegisterAliases(aliases, "script_create", "batch_create", "batch_skill"); + RegisterAliases(aliases, "markdown_create", "md_create", "markdown_skill"); + RegisterAliases(aliases, "excel_create", "xlsx_create", "excel_skill"); + RegisterAliases(aliases, "docx_create", "docx_skill"); + RegisterAliases(aliases, "csv_create", "csv_skill"); + RegisterAliases(aliases, "html_create", "html_skill"); + RegisterAliases(aliases, "chart_create", "chart_skill"); + RegisterAliases(aliases, "pptx_create", "pptx_skill"); + RegisterAliases(aliases, "document_plan", "document_planner"); + RegisterAliases(aliases, "document_assemble", "document_assembler"); + RegisterAliases(aliases, "spawn_agent", "sub_agent", "agent", "task", "spawnagent"); + RegisterAliases(aliases, "spawn_agents", "batchagent", "spawnagents"); + RegisterAliases(aliases, "tool_search", "toolsearch"); + RegisterAliases(aliases, "user_ask", "askuserquestion"); + RegisterAliases(aliases, "skill_manager", "skill"); + RegisterAliases(aliases, "mcp_list_resources", "listmcpresourcestool"); + RegisterAliases(aliases, "mcp_read_resource", "readmcpresourcetool"); + RegisterAliases(aliases, "process", "bash", "shell", "terminal", "run", "powershell", "shellcommand", "execute"); + RegisterAliases(aliases, "glob", "ls", "listfiles", "list_files", "globfiles", "glob_files", "search_files", "find"); + RegisterAliases(aliases, "grep", "grep_tool", "greptool", "rg", "ripgrep", "search_content"); + RegisterAliases(aliases, "file_read", "read", "readfile", "read_file"); + RegisterAliases(aliases, "file_write", "write", "writefile", "write_file"); + RegisterAliases(aliases, "file_edit", "edit", "editfile", "edit_file"); + + return aliases; + } + + private static void RegisterAliases(IDictionary aliases, string canonicalName, params string[] values) + { + foreach (var value in values) + aliases[NormalizeToken(value)] = canonicalName; + } +} diff --git a/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs b/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs index bddbcd0..baa8c18 100644 --- a/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs +++ b/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs @@ -81,6 +81,7 @@ internal static class AgentTranscriptDisplayCatalog "task_stop" => "작업 중지", "task_output" => "작업 출력", "spawn_agent" => "서브에이전트", + "spawn_agents" => "배치 에이전트", "wait_agents" => "에이전트 대기", _ => normalized.Replace('_', ' ').Trim(), }; @@ -416,7 +417,7 @@ internal static class AgentTranscriptDisplayCatalog => "제안", "process" or "bash" or "powershell" => "명령", - "spawn_agent" or "wait_agents" + "spawn_agent" or "spawn_agents" or "wait_agents" => "에이전트", "web_fetch" or "http" => "웹", diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs index d6319a8..48527f9 100644 --- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -5,8 +5,8 @@ namespace AxCopilot.Services.Agent; /// /// AX Agent execution-prep engine. -/// Inspired by the `claw-code` split between input preparation and session execution, -/// so the UI layer stops owning message assembly and final assistant commit logic. +/// UI 레이어가 메시지 조립과 최종 어시스턴트 커밋 로직을 직접 소유하지 않도록 +/// 입력 준비(preparation)와 세션 실행(execution)을 분리하는 패턴입니다. /// public sealed class AxAgentExecutionEngine { diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 4b052f3..2f445c2 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -394,7 +394,7 @@ public static class ContextCondenser /// /// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다. - /// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다. + /// LLM 호출 전에 토큰을 한 번 더 줄이는 마이크로 압축 단계입니다. /// private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List messages) { @@ -653,6 +653,10 @@ public static class ContextCondenser var content = message.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) return false; + // P3: 세션 학습 메시지는 압축 대상에서 제외 — 매 반복 갱신되므로 항상 보존 + if (string.Equals(message.MetaKind, "session_learnings", StringComparison.OrdinalIgnoreCase)) + return false; + return message.MetaKind != null || content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal) || content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) diff --git a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs index 5d9aa4a..6e5bb08 100644 --- a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs @@ -218,7 +218,9 @@ public class DocumentAssemblerTool : IAgentTool var (heading, content, level) = sections[i]; var tag = level <= 1 ? "h2" : "h3"; sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}"); - sb.AppendLine($"
{content}
"); + // LLM이 생성한 깨진 태그 자동 수정 + var sanitized = HtmlSkill.SanitizeHtmlTagsPublic(content); + sb.AppendLine($"
{sanitized}
"); } sb.AppendLine(""); diff --git a/src/AxCopilot/Services/Agent/ExecutionPolicyOverlay.cs b/src/AxCopilot/Services/Agent/ExecutionPolicyOverlay.cs new file mode 100644 index 0000000..cd792c3 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ExecutionPolicyOverlay.cs @@ -0,0 +1,41 @@ +using static AxCopilot.Services.Agent.ModelExecutionProfileCatalog; + +namespace AxCopilot.Services.Agent; + +/// +/// 기존 ExecutionPolicy 위에 적용할 sparse override. +/// null인 필드는 base policy 값을 그대로 사용합니다. +/// +public sealed record ExecutionPolicyOverlay( + double? ToolTemperatureCap = null, + int? NoToolResponseThreshold = null, + int? NoToolRecoveryMaxRetries = null, + bool? ForceInitialToolCall = null, + bool? EnableCodeQualityGates = null, + bool? EnableDocumentVerificationGate = null, + bool? ReduceEarlyMemoryPressure = null, + int? MaxParallelReadBatch = null +); + +/// +/// base ExecutionPolicy + overlay 병합 유틸. +/// +public static class ExecutionPolicyMerger +{ + public static ExecutionPolicy Apply(ExecutionPolicy basePolicy, ExecutionPolicyOverlay? overlay) + { + if (overlay is null) return basePolicy; + + return basePolicy with + { + ToolTemperatureCap = overlay.ToolTemperatureCap ?? basePolicy.ToolTemperatureCap, + NoToolResponseThreshold = overlay.NoToolResponseThreshold ?? basePolicy.NoToolResponseThreshold, + NoToolRecoveryMaxRetries = overlay.NoToolRecoveryMaxRetries ?? basePolicy.NoToolRecoveryMaxRetries, + ForceInitialToolCall = overlay.ForceInitialToolCall ?? basePolicy.ForceInitialToolCall, + EnableCodeQualityGates = overlay.EnableCodeQualityGates ?? basePolicy.EnableCodeQualityGates, + EnableDocumentVerificationGate = overlay.EnableDocumentVerificationGate ?? basePolicy.EnableDocumentVerificationGate, + ReduceEarlyMemoryPressure = overlay.ReduceEarlyMemoryPressure ?? basePolicy.ReduceEarlyMemoryPressure, + MaxParallelReadBatch = overlay.MaxParallelReadBatch ?? basePolicy.MaxParallelReadBatch, + }; + } +} diff --git a/src/AxCopilot/Services/Agent/FileEditTool.cs b/src/AxCopilot/Services/Agent/FileEditTool.cs index 046d9c7..4719899 100644 --- a/src/AxCopilot/Services/Agent/FileEditTool.cs +++ b/src/AxCopilot/Services/Agent/FileEditTool.cs @@ -1,34 +1,52 @@ -using System.IO; +using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; -/// 파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴). +/// +/// 파일의 특정 부분을 수정하는 도구. +/// 두 가지 모드 지원: +/// 1. 기존 모드: old_string → new_string 패턴 매칭 +/// 2. 앵커 모드: hash anchor 위치 기반 편집 (pos 파라미터 사용) +/// public class FileEditTool : IAgentTool { public string Name => "file_edit"; - public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique."; + public string Description => + "Edit a file. Two modes:\n" + + "1. String mode: old_string + new_string (replace exact match). Set replace_all=true for all occurrences.\n" + + "2. Anchor mode: Use pos (e.g. \"11#VK\") from file_read hash_anchor output + op (replace/delete/insert_before/insert_after) + lines. " + + "Hash anchors detect stale edits — if the file changed since you read it, the edit is rejected."; public ToolParameterSchema Parameters => new() { Properties = new() { + // ── 공통 ── ["path"] = new() { Type = "string", Description = "File path to edit" }, - ["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" }, - ["new_string"] = new() { Type = "string", Description = "Replacement string" }, + + // ── 기존 string 모드 ── + ["old_string"] = new() { Type = "string", Description = "Exact string to find and replace (string mode)" }, + ["new_string"] = new() { Type = "string", Description = "Replacement string (string mode)" }, ["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." }, + + // ── 앵커 모드 ── + ["pos"] = new() { Type = "string", Description = "Hash-anchored position, e.g. \"11#VK\" or \"11#VK-15#MB\" for range (anchor mode)" }, + ["op"] = new() { Type = "string", Description = "Operation: replace, delete, insert_before, insert_after (anchor mode, default: replace)" }, + ["lines"] = new() + { + Type = "array", + Description = "New lines to insert/replace (anchor mode). Each element is a string.", + Items = new() { Type = "string" } + }, }, - Required = ["path", "old_string", "new_string"] + Required = ["path"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { 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); if (!context.IsPathAllowed(fullPath)) @@ -40,6 +58,248 @@ public class FileEditTool : IAgentTool if (!await context.CheckWritePermissionAsync(Name, fullPath)) return ToolResult.Fail($"쓰기 권한 거부: {fullPath}"); + // 모드 판별: pos 파라미터가 있으면 앵커 모드 + if (args.SafeTryGetProperty("pos", out var posEl) && !string.IsNullOrWhiteSpace(posEl.SafeGetString())) + return await ExecuteAnchorModeAsync(args, fullPath, posEl.SafeGetString()!, ct); + + return await ExecuteStringModeAsync(args, fullPath, ct); + } + + // ════════════════════════════════════════════════════════════ + // 앵커 모드 (Hash-Anchored Edits) + // ════════════════════════════════════════════════════════════ + + private async Task ExecuteAnchorModeAsync( + JsonElement args, string fullPath, string posStr, CancellationToken ct) + { + try + { + var op = "replace"; + if (args.SafeTryGetProperty("op", out var opEl)) + op = opEl.SafeGetString()?.ToLowerInvariant() ?? "replace"; + + // 새 라인 파싱 + var newLines = new List(); + if (args.SafeTryGetProperty("lines", out var linesEl) && linesEl.ValueKind == JsonValueKind.Array) + { + foreach (var item in linesEl.EnumerateArray()) + newLines.Add(item.SafeGetString() ?? ""); + } + + // op=delete 외에는 lines 필수 + if (op != "delete" && newLines.Count == 0) + return ToolResult.Fail("lines array is required for replace/insert operations."); + + // 파일 읽기 + var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct); + var fileLines = TextFileCodec.SplitLines(read.Text); + + // 위치 파싱 (단일: "11#VK", 범위: "11#VK-15#MB") + var dashIdx = posStr.IndexOf('-', 1); // 첫 글자 이후부터 검색 (음수 라인번호 방지) + int startLine, endLine; + string startAnchor, endAnchor; + + if (dashIdx > 0) + { + // 범위 + if (!HashAnchor.TryParsePosition(posStr[..dashIdx], out startLine, out startAnchor)) + return ToolResult.Fail($"Invalid start position: {posStr[..dashIdx]}. Format: LINENUM#HASH"); + if (!HashAnchor.TryParsePosition(posStr[(dashIdx + 1)..], out endLine, out endAnchor)) + return ToolResult.Fail($"Invalid end position: {posStr[(dashIdx + 1)..]}. Format: LINENUM#HASH"); + } + else + { + // 단일 라인 + if (!HashAnchor.TryParsePosition(posStr, out startLine, out startAnchor)) + return ToolResult.Fail($"Invalid position: {posStr}. Format: LINENUM#HASH (e.g. 11#VK)"); + endLine = startLine; + endAnchor = startAnchor; + } + + if (startLine > endLine) + return ToolResult.Fail($"Start line ({startLine}) must be ≤ end line ({endLine})."); + + // 해시 앵커 검증 (스테일 감지) + var positions = new List<(int, string)> { (startLine, startAnchor) }; + if (endLine != startLine) + positions.Add((endLine, endAnchor)); + + var (allValid, errorDetail) = HashAnchor.ValidatePositions(fileLines, positions); + if (!allValid) + return ToolResult.Fail(errorDetail!); + + // 편집 적용 + var result = ApplyAnchorOperation(fileLines, op, startLine, endLine, newLines); + if (!result.Success) + return ToolResult.Fail(result.Error!); + + // diff 생성 (변경 전후) + var diffPreview = GenerateAnchorDiff(fileLines, result.NewFileLines!, startLine, endLine, op, fullPath); + + // 파일 쓰기 + var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom); + var newContent = string.Join("\n", result.NewFileLines!); + await TextFileCodec.WriteAllTextAsync(fullPath, newContent, writeEncoding, ct); + + // 변경된 영역의 새 앵커를 반환 (연쇄 편집 지원) + var updatedAnchors = BuildUpdatedAnchors(result.NewFileLines!, startLine, newLines.Count, op); + + return ToolResult.Ok( + $"파일 수정 완료 (anchored {op}): {fullPath}\n\n{diffPreview}\n\n{updatedAnchors}", + fullPath); + } + catch (Exception ex) + { + return ToolResult.Fail($"앵커 편집 실패: {ex.Message}"); + } + } + + private readonly record struct AnchorEditResult(bool Success, string? Error, string[]? NewFileLines); + + private static AnchorEditResult ApplyAnchorOperation( + string[] fileLines, string op, int startLine, int endLine, List newLines) + { + var result = new List(fileLines.Length + newLines.Count); + var startIdx = startLine - 1; // 0-based + var endIdx = endLine - 1; // 0-based + + switch (op) + { + case "replace": + // 앞부분 + for (int i = 0; i < startIdx; i++) + result.Add(fileLines[i]); + // 새 라인 + result.AddRange(newLines); + // 뒷부분 + for (int i = endIdx + 1; i < fileLines.Length; i++) + result.Add(fileLines[i]); + break; + + case "delete": + for (int i = 0; i < startIdx; i++) + result.Add(fileLines[i]); + for (int i = endIdx + 1; i < fileLines.Length; i++) + result.Add(fileLines[i]); + break; + + case "insert_before": + for (int i = 0; i < startIdx; i++) + result.Add(fileLines[i]); + result.AddRange(newLines); + for (int i = startIdx; i < fileLines.Length; i++) + result.Add(fileLines[i]); + break; + + case "insert_after": + for (int i = 0; i <= endIdx; i++) + result.Add(fileLines[i]); + result.AddRange(newLines); + for (int i = endIdx + 1; i < fileLines.Length; i++) + result.Add(fileLines[i]); + break; + + default: + return new(false, $"Unknown operation: {op}. Use replace, delete, insert_before, or insert_after.", null); + } + + return new(true, null, result.ToArray()); + } + + /// 변경 후 영역의 새 앵커를 생성하여 연쇄 편집을 지원합니다. + private static string BuildUpdatedAnchors(string[] newFileLines, int startLine, int newLineCount, string op) + { + if (newFileLines.Length == 0) return ""; + + // 변경 영역 근처 라인의 새 앵커 + var sb = new StringBuilder(); + sb.AppendLine("Updated anchors (for chained edits):"); + + var showStart = Math.Max(0, startLine - 2); + var showEnd = op switch + { + "delete" => Math.Min(newFileLines.Length, startLine + 2), + _ => Math.Min(newFileLines.Length, startLine + newLineCount + 1), + }; + + var anchors = HashAnchor.ComputeAnchors(newFileLines); + for (int i = showStart; i < showEnd; i++) + { + sb.AppendLine(HashAnchor.FormatLine(newFileLines[i], i + 1, anchors[i])); + } + + return sb.ToString().TrimEnd(); + } + + /// 앵커 편집의 diff 프리뷰를 생성합니다. + private static string GenerateAnchorDiff( + string[] oldLines, string[] newLines, int startLine, int endLine, string op, string filePath) + { + var sb = new StringBuilder(); + var fileName = Path.GetFileName(filePath); + sb.AppendLine($"--- {fileName} (before)"); + sb.AppendLine($"+++ {fileName} (after)"); + + const int ctx = 2; + var startIdx = startLine - 1; + var endIdx = endLine - 1; + var ctxStart = Math.Max(0, startIdx - ctx); + + sb.AppendLine($"@@ -{ctxStart + 1} @@"); + + // 앞쪽 컨텍스트 + for (int i = ctxStart; i < startIdx && i < oldLines.Length; i++) + sb.AppendLine($" {oldLines[i].TrimEnd('\r')}"); + + // 삭제된 라인 + if (op is "replace" or "delete") + { + for (int i = startIdx; i <= endIdx && i < oldLines.Length; i++) + sb.AppendLine($"-{oldLines[i].TrimEnd('\r')}"); + } + + // 추가된 라인 (new content) + if (op is "replace" or "insert_before" or "insert_after") + { + // 새 파일에서 삽입된 라인 범위를 추적 + var insertStart = op switch + { + "insert_before" => startIdx, + "insert_after" => endIdx + 1, + _ => startIdx, // replace + }; + + var insertCount = op == "replace" + ? newLines.Length - oldLines.Length + (endIdx - startIdx + 1) + : newLines.Length - oldLines.Length; + + // 심플하게: old→new 차이를 보여줌 + for (int i = insertStart; i < insertStart + Math.Max(0, insertCount) && i < newLines.Length; i++) + sb.AppendLine($"+{newLines[i].TrimEnd('\r')}"); + } + + // 뒤쪽 컨텍스트 + var afterStart = endIdx + 1; + var afterEnd = Math.Min(oldLines.Length, afterStart + ctx); + for (int i = afterStart; i < afterEnd; i++) + sb.AppendLine($" {oldLines[i].TrimEnd('\r')}"); + + return sb.ToString().TrimEnd(); + } + + // ════════════════════════════════════════════════════════════ + // 기존 String 모드 (하위 호환) + // ════════════════════════════════════════════════════════════ + + private async Task ExecuteStringModeAsync(JsonElement args, string fullPath, CancellationToken ct) + { + var oldStr = args.SafeTryGetProperty("old_string", out var osEl) ? osEl.SafeGetString() ?? "" : ""; + var newStr = args.SafeTryGetProperty("new_string", out var nsEl) ? nsEl.SafeGetString() ?? "" : ""; + var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean(); + + if (string.IsNullOrEmpty(oldStr)) + return ToolResult.Fail("old_string이 필요합니다. 앵커 모드를 사용하려면 pos 파라미터를 지정하세요."); + try { var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct); @@ -48,15 +308,13 @@ public class FileEditTool : IAgentTool var count = CountOccurrences(content, oldStr); if (count == 0) { - // 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로 전체 교체하거나, 고유한 문자열을 지정하세요."); - // Diff Preview: 변경 내용을 컨텍스트와 함께 표시 - var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath); + var diffPreview = GenerateStringDiff(content, oldStr, newStr, fullPath); var updated = content.Replace(oldStr, newStr); var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom); @@ -74,13 +332,12 @@ public class FileEditTool : IAgentTool } /// 변경 전/후 diff를 생성합니다 (unified diff 스타일). - private static string GenerateDiff(string content, string oldStr, string newStr, string filePath) + private static string GenerateStringDiff(string content, string oldStr, string newStr, string filePath) { var lines = content.Split('\n'); var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal); if (matchIdx < 0) return ""; - // 변경 시작 줄 번호 계산 var startLine = content[..matchIdx].Count(c => c == '\n'); var oldLines = oldStr.Split('\n'); var newLines = newStr.Split('\n'); @@ -90,26 +347,21 @@ public class FileEditTool : IAgentTool sb.AppendLine($"--- {fileName} (before)"); sb.AppendLine($"+++ {fileName} (after)"); - // 컨텍스트 라인 수 const int ctx = 2; var ctxStart = Math.Max(0, startLine - ctx); var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx); sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@"); - // 앞쪽 컨텍스트 for (int i = ctxStart; i < startLine; i++) sb.AppendLine($" {lines[i].TrimEnd('\r')}"); - // 삭제 라인 foreach (var line in oldLines) sb.AppendLine($"-{line.TrimEnd('\r')}"); - // 추가 라인 foreach (var line in newLines) sb.AppendLine($"+{line.TrimEnd('\r')}"); - // 뒤쪽 컨텍스트 var afterEnd = startLine + oldLines.Length; for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++) sb.AppendLine($" {lines[i].TrimEnd('\r')}"); @@ -124,7 +376,6 @@ public class FileEditTool : IAgentTool var sb = new StringBuilder(); - // 유사 행 검색: old_string의 첫 줄로 근사 매치 시도 var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r'); if (firstLine.Length >= 8) { @@ -144,7 +395,6 @@ public class FileEditTool : IAgentTool } } - // 파일이 짧으면 전체 내용 표시 if (sb.Length == 0) { var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content; diff --git a/src/AxCopilot/Services/Agent/FileReadTool.cs b/src/AxCopilot/Services/Agent/FileReadTool.cs index c6bcaf1..dba4038 100644 --- a/src/AxCopilot/Services/Agent/FileReadTool.cs +++ b/src/AxCopilot/Services/Agent/FileReadTool.cs @@ -3,11 +3,14 @@ using System.Text.Json; namespace AxCopilot.Services.Agent; -/// 파일 내용을 읽어 반환하는 도구. +/// 파일 내용을 읽어 반환하는 도구. hash_anchor=true 시 라인별 해시 앵커를 포함합니다. public class FileReadTool : IAgentTool { public string Name => "file_read"; - public string Description => "Read the contents of a file. Returns the text content with line numbers."; + public string Description => + "Read the contents of a file. Returns the text content with line numbers.\n" + + "When hash_anchor=true, each line includes a 2-char hash anchor (e.g. \"11#VK| code\") " + + "that can be used with file_edit's anchored mode for precise, conflict-safe edits."; public ToolParameterSchema Parameters => new() { @@ -16,6 +19,7 @@ public class FileReadTool : IAgentTool ["path"] = new() { Type = "string", Description = "File path to read (absolute or relative to work folder)" }, ["offset"] = new() { Type = "integer", Description = "Starting line number (1-based). Optional, default 1." }, ["limit"] = new() { Type = "integer", Description = "Maximum number of lines to read. Optional, default 500." }, + ["hash_anchor"] = new() { Type = "boolean", Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting." }, }, Required = ["path"] }; @@ -28,6 +32,9 @@ public class FileReadTool : IAgentTool var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1; var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500; + // hash_anchor: 명시적 파라미터 > 전역 설정 + var useHashAnchor = ResolveHashAnchorMode(args); + var fullPath = ResolvePath(path, context.WorkFolder); if (!context.IsPathAllowed(fullPath)) @@ -44,10 +51,21 @@ public class FileReadTool : IAgentTool var startIdx = Math.Max(0, offset - 1); var endIdx = Math.Min(total, startIdx + limit); - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})"); - for (int i = startIdx; i < endIdx; i++) - sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}"); + var sb = new System.Text.StringBuilder((endIdx - startIdx) * 80); + + if (useHashAnchor) + { + var anchors = HashAnchor.ComputeAnchors(lines); + sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName}, hash_anchor=on)"); + for (int i = startIdx; i < endIdx; i++) + sb.AppendLine(HashAnchor.FormatLine(lines[i], i + 1, anchors[i])); + } + else + { + sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})"); + for (int i = startIdx; i < endIdx; i++) + sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}"); + } return Task.FromResult(ToolResult.Ok(sb.ToString(), fullPath)); } @@ -64,4 +82,18 @@ public class FileReadTool : IAgentTool return Path.GetFullPath(Path.Combine(workFolder, path)); return Path.GetFullPath(path); } + + /// + /// hash_anchor 모드를 결정합니다. + /// 명시적 파라미터 > 전역 설정(EnableHashAnchoredEdits). + /// + internal static bool ResolveHashAnchorMode(JsonElement args) + { + if (args.SafeTryGetProperty("hash_anchor", out var haEl)) + return haEl.GetBoolean(); + + // 전역 설정 참조 + var app = System.Windows.Application.Current as App; + return app?.SettingsService?.Settings.Llm.EnableHashAnchoredEdits ?? false; + } } diff --git a/src/AxCopilot/Services/Agent/HashAnchor.cs b/src/AxCopilot/Services/Agent/HashAnchor.cs new file mode 100644 index 0000000..ef56bf8 --- /dev/null +++ b/src/AxCopilot/Services/Agent/HashAnchor.cs @@ -0,0 +1,167 @@ +using System.IO.Hashing; +using System.Text; + +namespace AxCopilot.Services.Agent; + +/// +/// Hash-anchored edits 인프라. +/// 파일 읽기 시 각 라인에 2글자 해시 앵커를 부여하고, +/// 편집 시 해시 앵커로 대상 라인을 정확히 식별 + 스테일 감지. +/// +internal static class HashAnchor +{ + // oh-my-openagent 호환 알파벳 (16자 → 2글자 조합 = 256가지) + private const string Alphabet = "ZPMQVRWSNKTXJBYH"; + + /// + /// 라인 내용 + 라인 번호(1-based)로부터 2글자 해시 앵커를 생성합니다. + /// + public static string ComputeAnchor(string lineContent, int lineNumber) + { + // 정규화: CR 제거 + 후행 공백 제거 + var normalized = lineContent.TrimEnd('\r').TrimEnd(); + + // 빈 줄/공백만 있는 줄 → 라인번호를 시드로 사용 (충돌 감소) + uint hash; + if (IsBlankOrWhitespace(normalized)) + { + Span numBuf = stackalloc byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(numBuf, lineNumber); + hash = XxHash32(numBuf); + } + else + { + var bytes = Encoding.UTF8.GetBytes(normalized); + hash = XxHash32(bytes); + } + + // 8비트로 축소 → 알파벳 2글자로 인코딩 + var reduced = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24)); + var hi = Alphabet[(reduced >> 4) & 0x0F]; + var lo = Alphabet[reduced & 0x0F]; + return $"{hi}{lo}"; + } + + /// + /// 파일 전체 라인에 대해 해시 앵커를 배열로 반환합니다. + /// anchors[i]는 lines[i]의 앵커 (0-based). + /// + public static string[] ComputeAnchors(string[] lines) + { + var anchors = new string[lines.Length]; + for (int i = 0; i < lines.Length; i++) + anchors[i] = ComputeAnchor(lines[i], i + 1); + return anchors; + } + + /// + /// "LINENUM#HASH" 형식의 위치 문자열을 파싱합니다. + /// 예: "11#VK" → lineNumber=11, anchor="VK" + /// + public static bool TryParsePosition(string pos, out int lineNumber, out string anchor) + { + lineNumber = 0; + anchor = ""; + + if (string.IsNullOrWhiteSpace(pos)) + return false; + + var hashIdx = pos.IndexOf('#'); + if (hashIdx < 1 || hashIdx >= pos.Length - 1) + return false; + + if (!int.TryParse(pos.AsSpan(0, hashIdx), out lineNumber) || lineNumber < 1) + return false; + + anchor = pos[(hashIdx + 1)..].Trim(); + return anchor.Length == 2; + } + + /// + /// 앵커가 현재 파일 라인과 일치하는지 검증합니다. + /// + public static bool Validate(string lineContent, int lineNumber, string expectedAnchor) + { + var actual = ComputeAnchor(lineContent, lineNumber); + return string.Equals(actual, expectedAnchor, StringComparison.Ordinal); + } + + /// + /// 해시 앵커가 포함된 파일 읽기 출력을 생성합니다. + /// 형식: "LINENUM#HASH| content" + /// + public static string FormatLine(string lineContent, int lineNumber, string anchor) + { + return $"{lineNumber}#{anchor}| {lineContent.TrimEnd('\r')}"; + } + + /// + /// 해시 앵커가 포함된 파일 전체 출력을 생성합니다. + /// + public static string FormatLines(string[] lines, string[] anchors, int startIdx, int endIdx) + { + var sb = new StringBuilder((endIdx - startIdx) * 80); + for (int i = startIdx; i < endIdx && i < lines.Length; i++) + { + var lineNum = i + 1; + sb.Append(lineNum); + sb.Append('#'); + sb.Append(anchors[i]); + sb.Append("| "); + sb.AppendLine(lines[i].TrimEnd('\r')); + } + return sb.ToString(); + } + + /// + /// 여러 앵커 위치를 검증하고, 불일치가 있으면 상세 에러를 반환합니다. + /// + public static (bool AllValid, string? ErrorDetail) ValidatePositions( + string[] lines, List<(int LineNumber, string Anchor)> positions) + { + var mismatches = new List(); + foreach (var (lineNum, expectedAnchor) in positions) + { + if (lineNum < 1 || lineNum > lines.Length) + { + mismatches.Add($" Line {lineNum}: out of range (file has {lines.Length} lines)"); + continue; + } + + var actual = ComputeAnchor(lines[lineNum - 1], lineNum); + if (!string.Equals(actual, expectedAnchor, StringComparison.Ordinal)) + { + var preview = lines[lineNum - 1].TrimEnd('\r'); + if (preview.Length > 80) preview = preview[..80] + "..."; + mismatches.Add($" Line {lineNum}: expected #{expectedAnchor}, got #{actual} — \"{preview}\""); + } + } + + if (mismatches.Count == 0) + return (true, null); + + var detail = $"Hash anchor mismatch — file was modified since last read. Re-read the file to get fresh anchors.\n" + + string.Join("\n", mismatches); + return (false, detail); + } + + // ════════════════════════════════════════════ + // 내부 유틸 + // ════════════════════════════════════════════ + + private static bool IsBlankOrWhitespace(string s) + { + foreach (var c in s) + { + if (c != ' ' && c != '\t') + return false; + } + return true; + } + + private static uint XxHash32(ReadOnlySpan data) + { + // System.IO.Hashing 사용 + return System.IO.Hashing.XxHash32.HashToUInt32(data); + } +} diff --git a/src/AxCopilot/Services/Agent/HtmlSkill.cs b/src/AxCopilot/Services/Agent/HtmlSkill.cs index eaefff2..ade2063 100644 --- a/src/AxCopilot/Services/Agent/HtmlSkill.cs +++ b/src/AxCopilot/Services/Agent/HtmlSkill.cs @@ -15,13 +15,15 @@ public class HtmlSkill : IAgentTool { public string Name => "html_create"; public string Description => "Create a styled HTML (.html) document with rich formatting. " + + "REQUIRED: 'title' AND 'body' (HTML string). " + + "If you prefer structured blocks, set body=\"\" and provide 'sections' array instead. " + + "NEVER call this tool with only title — you MUST include body (or sections). " + "Supports: table of contents (toc), cover page, callouts (.callout-info/warning/tip/danger), " + "badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " + "progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " + "and auto section numbering. " + - "Use 'sections' array for structured content (heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi) " + - "instead of raw HTML body. Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " + - "Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard."; + "Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " + + "Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc."; public ToolParameterSchema Parameters => new() { @@ -29,10 +31,17 @@ public class HtmlSkill : IAgentTool { ["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." }, ["title"] = new() { Type = "string", Description = "Document title (shown in browser tab and header)" }, - ["body"] = new() { Type = "string", Description = "HTML body content. Use semantic tags: h2/h3 for sections, " + + ["body"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] HTML body content. " + + "This is the MAIN document content — always include rich, meaningful HTML. " + + "Content depth guideline: at least 5–8 h2 sections, each with 2+ paragraphs of substantive prose (≥2 sentences per paragraph). " + + "Add variety: tables, lists, callouts, charts, KPIs — not just plain text. " + + "IMPORTANT: when 'numbered' is true, DO NOT prefix headings with numbers yourself (e.g. '

1. 개요

') — " + + "the renderer auto-numbers via CSS. Write headings as plain text: '

개요

'. " + + "Use semantic tags: h2/h3 for sections, " + "div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " + "div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " + - "div.timeline>div.timeline-item for timelines, div.progress for progress bars." }, + "div.timeline>div.timeline-item for timelines, div.progress for progress bars. " + + "If you want to use 'sections' array instead, pass body=\"\" (empty string)." }, ["sections"] = new() { Type = "array", @@ -52,7 +61,7 @@ public class HtmlSkill : IAgentTool "170+ built-in icons available. " + "When both body and sections are provided, sections are appended after body." }, - ["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern" }, + ["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc. Default: modern" }, ["accent_color"] = new() { Type = "string", Description = "Hex color string (e.g. '#2E75B6') that overrides the CSS primary/accent color. Affects buttons, headings, borders, chart bars." }, ["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." }, ["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" }, @@ -64,7 +73,7 @@ public class HtmlSkill : IAgentTool Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page." }, }, - Required = ["title"] + Required = ["title", "body"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) @@ -78,11 +87,21 @@ public class HtmlSkill : IAgentTool if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString())) pathEl = default; // 아래에서 title 기반으로 생성 - // body와 sections 둘 다 없으면 오류 - bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null; - bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array; + // body와 sections 둘 다 없으면 오류 — 모델이 재호출 시 참고할 수 있도록 상세 가이드 포함 + bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) + && bodyEl.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(bodyEl.SafeGetString()); + bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) + && sectionsEl.ValueKind == JsonValueKind.Array + && sectionsEl.GetArrayLength() > 0; if (!hasBody && !hasSections) - return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다."); + { + return ToolResult.Fail( + "필수 파라미터 누락: 'body' (HTML 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" + + "다시 호출할 때는 title 외에 반드시 body를 포함하세요. 예:\n" + + "{\"name\":\"html_create\",\"arguments\":{\"title\":\"...\",\"body\":\"

개요

...

상세

...

\",\"mood\":\"modern\"}}\n" + + "sections 배열을 사용하려면 body=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요."); + } var title = titleEl.SafeGetString() ?? "Report"; // path가 없으면 title에서 안전한 파일명 생성 @@ -101,7 +120,10 @@ public class HtmlSkill : IAgentTool 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; + // toc는 명시적으로 true/false를 전달하거나, 생략 시 자동 판단 (body에 h2가 3개 이상이면 true) + var hasTocArg = args.SafeTryGetProperty("toc", out var tocVal) + && (tocVal.ValueKind == JsonValueKind.True || tocVal.ValueKind == JsonValueKind.False); + var useToc = hasTocArg && 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; @@ -149,6 +171,9 @@ public class HtmlSkill : IAgentTool body = hasBody ? body + "\n" + sectionsHtml : sectionsHtml; } + // HTML 태그 위생화 — LLM이 생성한 깨진 태그 자동 수정 + body = SanitizeHtmlTags(body); + // 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가 if (useNumbered) body = AddNumberedClass(body); @@ -156,15 +181,30 @@ public class HtmlSkill : IAgentTool // h2/h3에서 id 속성 자동 부여 (TOC 앵커용) body = EnsureHeadingIds(body); - // TOC 생성 - var tocHtml = useToc ? GenerateToc(body) : ""; + // toc 인자가 명시되지 않았으면 h2 개수로 자동 판단 (3개 이상 → TOC 생성) + if (!hasTocArg && !useToc) + { + var h2Count = Regex.Matches(body, @"= 3) + { + useToc = true; + Services.LogService.Debug($"[html_create] toc 자동 활성화 — h2={h2Count}개 감지"); + } + } + + // TOC 생성 — useNumbered면 TOC 항목도 번호 표시 (본문 CSS 카운터와 일치) + var tocHtml = useToc ? GenerateToc(body, useNumbered) : ""; // 커버 페이지 생성 var coverHtml = hasCover ? GenerateCover(coverVal, title) : ""; + // 다크 테마 기본 무드 결정 + var isDarkDefault = mood is "dark" or "seminar" or "seminar-toc" or "dashboard"; + var defaultTheme = isDarkDefault ? "dark" : "light"; + var sb = new StringBuilder(); sb.AppendLine(""); - sb.AppendLine(""); + sb.AppendLine($""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); @@ -172,6 +212,22 @@ public class HtmlSkill : IAgentTool sb.AppendLine($""); sb.AppendLine(""); sb.AppendLine(""); + + var isSidebarToc = mood == "seminar-toc"; + + // 테마 전환 버튼 + 플로팅 목차 버튼 + sb.AppendLine(""); + if (useToc && !isSidebarToc) + sb.AppendLine(""); + + // seminar-toc: page-wrapper + sidebar TOC 구��� + if (isSidebarToc) + { + sb.AppendLine("
"); + if (useToc && !string.IsNullOrEmpty(tocHtml)) + sb.AppendLine(GenerateSidebarToc(body)); + } + sb.AppendLine("
"); // 커버 페이지 @@ -191,8 +247,8 @@ public class HtmlSkill : IAgentTool // 본문을 body-content로 감싸서 좌우 여백 확보 sb.AppendLine("
"); - // TOC - if (!string.IsNullOrEmpty(tocHtml)) + // TOC (inline — seminar-toc는 사이드바로 이미 출력했으므로 스킵) + if (!string.IsNullOrEmpty(tocHtml) && !isSidebarToc) sb.AppendLine(tocHtml); // 본문 — table 태그에 반응형 래퍼 자동 추가 @@ -208,6 +264,14 @@ public class HtmlSkill : IAgentTool sb.AppendLine("
"); // body-content sb.AppendLine("
"); // container + if (isSidebarToc) + sb.AppendLine("
"); // page-wrapper + + // 테마 전환 + 플로팅 TOC / 스크롤 스파이 스크립트 + sb.AppendLine(TemplateService.ThemeToggleScript); + if (isSidebarToc && useToc) + sb.AppendLine(SidebarTocScrollSpyScript); + sb.AppendLine(""); sb.AppendLine(""); @@ -619,6 +683,50 @@ public class HtmlSkill : IAgentTool catch { return "#" + hex; } } + // ───────────────────────────────────────────────────────────────────────── + // HTML 태그 위생화 — LLM이 생성한 깨진/불일치 태그 자동 수정 + // ───────────────────────────────────────────────────────────────────────── + + /// 외부에서도 호출 가능한 태그 위생화 래퍼. + public static string SanitizeHtmlTagsPublic(string html) => SanitizeHtmlTags(html); + + /// + /// LLM이 생성한 HTML에서 흔한 태그 오류를 자동 수정합니다. + /// 1) </strong: → </strong>: (닫기 '>' 누락) + /// 2) </em: → </em>: (같은 패턴) + /// 3) <span class="...">text</strong> → <span>text</span> (태그 불일치) + /// 4) <strong class="...">text</span> → <strong>text</strong> (태그 불일치) + /// + private static string SanitizeHtmlTags(string html) + { + if (string.IsNullOrEmpty(html)) return html; + + // 패턴 1: ' 누락으로 닫기 태그가 깨진 경우 + // 예: : 한종희 + // ; 내용 + html = Regex.Replace(html, @"a-zA-Z])", "$2"); + + // 패턴 2: text 또는 text → 으로 교정 + // 닫기 태그가 열기 태그와 불일치할 때 — 열기 태그 기준으로 닫기 태그를 수정 + html = Regex.Replace(html, + @"<(span|strong|em|b|i|u|a|code|mark)(\s[^>]*)?>([^<]*)", + match => + { + var openTag = match.Groups[1].Value; + var attrs = match.Groups[2].Value; + var content = match.Groups[3].Value; + var closeTag = match.Groups[4].Value; + + // 열기/닫기 태그가 불일치하면 열기 태그 기준으로 닫기 교정 + if (!string.Equals(openTag, closeTag, StringComparison.OrdinalIgnoreCase)) + return $"<{openTag}{attrs}>{content}"; + + return match.Value; // 일치하면 그대로 + }); + + return html; + } + // ───────────────────────────────────────────────────────────────────────── // print CSS // ───────────────────────────────────────────────────────────────────────── @@ -669,10 +777,11 @@ public class HtmlSkill : IAgentTool }); } - /// h2, h3에 class="numbered" 추가 + /// h2, h3에 class="numbered" 추가. LLM이 본문에 이미 붙여놓은 "1. " / "1-1. " 접두 번호는 제거 (CSS 카운터와 중복 방지). private static string AddNumberedClass(string html) { - return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match => + // 1) h2/h3 여는 태그에 numbered 클래스 부여 + html = Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match => { var tag = match.Groups[1].Value; var attrs = match.Groups[2].Value; @@ -687,10 +796,20 @@ public class HtmlSkill : IAgentTool return $"<{tag}{attrs} class=\"numbered\">"; }); + + // 2) h2/h3 본문 앞에 "1. " / "1-1. " / "1) " 등 기존 번호가 있으면 제거. + // CSS 카운터(h2.numbered::before { content: counter(section) '. '; })와 중복되면 + // "1. 1. 기업 개요"처럼 번호가 두 번 찍힌다. + html = Regex.Replace(html, + @"(<(h[23])\b[^>]*\bclass\s*=\s*""[^""]*\bnumbered\b[^""]*""[^>]*>)\s*(\d+([.\-)]\s*\d+)*[.\-)]\s+)", + "$1", + RegexOptions.IgnoreCase); + + return html; } - /// body HTML에서 h2/h3을 파싱해 목차 HTML 생성 - private static string GenerateToc(string html) + /// body HTML에서 h2/h3을 파싱해 목차 HTML 생성. numbered=true면 본문과 동일한 계층 번호(1., 1-1., ...)를 TOC 항목 앞에 표시. + private static string GenerateToc(string html, bool numbered = false) { var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); @@ -702,6 +821,9 @@ public class HtmlSkill : IAgentTool sb.AppendLine("

📋 목차

"); sb.AppendLine("
    "); + var h2Counter = 0; + var h3Counter = 0; + foreach (Match h in headings) { var level = h.Groups[1].Value.ToLower(); @@ -709,7 +831,24 @@ public class HtmlSkill : IAgentTool // 태그 내부 텍스트에서 HTML 태그 제거 var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim(); var cssClass = level == "h3" ? " class=\"toc-h3\"" : ""; - sb.AppendLine($"{text}"); + + string prefix = ""; + if (numbered) + { + if (level == "h2") + { + h2Counter++; + h3Counter = 0; + prefix = $"{h2Counter}. "; + } + else // h3 + { + h3Counter++; + prefix = $"{Math.Max(1, h2Counter)}-{h3Counter}. "; + } + } + + sb.AppendLine($"{prefix}{text}"); } sb.AppendLine("
"); @@ -717,6 +856,58 @@ public class HtmlSkill : IAgentTool return sb.ToString(); } + /// body HTML에서 h2/h3를 파싱해 고정 사이드바 TOC HTML 생성 (seminar-toc 전용) + private static string GenerateSidebarToc(string html) + { + var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + if (headings.Count == 0) return ""; + + var sb = new StringBuilder(); + sb.AppendLine("
"); + sb.AppendLine("

Table of Contents

"); + sb.AppendLine("
"); + + var sectionNum = 0; + foreach (Match h in headings) + { + var level = h.Groups[1].Value.ToLower(); + var id = h.Groups[2].Value; + var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim(); + if (level == "h2") + { + sectionNum++; + sb.AppendLine($"{sectionNum} {Escape(text)}"); + } + else + { + sb.AppendLine($"{Escape(text)}"); + } + } + + sb.AppendLine("
"); + sb.AppendLine("
"); + return sb.ToString(); + } + + private const string SidebarTocScrollSpyScript = """ + + """; + /// cover 객체에서 커버 페이지 HTML 생성 private static string GenerateCover(JsonElement cover, string fallbackTitle) { diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index 0c8d279..c4d742b 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -98,13 +98,14 @@ public class AgentContext "html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create", "chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint", "process", "build_run", "git_tool", "http_tool", "open_external", "snippet_runner", - "spawn_agent", "test_loop", + "spawn_agent", "spawn_agents", "test_loop", }; private static readonly HashSet DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase) { "process", "build_run", "spawn_agent", + "spawn_agents", "snippet_runner", "test_loop", }; @@ -113,12 +114,12 @@ public class AgentContext "file_write", "file_edit", "file_manage", "html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create", "chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint", - "todo_write", "skill_manager", "project_rule", "task_create", "task_update", "task_stop", - "team_create", "team_delete", "cron_create", "cron_delete", "zip", + "todo_write", "skill_manager", "project_rules", "task_create", "task_update", "task_stop", + "team_create", "team_delete", "cron_create", "cron_delete", "zip_tool", }; private static readonly HashSet ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase) { - "process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "git_tool", + "process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "spawn_agents", "git_tool", }; private readonly object _permissionLock = new(); @@ -126,29 +127,31 @@ public class AgentContext /// 작업 폴더 경로. public string WorkFolder { get; set; } = ""; - /// 파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny - public string Permission { get; init; } = "Default"; + /// 파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny. + /// 실행 중 사용자가 UI에서 권한을 바꾸면 SyncContextFromSettings를 통해 업데이트됨. + public string Permission { get; set; } = "Default"; /// 도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드. - public Dictionary ToolPermissions { get; init; } = new(); + public Dictionary ToolPermissions { get; set; } = new(); /// 차단 경로 패턴 목록. - public List BlockedPaths { get; init; } = new(); + public List BlockedPaths { get; set; } = new(); /// 차단 확장자 목록. - public List BlockedExtensions { get; init; } = new(); + public List BlockedExtensions { get; set; } = new(); /// 현재 활성 탭. "Chat" | "Cowork" | "Code". - public string ActiveTab { get; init; } = "Chat"; + public string ActiveTab { get; set; } = "Chat"; - /// 운영 모드. internal(사내) | external(사외). - public string OperationMode { get; init; } = AxCopilot.Services.OperationModePolicy.InternalMode; + /// 운영 모드. internal(사내) | external(사외). + /// 실행 중 사용자가 설정에서 모드를 바꾸면 SyncContextFromSettings를 통해 업데이트됨. + public string OperationMode { get; set; } = AxCopilot.Services.OperationModePolicy.InternalMode; /// 개발자 모드: 상세 이력 표시. - public bool DevMode { get; init; } + public bool DevMode { get; set; } /// 개발자 모드: 도구 실행 전 매번 사용자 승인 대기. - public bool DevModeStepApproval { get; init; } + public bool DevModeStepApproval { get; set; } /// 권한 확인 콜백 (Ask 모드). 반환값: true=승인, false=거부. public Func>? AskPermission { get; init; } @@ -199,6 +202,17 @@ public class AgentContext public bool IsOutsideWorkspace(string path) { if (string.IsNullOrEmpty(WorkFolder)) return false; + if (string.IsNullOrWhiteSpace(path)) return false; + + // ── 방어: path 형태가 아닌 식별자(도구명 등)가 잘못 전달되면 내부로 간주 ── + // DescribeToolTarget가 primary 없을 때 toolName("html_create")을 반환하는 경로의 fallback. + // 이 경우 Path.GetFullPath는 앱 CWD 기준으로 해석되어 "외부"로 오판될 위험이 있음. + var looksLikePath = path.Contains('/') + || path.Contains('\\') + || Path.IsPathRooted(path) + || Path.HasExtension(path); + if (!looksLikePath) return false; + try { var fullPath = Path.GetFullPath(path); @@ -239,12 +253,15 @@ public class AgentContext public string GetEffectiveToolPermission(string toolName, string? target) { toolName ??= ""; - var normalizedToolName = toolName.Trim(); + var normalizedToolName = AgentToolCatalog.Canonicalize(toolName); if (TryResolvePatternPermission(toolName, target, out var patternPermission)) return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission)); - if (ToolPermissions.TryGetValue(toolName, out var toolPerm) && + if (ToolPermissions.TryGetValue(normalizedToolName, out var toolPerm) && + !string.IsNullOrWhiteSpace(toolPerm)) + return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm)); + if (ToolPermissions.TryGetValue(toolName, out toolPerm) && !string.IsNullOrWhiteSpace(toolPerm)) return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm)); if (ToolPermissions.TryGetValue("*", out var wildcardPerm) && @@ -254,7 +271,7 @@ public class AgentContext !string.IsNullOrWhiteSpace(defaultPerm)) return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm)); - var fallback = SensitiveTools.Contains(toolName) + var fallback = SensitiveTools.Contains(normalizedToolName) ? PermissionModeCatalog.NormalizeGlobalMode(Permission) : PermissionModeCatalog.AcceptEdits; return ResolveModeForTool(normalizedToolName, fallback); @@ -320,7 +337,7 @@ public class AgentContext if (ToolPermissions.Count == 0 || string.IsNullOrWhiteSpace(target)) return false; - var normalizedTool = toolName.Trim(); + var normalizedTool = AgentToolCatalog.Canonicalize(toolName); var normalizedTarget = target.Trim(); foreach (var kv in ToolPermissions) @@ -385,7 +402,7 @@ public class AgentContext var at = trimmed.IndexOf('@'); if (at > 0 && at < trimmed.Length - 1) { - ruleTool = trimmed[..at].Trim(); + ruleTool = AgentToolCatalog.Canonicalize(trimmed[..at].Trim()); rulePattern = trimmed[(at + 1)..].Trim(); return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern); } @@ -393,7 +410,7 @@ public class AgentContext var pipe = trimmed.IndexOf('|'); if (pipe > 0 && pipe < trimmed.Length - 1) { - ruleTool = trimmed[..pipe].Trim(); + ruleTool = AgentToolCatalog.Canonicalize(trimmed[..pipe].Trim()); rulePattern = trimmed[(pipe + 1)..].Trim(); return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern); } @@ -401,7 +418,7 @@ public class AgentContext var open = trimmed.IndexOf('('); if (open > 0 && trimmed.EndsWith(")", StringComparison.Ordinal)) { - ruleTool = trimmed[..open].Trim(); + ruleTool = AgentToolCatalog.Canonicalize(trimmed[..open].Trim()); rulePattern = trimmed[(open + 1)..^1].Trim(); return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern); } @@ -460,9 +477,9 @@ public class AgentContext return ApplyDangerousAutoGuard(toolName, normalizedMode); } - private static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName); + private static bool IsWriteTool(string toolName) => WriteTools.Contains(AgentToolCatalog.Canonicalize(toolName)); - private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(toolName); + private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(AgentToolCatalog.Canonicalize(toolName)); private string ApplyDangerousAutoGuard(string toolName, string permission) { @@ -472,7 +489,7 @@ public class AgentContext if (PermissionModeCatalog.IsAuto(permission) && !PermissionModeCatalog.IsBypassPermissions(permission) && !PermissionModeCatalog.IsDontAsk(permission) - && DangerousAutoTools.Contains(toolName)) + && DangerousAutoTools.Contains(AgentToolCatalog.Canonicalize(toolName))) return PermissionModeCatalog.Default; return permission; diff --git a/src/AxCopilot/Services/Agent/IntentGateService.cs b/src/AxCopilot/Services/Agent/IntentGateService.cs new file mode 100644 index 0000000..8aba7da --- /dev/null +++ b/src/AxCopilot/Services/Agent/IntentGateService.cs @@ -0,0 +1,326 @@ +using static AxCopilot.Services.Agent.AgentLoopService; + +namespace AxCopilot.Services.Agent; + +/// +/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기. +/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합) +/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑 +/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동 +/// +internal sealed class IntentGateService +{ + private readonly ILlmService? _llm; + + /// DetectComplexTask에서 매번 재생성 방지용 정적 배열. + private static readonly string[] Conjunctions = + { + "그리고", "하고", "다음에", "이후에", "그런 다음", + " and then ", " after that ", " also ", " additionally " + }; + + private static readonly string[] ActionVerbs = + { + "해줘", "해 줘", "만들어", "수정해", "분석해", "작성해", + "검토해", "확인해", "추가해", "삭제해", "변경해" + }; + + /// 입력 길이 제한 — 50KB 이상은 잘라서 처리. + private const int MaxInputLength = 50_000; + + public IntentGateService(ILlmService? llm = null) => _llm = llm; + + /// + /// 사용자 쿼리를 분석하여 IntentResult를 생성합니다. + /// + public Task ClassifyAsync( + string userQuery, string? activeTab, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + // 안전 가드: null/과도한 길이 + var safeQuery = userQuery ?? ""; + if (safeQuery.Length > MaxInputLength) + safeQuery = safeQuery[..MaxInputLength]; + + // 한 번만 lowercase 변환 후 하위 메서드에 전달 + var lowerQuery = safeQuery.ToLowerInvariant(); + + // ── Stage 1: 키워드 분류 ── + var taskType = ClassifyTaskTypeKeyword(safeQuery, activeTab); + var (intentCategory, intentConfidence) = IntentDetector.Detect(safeQuery); + + // 종합 confidence: taskType 확정도 + IntentDetector 확신도 가중 평균 + var taskTypeConfidence = ComputeTaskTypeConfidence(taskType, lowerQuery); + var combinedConfidence = Math.Min(1.0, + taskTypeConfidence * 0.6 + intentConfidence * 0.4); + + // ── Stage 2: 프로파일 매핑 ── + var overlay = MapToOverlay(taskType, intentCategory, activeTab); + var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory); + + // ── 복합 요청 감지 (P5 연동) ── + var (isComplex, hint) = DetectComplexTask(lowerQuery); + + var result = new IntentResult( + TaskType: taskType, + IntentCategory: intentCategory, + Confidence: Math.Round(combinedConfidence, 2, MidpointRounding.AwayFromZero), + PolicyOverlay: overlay, + SuggestedScope: scope, + IsComplexTask: isComplex, + DecompositionHint: hint + ); + + return Task.FromResult(result); + } + + // ════════════════════════════════════════════════════════════ + // Stage 1: 키워드 기반 작업 유형 분류 + // ════════════════════════════════════════════════════════════ + + /// + /// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직. + /// + internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab) + { + var q = userQuery ?? ""; + + if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검")) + return "review"; + + if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패")) + return "bugfix"; + + if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선")) + return "refactor"; + + if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서")) + return "docs"; + + if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능")) + return "feature"; + + return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general"; + } + + /// + /// taskType 키워드 매칭 강도로 confidence를 산출합니다. + /// 는 이미 ToLowerInvariant 처리된 문자열입니다. + /// + private static double ComputeTaskTypeConfidence(string taskType, string lowerQuery) + { + // "general"은 폴백이므로 confidence 낮음 + if (string.Equals(taskType, "general", StringComparison.Ordinal)) return 0.3; + + // 직접 매칭 키워드 수 세기 + var hitCount = taskType switch + { + "review" => CountHits(lowerQuery, "review", "리뷰", "검토", "code review", "점검"), + "bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "오류", "버그", "수정", "고쳐", "실패"), + "refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩터링", "정리", "개편"), + "docs" => CountHits(lowerQuery, "report", "document", "보고서", "문서", "제안서"), + "feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"), + _ => 0, + }; + + return hitCount switch + { + >= 3 => 0.95, + 2 => 0.85, + 1 => 0.7, + _ => 0.5, + }; + } + + private static int CountHits(string lower, params string[] keywords) + { + var count = 0; + foreach (var kw in keywords) + { + if (lower.Contains(kw, StringComparison.OrdinalIgnoreCase)) + count++; + } + return count; + } + + // ════════════════════════════════════════════════════════════ + // Stage 2: 프로파일 매핑 + // ════════════════════════════════════════════════════════════ + + /// + /// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다. + /// + private static ExecutionPolicyOverlay? MapToOverlay( + string taskType, string intentCategory, string? activeTab) + { + return (taskType, intentCategory) switch + { + // 코드 수정 관련 + ("bugfix", "coding" or "general") => new( + ToolTemperatureCap: 0.2, + ForceInitialToolCall: true, + EnableCodeQualityGates: true), + + ("bugfix", _) => new( + ToolTemperatureCap: 0.25, + ForceInitialToolCall: true, + EnableCodeQualityGates: true), + + ("feature", "coding" or "general") => new( + ToolTemperatureCap: 0.3, + ForceInitialToolCall: true, + EnableCodeQualityGates: true), + + ("refactor", _) => new( + ToolTemperatureCap: 0.25, + ForceInitialToolCall: true, + EnableCodeQualityGates: true), + + // 문서 생성 + ("docs", "document" or "creative" or "general") => new( + EnableDocumentVerificationGate: true, + ReduceEarlyMemoryPressure: true), + + ("docs", _) => new( + EnableDocumentVerificationGate: true), + + // 리뷰/분석 + ("review", "analysis" or "coding" or "general") => new( + ToolTemperatureCap: 0.3, + EnableCodeQualityGates: true, + ForceInitialToolCall: true), + + ("review", _) => new( + ToolTemperatureCap: 0.3, + ForceInitialToolCall: true), + + // general + 순수 대화 (Chat 탭) + ("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase) + => null, // Chat 탭은 도구 없음, overlay 불필요 + + // general + 문서 의도 + ("general", "document") => new( + EnableDocumentVerificationGate: true), + + // general + 분석 의도 + ("general", "analysis") => new( + ToolTemperatureCap: 0.35, + MaxParallelReadBatch: 8), + + // 기타: base policy 그대로 + _ => null, + }; + } + + // ════════════════════════════════════════════════════════════ + // 탐색 범위 결정 + // ════════════════════════════════════════════════════════════ + + /// + /// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다. + /// 는 이미 ToLowerInvariant 처리된 문자열입니다. + /// + private static ExplorationScope ClassifyScopeFromIntent( + string lowerQuery, string? activeTab, string taskType, string intentCategory) + { + if (string.IsNullOrWhiteSpace(lowerQuery)) + return ExplorationScope.OpenEnded; + + // docs 타입이면서 생성 동사가 있으면 DirectCreation + if (taskType == "docs" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + if (HasCreationVerb(lowerQuery)) + return ExplorationScope.DirectCreation; + } + + // document 인텐트 + 생성 동사 → DirectCreation + if (intentCategory == "document" + && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && HasCreationVerb(lowerQuery)) + return ExplorationScope.DirectCreation; + + // RepoWide + if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체", + "repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 점검")) + return ExplorationScope.RepoWide; + + // Localized + if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') || + ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ", + "bug", "오류", "버그", "예외")) + return ExplorationScope.Localized; + + // TopicBased + if (ContainsAny(lowerQuery, "정리", "요약", "보고서", "주제", "관련", "분석")) + return ExplorationScope.TopicBased; + + // 탭 기반 기본값 + return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + ? ExplorationScope.Localized + : ExplorationScope.OpenEnded; + } + + private static bool HasCreationVerb(string lower) + => ContainsAny(lower, + "작성해", "써줘", "써 줘", "만들어", "생성해", + "만들어줘", "만들어 줘", "생성해줘", "생성해 줘", + "write", "create", "draft", "generate", "compose", + "작성하", "작성을", "생성하", "생성을", + "작성 부탁", "만들어 부탁"); + + // ════════════════════════════════════════════════════════════ + // 복합 요청 감지 (P5 연동) + // ════════════════════════════════════════════════════════════ + + /// + /// 복합 요청을 감지합니다. 는 이미 lowercase 변환된 문자열입니다. + /// + private static (bool IsComplex, string? Hint) DetectComplexTask(string lowerQuery) + { + if (lowerQuery.Length < 20) + return (false, null); + + // 접속사/열거 패턴 감지 (클래스 수준 static readonly 배열 사용) + var conjunctionCount = 0; + foreach (var conj in Conjunctions) + { + if (lowerQuery.Contains(conj, StringComparison.Ordinal)) + conjunctionCount++; + } + + // 동사 열거 패턴 (클래스 수준 static readonly 배열 사용) + var verbCount = 0; + foreach (var verb in ActionVerbs) + { + var idx = 0; + while ((idx = lowerQuery.IndexOf(verb, idx, StringComparison.Ordinal)) >= 0) + { + verbCount++; + idx += verb.Length; + } + } + + if (conjunctionCount >= 2 || verbCount >= 3) + { + return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요."); + } + + return (false, null); + } + + // ════════════════════════════════════════════════════════════ + // 공통 유틸 + // ════════════════════════════════════════════════════════════ + + private static bool ContainsAny(string text, params string[] keywords) + { + foreach (var kw in keywords) + { + if (text.Contains(kw, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } +} diff --git a/src/AxCopilot/Services/Agent/IntentResult.cs b/src/AxCopilot/Services/Agent/IntentResult.cs new file mode 100644 index 0000000..db46bff --- /dev/null +++ b/src/AxCopilot/Services/Agent/IntentResult.cs @@ -0,0 +1,21 @@ +namespace AxCopilot.Services.Agent; + +/// +/// IntentGate 분류 결과. 작업 유형, 인텐트 카테고리, 실행 정책 오버레이를 포함합니다. +/// +internal sealed record IntentResult( + /// 작업 유형: review, bugfix, refactor, feature, docs, general + string TaskType, + /// 인텐트 카테고리: coding, translation, analysis, creative, document, math, general + string IntentCategory, + /// 분류 확신도 0.0~1.0 + double Confidence, + /// 기존 ExecutionPolicy 위에 덮어쓸 sparse override (null이면 base 그대로) + ExecutionPolicyOverlay? PolicyOverlay, + /// 제안 탐색 범위 + AgentLoopService.ExplorationScope SuggestedScope, + /// 복합 요청 감지 (P5 spawn_agents 연동) + bool IsComplexTask, + /// 복합 요청 분해 힌트 (P5 연동) + string? DecompositionHint +); diff --git a/src/AxCopilot/Services/Agent/MarkdownSkill.cs b/src/AxCopilot/Services/Agent/MarkdownSkill.cs index 46f390d..4f090ac 100644 --- a/src/AxCopilot/Services/Agent/MarkdownSkill.cs +++ b/src/AxCopilot/Services/Agent/MarkdownSkill.cs @@ -16,9 +16,11 @@ public class MarkdownSkill : IAgentTool public string Name => "markdown_create"; public string Description => "Create a Markdown (.md) document. " + + "REQUIRED: 'content' (raw markdown string). " + + "Alternative: set content=\"\" and provide 'sections' array for structured blocks. " + + "NEVER call this tool with only title — you MUST include content (or sections). " + "Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " + - "Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents. " + - "Use 'content' for raw markdown (backward compatible)."; + "Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents."; public ToolParameterSchema Parameters => new() { @@ -26,7 +28,8 @@ public class MarkdownSkill : IAgentTool { ["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." }, ["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." }, - ["content"] = new() { Type = "string", Description = "원시 마크다운 내용 (하위 호환). sections가 없을 때 사용합니다." }, + ["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " + + "sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." }, ["sections"] = new() { Type = "array", @@ -51,18 +54,28 @@ public class MarkdownSkill : IAgentTool ["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." }, ["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." }, }, - Required = [] + Required = ["content"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { // ── 필수 파라미터 ────────────────────────────────────────────────── - 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 hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) + && sectionsEl.ValueKind == JsonValueKind.Array + && sectionsEl.GetArrayLength() > 0; + var hasContent = args.SafeTryGetProperty("content", out var contentEl) + && contentEl.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(contentEl.SafeGetString()); var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object; if (!hasSections && !hasContent) - return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다."); + { + return ToolResult.Fail( + "필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" + + "다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" + + "{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" + + "sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요."); + } // path 미제공 시 title에서 자동 생성 string path; diff --git a/src/AxCopilot/Services/Agent/ModelPromptAdapter.cs b/src/AxCopilot/Services/Agent/ModelPromptAdapter.cs new file mode 100644 index 0000000..75025b0 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ModelPromptAdapter.cs @@ -0,0 +1,450 @@ +using System.IO; +using System.Reflection; +using System.Text; + +namespace AxCopilot.Services.Agent; + +/// +/// 모델 패밀리별 프롬프트 전략 어댑터. +/// 모델 이름에서 패밀리를 자동 감지하고, 패밀리별 시스템 프롬프트 변환/보강을 수행합니다. +/// +/// 3단계 프롬프트 수준: +/// - "off": 변환 없음 (기존 동작) +/// - "basic": 가벼운 규칙 추가/재배치 +/// - "detailed": 임베디드 리소스의 모델별 전용 프롬프트 파일 적용 (수백 줄급) +/// +internal static class ModelPromptAdapter +{ + // 임베디드 리소스 캐시 (한 번 로드 후 재사용) + private static readonly Dictionary _detailedPromptCache = new(StringComparer.OrdinalIgnoreCase); + private static bool _detailedPromptsLoaded; + private static readonly object _loadLock = new(); + + // ════════════════════════════════════════════════════════════ + // 모델 패밀리 감지 + // ════════════════════════════════════════════════════════════ + + /// + /// 모델명/별칭에서 패밀리를 자동 감지합니다. + /// 매칭되지 않으면 "default"를 반환합니다. + /// + public static string DetectModelFamily(string? modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) + return "default"; + + var lower = modelName.ToLowerInvariant(); + + if (lower.Contains("qwen")) return "qwen"; + if (lower.Contains("deepseek")) return "deepseek"; + if (lower.Contains("kimi") || lower.Contains("moonshot") || lower.StartsWith("k1")) return "kimi"; + if (lower.Contains("gemma")) return "gemma"; + if (lower.Contains("llama")) return "llama"; + if (lower.Contains("mistral") || lower.Contains("mixtral")) return "mistral"; + if (lower.StartsWith("yi-") || lower.Contains("/yi-")) return "yi"; + if (lower.Contains("phi-") || lower.Contains("phi3") || lower.Contains("phi4")) return "phi"; + if (lower.Contains("gemini")) return "gemini"; + if (lower.Contains("claude")) return "claude"; + + return "default"; + } + + /// + /// 모델 패밀리에 추천되는 기본 ExecutionProfile 키를 반환합니다. + /// + public static string GetRecommendedExecutionProfile(string modelFamily) + => modelFamily switch + { + "qwen" => "tool_call_strict", + "gemma" => "tool_call_strict", + "kimi" => "balanced", + "deepseek" => "balanced", + "llama" => "balanced", + "mistral" => "reasoning_first", + "phi" => "tool_call_strict", + "yi" => "balanced", + "gemini" => "reasoning_first", + "claude" => "reasoning_first", + _ => "balanced", + }; + + // ════════════════════════════════════════════════════════════ + // 프롬프트 전략 적용 (3단계) + // ════════════════════════════════════════════════════════════ + + /// + /// 프롬프트 수준에 따라 시스템 프롬프트를 어댑테이션합니다. + /// + /// 원본 시스템 프롬프트 + /// 감지된 모델 패밀리 + /// "off"/"basic"/"detailed" + public static string AdaptSystemPrompt(string basePrompt, string modelFamily, string level = "basic") + { + if (string.Equals(level, "off", StringComparison.OrdinalIgnoreCase)) + return basePrompt; + + if (string.Equals(modelFamily, "default", StringComparison.Ordinal)) + return basePrompt; + + if (string.Equals(level, "detailed", StringComparison.OrdinalIgnoreCase)) + return AdaptDetailed(basePrompt, modelFamily); + + // basic (기본) + return AdaptBasic(basePrompt, modelFamily); + } + + /// 이전 호환: level 없이 호출 시 basic 적용. + public static string AdaptSystemPrompt(string basePrompt, string modelFamily) + => AdaptBasic(basePrompt, modelFamily); + + /// + /// 모델 패밀리별 프롬프트 예산(최대 토큰)을 반환합니다. + /// 0이면 제한 없음. + /// + public static int GetPromptBudget(string modelFamily) + => modelFamily switch + { + "qwen" => 2000, + "gemma" => 800, + "phi" => 1000, + "kimi" => 0, + "deepseek" => 0, + _ => 0, + }; + + /// 모델 패밀리의 한국어 라벨을 반환합니다. + public static string GetFamilyLabel(string modelFamily) + => modelFamily switch + { + "qwen" => "Qwen", + "deepseek" => "DeepSeek", + "kimi" => "Kimi/Moonshot", + "gemma" => "Gemma", + "llama" => "Llama", + "mistral" => "Mistral/Mixtral", + "yi" => "Yi", + "phi" => "Phi", + "gemini" => "Gemini", + "claude" => "Claude", + _ => "기본", + }; + + // ════════════════════════════════════════════════════════════ + // Basic 모드 (가벼운 규칙 추가) + // ════════════════════════════════════════════════════════════ + + private static string AdaptBasic(string basePrompt, string modelFamily) + { + var strategy = GetBasicStrategy(modelFamily); + return strategy.Adapt(basePrompt); + } + + private static IModelPromptStrategy GetBasicStrategy(string modelFamily) + => modelFamily switch + { + "qwen" => QwenBasicStrategy.Instance, + "deepseek" => DeepSeekBasicStrategy.Instance, + "kimi" => KimiBasicStrategy.Instance, + "gemma" => GemmaBasicStrategy.Instance, + _ => DefaultStrategy.Instance, + }; + + // ════════════════════════════════════════════════════════════ + // Detailed 모드 (임베디드 리소스 프롬프트) + // ════════════════════════════════════════════════════════════ + + /// + /// 임베디드 리소스에서 모델별 상세 프롬프트를 로드하고 기본 프롬프트 앞에 삽입합니다. + /// 프롬프트 구조: [상세 모델 프롬프트] + [구분선] + [기본 프롬프트(메타정보 추출)] + /// + private static string AdaptDetailed(string basePrompt, string modelFamily) + { + var detailedPrompt = LoadDetailedPrompt(modelFamily); + if (string.IsNullOrEmpty(detailedPrompt)) + { + // 상세 프롬프트가 없으면 basic으로 폴백 + return AdaptBasic(basePrompt, modelFamily); + } + + var sb = new StringBuilder(detailedPrompt.Length + basePrompt.Length + 200); + + // 상세 모델 프롬프트 (임베디드 리소스) + sb.AppendLine(detailedPrompt); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + + // 기본 프롬프트에서 메타 정보 추출 (날짜, 작업 폴더, 도구 권한 등) + // 이 부분은 세션마다 달라지므로 반드시 포함해야 함 + sb.Append(ExtractSessionContext(basePrompt)); + + var result = sb.ToString(); + + // 토큰 예산 적용 + var budget = GetPromptBudget(modelFamily); + if (budget > 0) + return TruncateToTokenBudget(result, budget); + + return result; + } + + /// + /// 기본 프롬프트에서 세션별 동적 컨텍스트를 추출합니다. + /// (날짜, 작업 폴더, 권한, 워크스페이스 컨텍스트 등) + /// + private static string ExtractSessionContext(string basePrompt) + { + var sb = new StringBuilder(); + sb.AppendLine("## Session Context"); + + var lines = basePrompt.Split('\n'); + var inContextSection = false; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + var trimmed = line.Trim(); + + // 항상 포함할 메타 라인 + if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("Active tab", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("## Available Tools", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("Enabled:", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("Disabled:", StringComparison.OrdinalIgnoreCase)) + { + sb.AppendLine(line); + continue; + } + + // Workspace Context 섹션 전체 포함 + if (trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("## Project Rule", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("## Session Learning", StringComparison.OrdinalIgnoreCase)) + { + inContextSection = true; + sb.AppendLine(line); + continue; + } + + // 새 섹션 시작 시 컨텍스트 섹션 종료 + if (inContextSection) + { + if (trimmed.StartsWith("## ") && !trimmed.StartsWith("## Workspace") + && !trimmed.StartsWith("## Project Rule") && !trimmed.StartsWith("## Session")) + { + inContextSection = false; + } + else + { + sb.AppendLine(line); + } + } + } + + return sb.ToString(); + } + + /// + /// 임베디드 리소스에서 모델 패밀리별 상세 프롬프트를 로드합니다. + /// 캐시되어 있으면 캐시에서 반환합니다. + /// + internal static string? LoadDetailedPrompt(string modelFamily) + { + EnsureDetailedPromptsLoaded(); + + _detailedPromptCache.TryGetValue(modelFamily, out var prompt); + return prompt; + } + + private static void EnsureDetailedPromptsLoaded() + { + if (_detailedPromptsLoaded) return; + + lock (_loadLock) + { + if (_detailedPromptsLoaded) return; + + var assembly = Assembly.GetExecutingAssembly(); + var prefix = "AxCopilot.Assets.ModelPrompts."; + + foreach (var name in assembly.GetManifestResourceNames()) + { + if (!name.StartsWith(prefix) || !name.EndsWith(".md")) + continue; + + try + { + using var stream = assembly.GetManifestResourceStream(name); + if (stream == null) continue; + using var reader = new StreamReader(stream, Encoding.UTF8); + var content = reader.ReadToEnd(); + + // 파일명에서 패밀리 키 추출: AxCopilot.Assets.ModelPrompts.qwen.md → "qwen" + var familyKey = name[prefix.Length..^3]; // ".md" 제거 + _detailedPromptCache[familyKey] = content; + } + catch + { + // 로드 실패는 무시 — basic 폴백 사용 + } + } + + _detailedPromptsLoaded = true; + } + } + + // ════════════════════════════════════════════════════════════ + // Basic 전략 구현 + // ════════════════════════════════════════════════════════════ + + private interface IModelPromptStrategy + { + string Adapt(string basePrompt); + } + + private sealed class DefaultStrategy : IModelPromptStrategy + { + public static readonly DefaultStrategy Instance = new(); + public string Adapt(string basePrompt) => basePrompt; + } + + // ─── Qwen Basic ─────────────────────────────────────── + + private sealed class QwenBasicStrategy : IModelPromptStrategy + { + public static readonly QwenBasicStrategy Instance = new(); + + public string Adapt(string basePrompt) + { + var sb = new StringBuilder(); + sb.AppendLine("[MUST] Start every response with a tool call. No text before tool_call."); + sb.AppendLine("[MUST] Call multiple independent tools in the same response."); + sb.AppendLine("[NEVER] Say '알겠습니다', '네', '확인했습니다' before a tool call."); + sb.AppendLine("[NEVER] Output text-only when a tool action is still needed."); + sb.AppendLine(); + + var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal); + if (bodyStart >= 0) + sb.Append(basePrompt[bodyStart..]); + else + sb.Append(basePrompt); + + sb.AppendLine(); + sb.AppendLine("REMINDER: Your first output MUST be a tool_call, not text. Begin now."); + + return TruncateToTokenBudget(sb.ToString(), 2000); + } + } + + // ─── DeepSeek Basic ─────────────────────────────────── + + private sealed class DeepSeekBasicStrategy : IModelPromptStrategy + { + public static readonly DeepSeekBasicStrategy Instance = new(); + + public string Adapt(string basePrompt) + { + var sb = new StringBuilder(); + sb.Append(basePrompt); + sb.AppendLine(); + sb.AppendLine("## DeepSeek Execution Rules"); + sb.AppendLine("- If you plan internally, keep planning under 2 sentences. Execute immediately after."); + sb.AppendLine("- Never output a plan without following it with tool calls in the same response."); + sb.AppendLine("- After editing code files, verify the build passes before making more changes."); + sb.AppendLine("- After 3+ file edits, run test_loop for regression testing."); + sb.AppendLine("- Use spawn_agent for independent subtasks that can run in parallel."); + return sb.ToString(); + } + } + + // ─── Kimi Basic ─────────────────────────────────────── + + private sealed class KimiBasicStrategy : IModelPromptStrategy + { + public static readonly KimiBasicStrategy Instance = new(); + + public string Adapt(string basePrompt) + { + var sb = new StringBuilder(); + sb.Append(basePrompt); + sb.AppendLine(); + sb.AppendLine("## Kimi Execution Rules"); + sb.AppendLine("- Be concise. Maximum 3 sentences of explanation between tool calls."); + sb.AppendLine("- After every file_edit, immediately call build_run to verify."); + sb.AppendLine("- When analyzing code or documents, structure findings as:"); + sb.AppendLine(" ## Finding Title"); + sb.AppendLine(" - Evidence: [cite file:line]"); + sb.AppendLine(" - Impact: [severity]"); + sb.AppendLine(" - Recommendation: [action]"); + sb.AppendLine("- For multi-section documents, use document_plan first."); + return sb.ToString(); + } + } + + // ─── Gemma Basic ────────────────────────────────────── + + private sealed class GemmaBasicStrategy : IModelPromptStrategy + { + public static readonly GemmaBasicStrategy Instance = new(); + + public string Adapt(string basePrompt) + { + var sb = new StringBuilder(); + sb.AppendLine("You are AX Copilot, a code assistant with tools."); + sb.AppendLine("RULES:"); + sb.AppendLine("1. Always use tools. Respond ONLY with tool calls when action is needed."); + sb.AppendLine("2. One tool per response. Wait for the result before the next step."); + sb.AppendLine("3. Never guess file contents. Read first, then act."); + sb.AppendLine(); + + var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal); + if (bodyStart >= 0) + { + foreach (var line in basePrompt[bodyStart..].Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("- Name:", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("- Build System:", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("- Primary Language:", StringComparison.OrdinalIgnoreCase)) + { + sb.AppendLine(trimmed); + } + } + } + + return TruncateToTokenBudget(sb.ToString(), 800); + } + } + + // ════════════════════════════════════════════════════════════ + // 유틸 + // ════════════════════════════════════════════════════════════ + + /// + /// 대략적 토큰 수 기준으로 프롬프트를 자릅니다. + /// 한국어 1자 ≈ 1.5 토큰, 영어 1단어 ≈ 1.3 토큰 근사. + /// budget=0이면 자르지 않습니다. + /// + private static string TruncateToTokenBudget(string text, int budgetTokens) + { + if (budgetTokens <= 0 || string.IsNullOrEmpty(text)) + return text; + + var charLimit = (int)(budgetTokens * 3.5); + if (text.Length <= charLimit) + return text; + + var truncated = text[..charLimit]; + var lastNewline = truncated.LastIndexOf('\n'); + if (lastNewline > charLimit / 2) + truncated = truncated[..lastNewline]; + + return truncated + "\n...(truncated for model context budget)"; + } +} diff --git a/src/AxCopilot/Services/Agent/MultiReadTool.cs b/src/AxCopilot/Services/Agent/MultiReadTool.cs index 885899d..8442150 100644 --- a/src/AxCopilot/Services/Agent/MultiReadTool.cs +++ b/src/AxCopilot/Services/Agent/MultiReadTool.cs @@ -43,6 +43,11 @@ public class MultiReadTool : IAgentTool Type = "boolean", Description = "If true, include the detected encoding in each file's header (default false)", }, + ["hash_anchor"] = new() + { + Type = "boolean", + Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting.", + }, }, Required = ["paths"], }; @@ -68,6 +73,7 @@ public class MultiReadTool : IAgentTool var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1) var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean(); + var useHashAnchor = FileReadTool.ResolveHashAnchorMode(args); // --- Validate paths array --- if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array) @@ -125,12 +131,23 @@ public class MultiReadTool : IAgentTool var takeCount = Math.Min(available, maxLines); var truncated = available > maxLines; + string[]? anchors = null; + if (useHashAnchor) + anchors = HashAnchor.ComputeAnchors(allLines); + for (var i = 0; i < takeCount; i++) { var lineNum = startIdx + i + 1; // 1-based line number in the original file - sb.Append(lineNum); - sb.Append('\t'); - sb.AppendLine(allLines[startIdx + i]); + if (useHashAnchor && anchors != null) + { + sb.AppendLine(HashAnchor.FormatLine(allLines[startIdx + i], lineNum, anchors[startIdx + i])); + } + else + { + sb.Append(lineNum); + sb.Append('\t'); + sb.AppendLine(allLines[startIdx + i]); + } } if (truncated) diff --git a/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs b/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs index 0ab663e..3764654 100644 --- a/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs +++ b/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs @@ -11,6 +11,12 @@ internal static class PermissionModePresentationCatalog { public static readonly IReadOnlyList Ordered = new[] { + new PermissionModePresentation( + PermissionModeCatalog.Deny, + "\uE72E", + "읽기 전용", + "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 요청에 따른 새 파일 생성은 가능합니다.", + "#6B7280"), new PermissionModePresentation( PermissionModeCatalog.Default, "\uE8D7", @@ -23,6 +29,12 @@ internal static class PermissionModePresentationCatalog "편집 자동 승인", "모든 파일 편집을 자동 승인합니다.", "#107C10"), + new PermissionModePresentation( + PermissionModeCatalog.Plan, + "\uE769", + "계획 모드", + "파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.", + "#D97706"), new PermissionModePresentation( PermissionModeCatalog.BypassPermissions, "\uE814", diff --git a/src/AxCopilot/Services/Agent/SessionLearning.cs b/src/AxCopilot/Services/Agent/SessionLearning.cs new file mode 100644 index 0000000..88f43cf --- /dev/null +++ b/src/AxCopilot/Services/Agent/SessionLearning.cs @@ -0,0 +1,15 @@ +namespace AxCopilot.Services.Agent; + +/// +/// 세션 내 자동 수집된 학습 항목. +/// +internal sealed record SessionLearning( + /// 카테고리: build_config, code_location, project_structure, error_pattern, dependency + string Category, + /// 학습 내용 텍스트 + string Content, + /// 추출 시점 + DateTime ExtractedAt, + /// 출처 도구명 + string SourceTool +); diff --git a/src/AxCopilot/Services/Agent/SessionLearningCollector.cs b/src/AxCopilot/Services/Agent/SessionLearningCollector.cs new file mode 100644 index 0000000..eca6764 --- /dev/null +++ b/src/AxCopilot/Services/Agent/SessionLearningCollector.cs @@ -0,0 +1,308 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace AxCopilot.Services.Agent; + +/// +/// 에이전트 루프 실행 중 도구 결과에서 학습 포인트를 자동 추출하여 +/// 후속 반복에 컨텍스트로 주입하는 세션 내 단기 학습 수집기. +/// +internal sealed class SessionLearningCollector +{ + private readonly List _learnings = new(); + private readonly object _lock = new(); + private readonly int _maxLearnings; + + /// 도구 출력이 이 크기를 초과하면 앞부분만 사용하여 메모리 보호. + private const int MaxOutputAnalysisLength = 32_000; + + public SessionLearningCollector(int maxLearnings = 10) + => _maxLearnings = maxLearnings; + + public int Count { get { lock (_lock) return _learnings.Count; } } + + /// + /// 도구 실행 결과에서 학습 포인트를 추출합니다. + /// 추출 규칙에 매칭되면 자동으로 저장됩니다. + /// + public void TryExtract(string toolName, string toolOutput, bool success) + { + if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(toolOutput)) + return; + + // 대용량 출력 보호: 앞부분만 분석 (Split('\n') 메모리 폭발 방지) + var safeOutput = toolOutput.Length > MaxOutputAnalysisLength + ? toolOutput[..MaxOutputAnalysisLength] + : toolOutput; + + var tool = toolName.ToLowerInvariant(); + SessionLearning? learning = null; + + try + { + learning = tool switch + { + "build_run" or "test_loop" when !success + => ExtractBuildConfig(tool, safeOutput), + "grep" or "glob" when success + => ExtractCodeLocation(tool, safeOutput), + "file_read" when success && IsProjectMetaFile(safeOutput) + => ExtractProjectStructure(tool, safeOutput), + "dev_env_detect" when success + => ExtractDependency(tool, safeOutput), + _ when !success && IsFileOperationTool(tool) + => ExtractErrorPattern(tool, safeOutput), + _ => null, + }; + } + catch + { + // 추출 실패는 무시 — 학습 수집은 부수 효과 + } + + if (learning is null) return; + + // 중복 방지: 동일 카테고리+내용이 이미 있으면 건너뜀 + lock (_lock) + { + if (_learnings.Any(l => l.Category == learning.Category + && l.Content.Equals(learning.Content, StringComparison.OrdinalIgnoreCase))) + return; + + _learnings.Add(learning); + + // FIFO: 초과 시 가장 오래된 항목 일괄 제거 (RemoveAt(0) 반복 대비 O(n) → O(1)) + var excess = _learnings.Count - _maxLearnings; + if (excess > 0) + _learnings.RemoveRange(0, excess); + } + } + + /// + /// 현재 누적 학습을 시스템 메시지 형태로 포맷합니다. + /// 학습이 없으면 null을 반환합니다. + /// + public string? BuildInjectionMessage() + { + List snapshot; + lock (_lock) + { + if (_learnings.Count == 0) return null; + snapshot = _learnings.ToList(); + } + + var sb = new StringBuilder(); + sb.AppendLine("[System:SessionLearnings] 이 세션에서 자동 수집된 학습 사항:"); + foreach (var l in snapshot) + { + sb.AppendLine($"- [{l.Category}] {l.Content}"); + } + sb.AppendLine("위 내용을 참고하여 동일 실수를 반복하지 마세요."); + return sb.ToString().TrimEnd(); + } + + /// 모든 학습 초기화. + public void Clear() + { + lock (_lock) _learnings.Clear(); + } + + // ════════════════════════════════════════════════════════════ + // 추출 규칙 + // ════════════════════════════════════════════════════════════ + + /// 빌드/테스트 실패에서 프로젝트 설정 학습. + private static SessionLearning? ExtractBuildConfig(string tool, string output) + { + var sb = new StringBuilder(); + + // .NET 타겟 프레임워크 감지 + var tfmMatch = Regex.Match(output, @"net\d+\.\d+(?:-windows[\d.]*)?", RegexOptions.IgnoreCase); + if (tfmMatch.Success) + sb.Append($"타겟: {tfmMatch.Value}"); + + // 에러 코드 추출 (CS, TS, etc.) + var errorCodes = Regex.Matches(output, @"\b(CS|TS|E)\d{4}\b"); + if (errorCodes.Count > 0) + { + var codes = errorCodes.Cast().Select(m => m.Value).Distinct().Take(5); + if (sb.Length > 0) sb.Append(", "); + sb.Append($"에러: {string.Join(", ", codes)}"); + } + + // 빌드 시스템 감지 + if (output.Contains("MSBuild", StringComparison.OrdinalIgnoreCase)) + { + if (sb.Length > 0) sb.Append(", "); + sb.Append("빌드: MSBuild"); + } + else if (output.Contains("npm", StringComparison.OrdinalIgnoreCase) + || output.Contains("node", StringComparison.OrdinalIgnoreCase)) + { + if (sb.Length > 0) sb.Append(", "); + sb.Append("빌드: npm/node"); + } + + // 주요 에러 메시지 첫 줄 (Split 대신 라인별 스캔으로 메모리 절약) + var errorLine = FindFirstMatchingLine(output, + l => l.Contains("error", StringComparison.OrdinalIgnoreCase) + && l.Length > 10 && l.Length < 200); + if (errorLine != null) + { + if (sb.Length > 0) sb.Append(" — "); + sb.Append(Truncate(errorLine, 120)); + } + + return sb.Length > 0 + ? new("build_config", sb.ToString(), DateTime.Now, tool) + : null; + } + + /// grep/glob 결과에서 코드 위치 패턴 학습. + private static SessionLearning? ExtractCodeLocation(string tool, string output) + { + // 파일 경로 추출 + var paths = Regex.Matches(output, @"(?:^|\s)([\w./\\-]+\.\w{1,6})(?:\s|:|$)", RegexOptions.Multiline) + .Cast() + .Select(m => m.Groups[1].Value) + .Where(p => p.Contains('/') || p.Contains('\\')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(5) + .ToList(); + + if (paths.Count < 2) return null; // 단일 파일이면 학습 가치 낮음 + + // 공통 디렉토리 패턴 추출 + var dirs = paths + .Select(p => string.Join("/", p.Replace('\\', '/').Split('/').SkipLast(1))) + .Where(d => !string.IsNullOrEmpty(d)) + .GroupBy(d => d) + .OrderByDescending(g => g.Count()) + .FirstOrDefault(); + + if (dirs == null) return null; + + var content = $"관련 파일이 {dirs.Key}/ 에 집중 ({paths.Count}개 파일)"; + return new("code_location", content, DateTime.Now, tool); + } + + /// 프로젝트 메타 파일 읽기에서 구조 학습. + private static SessionLearning? ExtractProjectStructure(string tool, string output) + { + var sb = new StringBuilder(); + + // csproj: TargetFramework, PackageReference + var tfm = Regex.Match(output, @"(.*?)", RegexOptions.IgnoreCase); + if (tfm.Success) + sb.Append($"프레임워크: {tfm.Groups[1].Value}"); + + var packages = Regex.Matches(output, @"() + .Select(m => m.Groups[1].Value) + .Take(8) + .ToList(); + if (packages.Count > 0) + { + if (sb.Length > 0) sb.Append(", "); + sb.Append($"주요 패키지: {string.Join(", ", packages)}"); + } + + // package.json: name, dependencies + var pkgName = Regex.Match(output, @"""name""\s*:\s*""([^""]+)"""); + if (pkgName.Success) + { + if (sb.Length > 0) sb.Append(", "); + sb.Append($"패키지: {pkgName.Groups[1].Value}"); + } + + return sb.Length > 0 + ? new("project_structure", Truncate(sb.ToString(), 200), DateTime.Now, tool) + : null; + } + + /// 런타임 감지 결과에서 의존성 학습. + private static SessionLearning? ExtractDependency(string tool, string output) + { + if (output.Length < 10) return null; + + // 주요 런타임/SDK 정보만 추출 (Split 대신 라인별 스캔) + var lines = FindMatchingLines(output, 4, + l => l.Length > 5 && l.Length < 150 + && (l.Contains("SDK", StringComparison.OrdinalIgnoreCase) + || l.Contains("runtime", StringComparison.OrdinalIgnoreCase) + || l.Contains("version", StringComparison.OrdinalIgnoreCase) + || l.Contains("node", StringComparison.OrdinalIgnoreCase) + || l.Contains("python", StringComparison.OrdinalIgnoreCase))); + + if (lines.Count == 0) return null; + + return new("dependency", string.Join("; ", lines), DateTime.Now, tool); + } + + /// 파일 조작 실패에서 에러 패턴 학습. + private static SessionLearning? ExtractErrorPattern(string tool, string output) + { + if (output.Length < 10) return null; + + var firstLine = FindFirstMatchingLine(output, l => l.Length > 5); + if (firstLine == null) return null; + + return new("error_pattern", $"{tool}: {Truncate(firstLine, 150)}", DateTime.Now, tool); + } + + // ════════════════════════════════════════════════════════════ + // 유틸 + // ════════════════════════════════════════════════════════════ + + private static bool IsProjectMetaFile(string output) + => output.Contains(" tool is "file_write" or "file_edit" or "file_read" or "file_manage"; + + private static string Truncate(string text, int maxLen) + => text.Length <= maxLen ? text : text[..maxLen] + "..."; + + /// + /// Split('\n') 없이 라인별 스캔하여 첫 매칭 라인을 반환합니다. + /// 대용량 출력에서 전체 배열 할당을 방지합니다. + /// + private static string? FindFirstMatchingLine(string text, Func predicate) + { + var span = text.AsSpan(); + while (span.Length > 0) + { + var newlineIdx = span.IndexOf('\n'); + var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span; + var line = lineSpan.Trim().ToString(); + if (predicate(line)) + return line; + if (newlineIdx < 0) break; + span = span[(newlineIdx + 1)..]; + } + return null; + } + + /// + /// Split('\n') 없이 라인별 스캔하여 최대 maxCount개의 매칭 라인을 반환합니다. + /// + private static List FindMatchingLines(string text, int maxCount, Func predicate) + { + var results = new List(maxCount); + var span = text.AsSpan(); + while (span.Length > 0 && results.Count < maxCount) + { + var newlineIdx = span.IndexOf('\n'); + var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span; + var line = lineSpan.Trim().ToString(); + if (predicate(line)) + results.Add(line); + if (newlineIdx < 0) break; + span = span[(newlineIdx + 1)..]; + } + return results; + } +} diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index 9532b5b..dad4d74 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -175,7 +175,6 @@ public static class SkillService /// /// paths 전면조건 스킬 활성화. - /// claw-code의 conditional skill 활성화 패턴과 동일하게 /// file path 입력이 매칭될 때만 동적으로 활성화합니다. /// public static string[] ActivateConditionalSkillsForPaths(IEnumerable filePaths, string cwd) @@ -704,6 +703,7 @@ public static class SkillService if (ToolNameMap.TryGetValue(normalized, out var mapped)) normalized = mapped; + normalized = AgentToolCatalog.Canonicalize(normalized); tools.Add(normalized); } diff --git a/src/AxCopilot/Services/Agent/SpawnAgentsTool.cs b/src/AxCopilot/Services/Agent/SpawnAgentsTool.cs new file mode 100644 index 0000000..0658d5e --- /dev/null +++ b/src/AxCopilot/Services/Agent/SpawnAgentsTool.cs @@ -0,0 +1,137 @@ +using System.Text; +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// P5: 배치 서브에이전트 생성 도구. +/// 여러 서브에이전트를 한 번에 생성하고 통합 결과를 반환합니다. +/// +public class SpawnAgentsTool : IAgentTool +{ + public string Name => "spawn_agents"; + + public string Description => + "Create multiple sub-agents in batch for parallel research or task execution.\n" + + "Each agent runs independently with its own task and optional profile.\n" + + "Collect results later with wait_agents."; + + public ToolParameterSchema Parameters => new() + { + Properties = new() + { + ["agents"] = new ToolProperty + { + Type = "array", + Description = "List of sub-agent definitions. Each has: id (unique identifier), task (work description), profile (optional: researcher/coder/writer/reviewer/planner).", + Items = new ToolProperty + { + Type = "object", + Description = "Sub-agent definition with id, task, and optional profile." + } + }, + }, + Required = new() { "agents" } + }; + + public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) + { + if (!args.SafeTryGetProperty("agents", out var agentsEl) || agentsEl.ValueKind != JsonValueKind.Array) + return ToolResult.Fail("agents array is required."); + + var agentDefs = new List<(string Id, string Task, string? Profile)>(); + foreach (var item in agentsEl.EnumerateArray()) + { + var id = item.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? "" : ""; + var task = item.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "" : ""; + var profile = item.SafeTryGetProperty("profile", out var profEl) ? profEl.SafeGetString() : null; + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(task)) + return ToolResult.Fail($"Each agent must have non-empty 'id' and 'task'. Found invalid entry."); + + agentDefs.Add((id, task, profile)); + } + + if (agentDefs.Count == 0) + return ToolResult.Fail("agents array is empty."); + + // 용량 사전 검증 (빠른 실패용 — 실제 원자성은 SubAgentTool.ExecuteAsync 내부 lock에서 보장) + var app = System.Windows.Application.Current as App; + var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 5; + var activeTasks = SubAgentTool.ActiveTasks; + var running = activeTasks.Values.Count(x => x.CompletedAt == null); + + if (running + agentDefs.Count > maxAgents) + return ToolResult.Fail( + $"Cannot spawn {agentDefs.Count} agents: {running} already running, max is {maxAgents}."); + + // 중복 ID 검사 + var duplicateIds = agentDefs.GroupBy(a => a.Id, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (duplicateIds.Count > 0) + return ToolResult.Fail($"Duplicate agent ids: {string.Join(", ", duplicateIds)}"); + + // 기존 활성 태스크와 ID 충돌 검사 + var conflictIds = agentDefs.Where(a => activeTasks.ContainsKey(a.Id)).Select(a => a.Id).ToList(); + if (conflictIds.Count > 0) + return ToolResult.Fail($"Agent ids already exist: {string.Join(", ", conflictIds)}"); + + // 각 에이전트를 SubAgentTool을 통해 생성 + var spawnTool = new SubAgentTool(); + var results = new List<(string Id, bool Success, string Message)>(); + var cancelled = false; + + foreach (var (id, task, profile) in agentDefs) + { + // 루프 중 취소 감지 — 이미 생성된 에이전트는 유지하고 나머지만 건너뜀 + if (ct.IsCancellationRequested) + { + cancelled = true; + results.Add((id, false, "Cancelled: batch spawn interrupted.")); + continue; + } + + // SubAgentTool.ExecuteAsync용 JsonElement 구성 + var jsonObj = new Dictionary + { + ["id"] = id, + ["task"] = task, + }; + if (!string.IsNullOrWhiteSpace(profile)) + jsonObj["profile"] = profile; + + var jsonStr = JsonSerializer.Serialize(jsonObj); + using var doc = JsonDocument.Parse(jsonStr); + + var result = await spawnTool.ExecuteAsync(doc.RootElement, context, ct).ConfigureAwait(false); + results.Add((id, result.Success, result.Output ?? "")); + } + + // 통합 결과 메시지 + var sb = new StringBuilder(); + var successCount = results.Count(r => r.Success); + var failCount = results.Count(r => !r.Success); + + sb.AppendLine($"Batch spawn: {successCount} started, {failCount} failed (total: {agentDefs.Count}){(cancelled ? " [partially cancelled]" : "")}"); + sb.AppendLine(); + + foreach (var (id, success, message) in results) + { + var status = success ? "✓" : "✗"; + var profileName = agentDefs.First(a => a.Id == id).Profile ?? "researcher"; + sb.AppendLine($"{status} [{id}] profile={profileName}"); + if (!success) + sb.AppendLine($" Error: {message}"); + } + + sb.AppendLine(); + sb.AppendLine("Use wait_agents to collect results when ready."); + + return failCount == agentDefs.Count + ? ToolResult.Fail(sb.ToString().TrimEnd()) + : ToolResult.Ok(sb.ToString().TrimEnd()); + } +} diff --git a/src/AxCopilot/Services/Agent/SubAgentProfile.cs b/src/AxCopilot/Services/Agent/SubAgentProfile.cs new file mode 100644 index 0000000..198bd73 --- /dev/null +++ b/src/AxCopilot/Services/Agent/SubAgentProfile.cs @@ -0,0 +1,144 @@ +namespace AxCopilot.Services.Agent; + +/// +/// 서브에이전트 실행 프로파일. 작업 유형별로 다른 system prompt, 도구, temperature를 적용합니다. +/// +internal sealed record SubAgentProfile( + string Name, + string Description, + string SystemPromptPrefix, + string[] EnabledToolNames, + string[] DisabledToolNames, + string FilePermission, + double? TemperatureOverride +); + +/// +/// 5개 빌트인 서브에이전트 프로파일 카탈로그. +/// +internal static class SubAgentProfileCatalog +{ + // ── 기본 읽기 전용 도구 세트 (기존 SubAgentTool과 동일) ── + private static readonly string[] ResearcherTools = + { + "file_read", "glob", "grep", "folder_map", "document_read", + "dev_env_detect", "git_tool", "lsp_code_intel", "code_search", + "code_review", "project_rule", "skill_manager", "json_tool", + "regex_tool", "diff_tool", "base64_tool", "hash_tool", + "datetime_tool", "math_tool", "xml_tool", "multi_read", + "file_info", "document_review", + }; + + private static readonly string[] WriterExtraTools = + { + "html_create", "docx_create", "markdown_create", "csv_create", + "excel_create", "pptx_create", "document_plan", "file_write", + }; + + private static readonly string[] CoderExtraTools = + { + "file_write", "file_edit", "build_run", "process", + "test_loop", "snippet_runner", + }; + + private static readonly string[] ReviewerExtraTools = + { + "code_review", "document_review", "git_tool", + }; + + // ── 항상 비활성화할 도구 (서브에이전트 재귀 방지) ── + private static readonly string[] AlwaysDisabled = + { + "spawn_agent", "spawn_agents", "wait_agents", + "memory", "notify", "open_external", "user_ask", + "checkpoint", "diff_preview", "playbook", "http_tool", + "clipboard", "sql_tool", + }; + + /// + /// 프로파일 이름으로 프로파일을 반환합니다. null/미지정이면 researcher(기본). + /// + public static SubAgentProfile Get(string? profileName) + { + return (profileName?.Trim().ToLowerInvariant()) switch + { + "coder" => new SubAgentProfile( + Name: "coder", + Description: "코드 수정 가능한 서브에이전트", + SystemPromptPrefix: + "You are a coding sub-agent for AX Copilot.\n" + + "You can read, write, and edit files, and run builds and tests.\n" + + "Focus on making the minimal correct change. Verify with build/test after editing.\n" + + "Do not ask the user questions.", + EnabledToolNames: ResearcherTools.Concat(CoderExtraTools).Distinct().ToArray(), + DisabledToolNames: AlwaysDisabled, + FilePermission: "AcceptEdits", + TemperatureOverride: 0.2), + + "writer" => new SubAgentProfile( + Name: "writer", + Description: "문서 생성 서브에이전트", + SystemPromptPrefix: + "You are a document creation sub-agent for AX Copilot.\n" + + "You can create documents (HTML, DOCX, Markdown, Excel, PPT) and write files.\n" + + "Focus on producing well-structured, complete documents.\n" + + "Do not ask the user questions.", + EnabledToolNames: ResearcherTools.Concat(WriterExtraTools).Distinct().ToArray(), + DisabledToolNames: AlwaysDisabled, + FilePermission: "AcceptEdits", + TemperatureOverride: 0.35), + + "reviewer" => new SubAgentProfile( + Name: "reviewer", + Description: "코드 리뷰 서브에이전트", + SystemPromptPrefix: + "You are a review sub-agent for AX Copilot.\n" + + "You perform code reviews and document reviews.\n" + + "Produce structured findings with P0-P3 severity ratings.\n" + + "Focus on concrete defects, regressions, and missing tests.\n" + + "Do not ask the user questions. Do not edit files.", + EnabledToolNames: ResearcherTools.Concat(ReviewerExtraTools).Distinct().ToArray(), + DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(), + FilePermission: "Deny", + TemperatureOverride: 0.25), + + "planner" => new SubAgentProfile( + Name: "planner", + Description: "작업 분해/계획 서브에이전트", + SystemPromptPrefix: + "You are a planning sub-agent for AX Copilot.\n" + + "Decompose tasks into ordered steps, identify the minimum file set,\n" + + "and highlight the primary risk for each step.\n" + + "Do not ask the user questions. Do not edit files.", + EnabledToolNames: new[] + { + "folder_map", "glob", "grep", "file_read", "document_read", + "dev_env_detect", "lsp_code_intel", "multi_read", "file_info", + "project_rule", + }, + DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(), + FilePermission: "Deny", + TemperatureOverride: 0.3), + + // researcher (기본) — 기존 SubAgentTool 동작과 완전히 동일 + _ => new SubAgentProfile( + Name: "researcher", + Description: "읽기 전용 조사 서브에이전트", + SystemPromptPrefix: + "You are a focused sub-agent for AX Copilot.\n" + + "You are running a bounded, read-only investigation.\n" + + "Use tools to inspect the project, gather evidence, and produce an actionable result.\n" + + "Do not ask the user questions.\n" + + "Do not attempt file edits, command execution, notifications, or external side effects.\n" + + "Prefer direct evidence from files and tool results over speculation.\n" + + "If something is uncertain, say so briefly and identify what evidence is missing.", + EnabledToolNames: ResearcherTools, + DisabledToolNames: AlwaysDisabled, + FilePermission: "Deny", + TemperatureOverride: null), + }; + } + + /// 프로파일 목록 (도움말/description용). + public static readonly string[] AllProfileNames = { "researcher", "coder", "writer", "reviewer", "planner" }; +} diff --git a/src/AxCopilot/Services/Agent/SubAgentTool.cs b/src/AxCopilot/Services/Agent/SubAgentTool.cs index 553d244..5e1a46a 100644 --- a/src/AxCopilot/Services/Agent/SubAgentTool.cs +++ b/src/AxCopilot/Services/Agent/SubAgentTool.cs @@ -35,6 +35,11 @@ public class SubAgentTool : IAgentTool Type = "string", Description = "A unique sub-agent identifier used by wait_agents." }, + ["profile"] = new ToolProperty + { + Type = "string", + Description = "Execution profile: researcher (default, read-only), coder (can edit/build), writer (doc creation), reviewer (code review), planner (task decomposition)." + }, }, Required = new() { "task", "id" } }; @@ -46,6 +51,7 @@ public class SubAgentTool : IAgentTool { var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : ""; var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : ""; + var profileName = args.SafeTryGetProperty("profile", out var p) ? p.SafeGetString() : null; if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id)) return Task.FromResult(ToolResult.Fail("task and id are required.")); @@ -80,7 +86,7 @@ public class SubAgentTool : IAgentTool { try { - var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false); + var result = await RunSubAgentAsync(id, task, context, profileName, cts.Token).ConfigureAwait(false); subTask.Result = result; subTask.Success = true; NotifyStatus(new SubAgentStatusEvent @@ -144,11 +150,15 @@ public class SubAgentTool : IAgentTool $"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result.")); } - private static async Task RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct) + private static async Task RunSubAgentAsync(string id, string task, AgentContext parentContext, string? profileName, CancellationToken ct) { - var settings = CreateSubAgentSettings(parentContext); + var profile = SubAgentProfileCatalog.Get(profileName); + var settings = CreateSubAgentSettings(parentContext, profile); using var llm = new LlmService(settings); - using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false); + // P2: 프로파일별 temperature override + if (profile.TemperatureOverride.HasValue) + llm.PushInferenceOverride(temperature: profile.TemperatureOverride.Value); + using var tools = await CreateSubAgentRegistryAsync(settings, profile).ConfigureAwait(false); var loop = new AgentLoopService(llm, tools, settings) { @@ -160,7 +170,7 @@ public class SubAgentTool : IAgentTool new() { Role = "system", - Content = BuildSubAgentSystemPrompt(task, parentContext), + Content = BuildSubAgentSystemPrompt(task, parentContext, profile), }, new() { @@ -189,93 +199,151 @@ public class SubAgentTool : IAgentTool return sb.ToString().TrimEnd(); } - private static SettingsService CreateSubAgentSettings(AgentContext parentContext) + private static SettingsService CreateSubAgentSettings(AgentContext parentContext, SubAgentProfile profile) { var settings = new SettingsService(); settings.Load(); var llm = settings.Settings.Llm; llm.WorkFolder = parentContext.WorkFolder; - llm.FilePermission = "Deny"; + llm.FilePermission = profile.FilePermission; llm.AgentHooks = new(); llm.ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase); - llm.DisabledTools = new List - { - "spawn_agent", - "wait_agents", - "file_write", - "file_edit", - "process", - "build_run", - "snippet_runner", - "memory", - "notify", - "open_external", - "user_ask", - "checkpoint", - "diff_preview", - "playbook", - "http_tool", - "clipboard", - "sql_tool", - }; + llm.DisabledTools = profile.DisabledToolNames.ToList(); return settings; } - private static async Task CreateSubAgentRegistryAsync(SettingsService settings) + /// 도구 이름 → 팩토리 매핑 (인스턴스를 필요한 것만 생성). + private static readonly Dictionary> ToolFactories = + new(StringComparer.OrdinalIgnoreCase) + { + ["file_read"] = () => new FileReadTool(), + ["glob"] = () => new GlobTool(), + ["grep"] = () => new GrepTool(), + ["folder_map"] = () => new FolderMapTool(), + ["document_read"] = () => new DocumentReaderTool(), + ["dev_env_detect"] = () => new DevEnvDetectTool(), + ["git_tool"] = () => new GitTool(), + ["lsp_code_intel"] = () => new LspTool(), + ["code_search"] = () => new CodeSearchTool(), + ["code_review"] = () => new CodeReviewTool(), + ["project_rule"] = () => new ProjectRuleTool(), + ["skill_manager"] = () => new SkillManagerTool(), + ["json_tool"] = () => new JsonTool(), + ["regex_tool"] = () => new RegexTool(), + ["diff_tool"] = () => new DiffTool(), + ["base64_tool"] = () => new Base64Tool(), + ["hash_tool"] = () => new HashTool(), + ["datetime_tool"] = () => new DateTimeTool(), + ["math_tool"] = () => new MathTool(), + ["xml_tool"] = () => new XmlTool(), + ["multi_read"] = () => new MultiReadTool(), + ["file_info"] = () => new FileInfoTool(), + ["document_review"] = () => new DocumentReviewTool(), + // coder 프로파일용 + ["file_write"] = () => new FileWriteTool(), + ["file_edit"] = () => new FileEditTool(), + ["build_run"] = () => new BuildRunTool(), + ["process"] = () => new ProcessTool(), + ["test_loop"] = () => new TestLoopTool(), + ["snippet_runner"] = () => new SnippetRunnerTool(), + // writer 프로파일용 + ["html_create"] = () => new HtmlSkill(), + ["docx_create"] = () => new DocxSkill(), + ["markdown_create"] = () => new MarkdownSkill(), + ["csv_create"] = () => new CsvSkill(), + ["excel_create"] = () => new ExcelSkill(), + ["pptx_create"] = () => new PptxSkill(), + ["document_plan"] = () => new DocumentPlannerTool(), + }; + + private static async Task CreateSubAgentRegistryAsync(SettingsService settings, SubAgentProfile profile) { var registry = new ToolRegistry(); - registry.Register(new FileReadTool()); - registry.Register(new GlobTool()); - registry.Register(new GrepTool()); - registry.Register(new FolderMapTool()); - registry.Register(new DocumentReaderTool()); - registry.Register(new DevEnvDetectTool()); - registry.Register(new GitTool()); - registry.Register(new LspTool()); - registry.Register(new CodeSearchTool()); - registry.Register(new CodeReviewTool()); - registry.Register(new ProjectRuleTool()); - registry.Register(new SkillManagerTool()); - registry.Register(new JsonTool()); - registry.Register(new RegexTool()); - registry.Register(new DiffTool()); - registry.Register(new Base64Tool()); - registry.Register(new HashTool()); - registry.Register(new DateTimeTool()); - registry.Register(new MathTool()); - registry.Register(new XmlTool()); - registry.Register(new MultiReadTool()); - registry.Register(new FileInfoTool()); - registry.Register(new DocumentReviewTool()); + // 필요한 도구만 인스턴스 생성 (기존: 전체 63개 생성 후 필터 → 개선: 필요한 것만 팩토리 호출) + foreach (var name in profile.EnabledToolNames) + { + if (ToolFactories.TryGetValue(name, out var factory)) + registry.Register(factory()); + } await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false); return registry; } - private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext) + private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext, SubAgentProfile profile) { var sb = new StringBuilder(); - sb.AppendLine("You are a focused sub-agent for AX Copilot."); - sb.AppendLine("You are running a bounded, read-only investigation."); - sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result."); - sb.AppendLine("Do not ask the user questions."); - sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects."); - sb.AppendLine("Prefer direct evidence from files and tool results over speculation."); - sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing."); + + // P2: 프로파일별 시스템 프롬프트 접두사 사용 + sb.AppendLine(profile.SystemPromptPrefix); + if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder)) sb.AppendLine($"Current work folder: {parentContext.WorkFolder}"); if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab)) sb.AppendLine($"Current tab: {parentContext.ActiveTab}"); + + // P4: 워크스페이스 컨텍스트 자동 주입 + var wsContext = WorkspaceContextGenerator.LoadContext(parentContext.WorkFolder); + if (!string.IsNullOrWhiteSpace(wsContext)) + { + sb.AppendLine(); + sb.AppendLine("Workspace context:"); + sb.AppendLine(wsContext.Length > 2000 ? wsContext[..2000] + "\n...(truncated)" : wsContext); + } + sb.AppendLine(); - sb.AppendLine("Investigation rules:"); - sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory."); - sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers."); - sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence."); - sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk."); - sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests."); + + // 프로파일별 작업 규칙 + switch (profile.Name) + { + case "coder": + sb.AppendLine("Coding rules:"); + sb.AppendLine("1. Read the relevant files first to understand existing patterns."); + sb.AppendLine("2. Make the minimal correct change — do not refactor unrelated code."); + sb.AppendLine("3. After editing, verify with build_run or test_loop."); + sb.AppendLine("4. If the build fails, fix the issue immediately."); + sb.AppendLine("5. Report what was changed and the verification result."); + break; + + case "writer": + sb.AppendLine("Document creation rules:"); + sb.AppendLine("1. Inspect existing documents or source files for context."); + sb.AppendLine("2. Produce well-structured, complete documents."); + sb.AppendLine("3. Use appropriate formatting for the target format."); + sb.AppendLine("4. Verify file was created successfully."); + break; + + case "reviewer": + sb.AppendLine("Review rules:"); + sb.AppendLine("1. Start by reading the directly relevant files."); + sb.AppendLine("2. Rate each finding P0 (critical) through P3 (minor)."); + sb.AppendLine("3. Prioritize concrete defects, regressions, and missing tests."); + sb.AppendLine("4. Cite exact file paths and line ranges as evidence."); + sb.AppendLine("5. Do not suggest edits — only report findings."); + break; + + case "planner": + sb.AppendLine("Planning rules:"); + sb.AppendLine("1. Inspect the codebase to understand the current architecture."); + sb.AppendLine("2. Decompose the task into ordered steps with clear dependencies."); + sb.AppendLine("3. Identify the minimum file set for each step."); + sb.AppendLine("4. Highlight the primary risk for each step."); + sb.AppendLine("5. Suggest a validation strategy."); + break; + + default: // researcher + sb.AppendLine("Investigation rules:"); + sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory."); + sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers."); + sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence."); + sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk."); + sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests."); + break; + } + var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab); if (!string.IsNullOrWhiteSpace(workflowHints)) { diff --git a/src/AxCopilot/Services/Agent/TemplateService.cs b/src/AxCopilot/Services/Agent/TemplateService.cs index ebb366d..d39d910 100644 --- a/src/AxCopilot/Services/Agent/TemplateService.cs +++ b/src/AxCopilot/Services/Agent/TemplateService.cs @@ -22,8 +22,33 @@ public static class TemplateService new("corporate", "기업 공식", "🏢", "보수적인 레이아웃, 로고 영역, 페이지 번호 — 사내 공식 보고서"), new("magazine", "매거진", "📰", "멀티 컬럼, 큰 히어로 헤더, 인용 강조 — 뉴스레터·매거진"), new("dashboard", "대시보드", "📈", "KPI 카드, 차트 영역, 그리드 레이아웃 — 데이터 대시보드"), + new("seminar", "세미나", "🎓", "다크/라이트 테마 전환, 그라데이션 히어로, 파이프라인 다이어그램 — 기술 세미나·발표 자료"), + new("seminar-toc", "세미나 (사이드 목차)", "📑", "좌측 고정 사이드바 목차 + 세미나 스타일 — 긴 기술 문서·레퍼런스"), ]; + /// 테마 전환 + 플로팅 TOC JS 스크립트 (HTML에 삽입용). + public const string ThemeToggleScript = """ + + """; + // ── 커스텀 무드 저장소 ── private static readonly Dictionary _customMoods = new(StringComparer.OrdinalIgnoreCase); @@ -67,6 +92,8 @@ public static class TemplateService "corporate" => CssCorporate, "magazine" => CssMagazine, "dashboard" => CssDashboard, + "seminar" => CssSeminar, + "seminar-toc" => CssSeminarSidebar, _ => CssModern, }; return moodCss + "\n" + CssShared; @@ -100,18 +127,21 @@ public static class TemplateService .Build(); var bodyHtml = Markdown.ToHtml(markdown, pipeline); var css = GetCss(moodKey); + var defaultTheme = moodKey is "dark" or "seminar" or "seminar-toc" or "dashboard" ? "dark" : "light"; return $""" - + +
{bodyHtml}
+ {ThemeToggleScript} """; @@ -167,6 +197,8 @@ public static class TemplateService "corporate" => new("#f3f4f6", "#ffffff", "#1f2937", "#6b7280", "#1e40af", "#e5e7eb"), "magazine" => new("#f9fafb", "#ffffff", "#111827", "#6b7280", "#dc2626", "#f3f4f6"), "dashboard" => new("#0f172a", "#1e293b", "#f1f5f9", "#94a3b8", "#3b82f6", "#334155"), + "seminar" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"), + "seminar-toc" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"), _ => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"), }; } @@ -595,6 +627,240 @@ public static class TemplateService """; #endregion + #region Seminar — 세미나 + private const string CssSeminar = """ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + :root { + --accent: #6C8EEF; --accent2: #A78BFA; --green: #34D399; --amber: #FBBF24; + --red: #F87171; --cyan: #22D3EE; --transition: 0.3s ease; + } + [data-theme="dark"] { + --bg: #0f1117; --bg2: #0b0d13; --surface: #161822; --surface2: #1c1f2e; --surface3: #22253a; + --border: #2a2d3e; --text: #e2e8f0; --text-dim: #8892a8; --text-inv: #1a1a2e; + --hero-grad1: #161822; --hero-grad2: #0f1117; --hero-glow: rgba(108,142,239,0.12); + --shadow: rgba(0,0,0,0.3); --code-bg: rgba(108,142,239,0.1); --code-border: rgba(108,142,239,0.15); + } + [data-theme="light"] { + --bg: #f8fafc; --bg2: #f1f5f9; --surface: #ffffff; --surface2: #f1f5f9; --surface3: #e8ecf2; + --border: #e2e8f0; --text: #1e293b; --text-dim: #64748b; --text-inv: #ffffff; + --hero-grad1: #eef2ff; --hero-grad2: #f8fafc; --hero-glow: rgba(108,142,239,0.08); + --shadow: rgba(0,0,0,0.06); --code-bg: rgba(108,142,239,0.06); --code-border: rgba(108,142,239,0.12); + --accent: #4f6fd9; --accent2: #8b6fc0; --green: #16a34a; --amber: #d97706; --red: #dc2626; --cyan: #0891b2; + } + * { margin: 0; padding: 0; box-sizing: border-box; } + html { scroll-behavior: smooth; } + body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif; + background: var(--bg); color: var(--text); line-height: 1.75; padding: 0; + -webkit-font-smoothing: antialiased; transition: background var(--transition), color var(--transition); } + .container { max-width: 980px; margin: 0 auto; padding: 0 28px; background: transparent; } + + /* ── Hero ── */ + .cover-page, .header-bar { position: relative; padding: 60px 40px 48px; text-align: center; overflow: hidden; + background: linear-gradient(180deg, var(--hero-grad1) 0%, var(--hero-grad2) 100%); + border-radius: 0; margin: 0 -28px 32px; transition: background var(--transition); } + .cover-page::before, .header-bar::before { content: ''; position: absolute; top: -120px; left: 50%; + transform: translateX(-50%); width: 600px; height: 600px; + background: radial-gradient(circle, var(--hero-glow) 0%, transparent 70%); pointer-events: none; } + .cover-page h1, .header-bar h1 { font-size: 36px; font-weight: 800; letter-spacing: -0.5px; + background: linear-gradient(135deg, var(--text), var(--accent), var(--accent2)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 8px; color: var(--text); } + .cover-page .cover-subtitle { font-size: 16px; color: var(--text-dim); } + .cover-page .cover-meta, .meta { font-size: 12px; color: var(--text-dim); margin-bottom: 24px; } + .header-bar .meta { margin-bottom: 0; margin-top: 8px; } + + /* ── Headings ── */ + h1 { font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 4px; } + h2 { font-size: 20px; font-weight: 700; margin: 40px 0 16px; color: var(--text); + padding-bottom: 12px; border-bottom: 2px solid var(--border); + display: flex; align-items: center; gap: 12px; transition: border-color var(--transition); } + h3 { font-size: 16px; font-weight: 700; color: var(--accent); margin: 28px 0 10px; } + h4 { font-size: 14px; font-weight: 600; color: var(--text); margin: 16px 0 8px; + border-left: 3px solid var(--accent); padding-left: 10px; } + + /* ── Text ── */ + p { margin: 10px 0; font-size: 14px; } + ul, ol { margin: 8px 0 12px 20px; font-size: 14px; } + li { margin: 4px 0; } + li::marker { color: var(--text-dim); } + strong { font-weight: 700; } + em { color: var(--accent); font-style: normal; font-weight: 600; } + + /* ── Code ── */ + code { font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; + background: var(--code-bg); border: 1px solid var(--code-border); + border-radius: 4px; padding: 1px 6px; font-size: 12.5px; color: var(--cyan); } + pre { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 18px; overflow-x: auto; font-size: 12.5px; margin: 14px 0; line-height: 1.55; + transition: all var(--transition); } + pre code { background: transparent; border: none; padding: 0; color: var(--text-dim); } + + /* ── Cards ── */ + .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 20px 24px; margin-bottom: 16px; transition: all var(--transition); } + .card:hover { box-shadow: 0 4px 16px var(--shadow); } + .card-header { font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } + + /* ── Tables ── */ + table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; } + th { background: var(--surface2); padding: 10px 14px; text-align: left; font-weight: 700; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); + border-bottom: 2px solid var(--border); transition: all var(--transition); } + td { padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top; + transition: all var(--transition); } + tr:last-child td { border-bottom: none; } + tr:hover td { background: var(--surface2); } + + /* ── Badges ── */ + .badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; + text-transform: uppercase; letter-spacing: 0.5px; display: inline-block; margin: 2px 4px 2px 0; } + .badge-green { background: rgba(52,211,153,0.15); color: var(--green); } + .badge-amber, .badge-yellow { background: rgba(251,191,36,0.15); color: var(--amber); } + .badge-blue { background: rgba(108,142,239,0.15); color: var(--accent); } + .badge-purple { background: rgba(167,139,250,0.15); color: var(--accent2); } + .badge-red { background: rgba(248,113,113,0.15); color: var(--red); } + .badge-cyan { background: rgba(34,211,238,0.15); color: var(--cyan); } + + /* ── Info boxes ── */ + .callout { border-radius: 10px; padding: 16px 20px; margin: 16px 0; font-size: 13.5px; + display: flex; gap: 12px; align-items: flex-start; border-left: 4px solid; + transition: all var(--transition); } + .callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; } + .callout-info { background: rgba(108,142,239,0.07); border-color: var(--accent); } + .callout-info::before { content: '💡'; } + .callout-tip { background: rgba(52,211,153,0.07); border-color: var(--green); } + .callout-tip::before { content: '✅'; } + .callout-warning { background: rgba(251,191,36,0.07); border-color: var(--amber); } + .callout-warning::before { content: '⚠️'; } + .callout-danger { background: rgba(248,113,113,0.07); border-color: var(--red); } + .callout-danger::before { content: '🚨'; } + .callout-note { background: rgba(167,139,250,0.07); border-color: var(--accent2); } + .callout-note::before { content: '📝'; } + + /* ── Flow diagram ── */ + .flow { display: flex; align-items: center; gap: 0; margin: 20px 0; flex-wrap: wrap; justify-content: center; } + .flow-step { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; + padding: 12px 18px; text-align: center; min-width: 120px; transition: all var(--transition); } + .flow-step .num { font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase; + letter-spacing: 1px; margin-bottom: 4px; } + .flow-step .label { font-size: 13px; font-weight: 600; } + .flow-step .desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; } + .flow-arrow { color: var(--text-dim); font-size: 18px; margin: 0 6px; flex-shrink: 0; } + + /* ── Pipeline ── */ + .pipeline { margin: 20px 0; } + .pipeline-stage { display: flex; align-items: flex-start; gap: 16px; padding: 14px 0; + border-left: 2px solid var(--border); margin-left: 14px; padding-left: 24px; position: relative; } + .pipeline-stage::before { content: ''; position: absolute; left: -7px; top: 18px; + width: 12px; height: 12px; border-radius: 50%; + background: var(--surface); border: 2px solid var(--accent); transition: all var(--transition); } + .pipeline-stage:last-child { border-left-color: transparent; } + .pipeline-stage .stage-num { font-size: 10px; font-weight: 700; color: var(--accent); + text-transform: uppercase; letter-spacing: 1px; min-width: 60px; padding-top: 2px; } + .pipeline-stage .stage-body { flex: 1; } + .pipeline-stage .stage-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; } + .pipeline-stage .stage-desc { font-size: 12.5px; color: var(--text-dim); } + + /* ── Stat cards ── */ + .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + padding: 18px 14px; text-align: center; transition: all var(--transition); } + .stat-card .number { font-size: 30px; font-weight: 800; color: var(--accent); } + .stat-card .label { font-size: 11px; color: var(--text-dim); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } + + /* ── Grids ── */ + .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; } + .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin: 16px 0; } + .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; } + + /* ── Profile cards ── */ + .profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; } + .profile-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 18px 20px; transition: all var(--transition); } + .profile-card:hover { box-shadow: 0 4px 16px var(--shadow); } + .profile-card .name { font-size: 14px; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; } + .profile-card .desc { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; } + .profile-card .props { font-size: 12px; } + .profile-card .props dt { color: var(--text-dim); float: left; width: 110px; } + .profile-card .props dd { margin-bottom: 3px; } + + /* ── Arch diagram ── */ + .arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; + padding: 24px 28px; margin: 20px 0; font-family: 'Cascadia Code', monospace; + font-size: 11.5px; line-height: 1.55; overflow-x: auto; white-space: pre; + color: var(--text-dim); transition: all var(--transition); } + .arch-diagram .hl { color: var(--accent); font-weight: 600; } + .arch-diagram .g { color: var(--green); } + .arch-diagram .a { color: var(--amber); } + .arch-diagram .r { color: var(--red); } + .arch-diagram .p { color: var(--accent2); } + .arch-diagram .c { color: var(--cyan); } + + /* ── Tags ── */ + .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; } + .tag { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11.5px; + font-weight: 500; background: var(--surface2); border: 1px solid var(--border); + color: var(--text-dim); transition: all var(--transition); } + .tag.accent { border-color: rgba(108,142,239,0.3); color: var(--accent); background: rgba(108,142,239,0.06); } + + /* ── Blockquote ── */ + blockquote { border-left: 3px solid var(--accent); padding: 12px 20px; margin: 16px 0; + background: rgba(108,142,239,0.05); border-radius: 0 10px 10px 0; font-size: 14px; + color: var(--text-dim); transition: all var(--transition); } + + /* ── Separator ── */ + .divider, hr { border: none; height: 1px; + background: linear-gradient(90deg, transparent, var(--border), transparent); margin: 40px 0; } + + /* ── Responsive ── */ + @media (max-width: 720px) { + .grid-2, .profile-grid { grid-template-columns: 1fr; } + .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } + .flow { flex-direction: column; } + .flow-arrow { transform: rotate(90deg); } + .cover-page h1, .header-bar h1 { font-size: 24px; } + .arch-diagram { font-size: 10px; padding: 16px; } + } + """; + + private const string CssSeminarSidebar = CssSeminar + """ + + /* ═══════ Sidebar TOC Layout Override ═══════ */ + .page-wrapper { display: flex; min-height: 100vh; } + .container { max-width: 980px; padding: 0 28px; margin-left: 280px; margin-right: auto; } + @supports (margin-left: max(0px, 0px)) { + .container { margin-left: max(280px, calc((100vw + 280px - 980px) / 2)); } + } + .toc { + position: fixed; top: 0; left: 0; width: 280px; height: 100vh; + overflow-y: auto; background: var(--surface); border-right: 1px solid var(--border); + padding: 24px 16px; z-index: 100; box-shadow: 2px 0 16px var(--shadow); + transition: all var(--transition); + } + .toc h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 2px; + color: var(--text-dim); margin-bottom: 14px; font-weight: 700; padding: 0 8px; } + .toc-grid { display: flex; flex-direction: column; gap: 2px; } + .toc a { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); + padding: 6px 8px; border-radius: 6px; font-size: 12.5px; transition: all 0.15s; } + .toc a:hover { background: var(--surface2); color: var(--accent); } + .toc a.active { background: rgba(108,142,239,0.1); color: var(--accent); font-weight: 600; } + .toc-num { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; + border-radius: 5px; font-size: 10px; font-weight: 700; + background: rgba(108,142,239,0.1); color: var(--accent); flex-shrink: 0; } + .toc-sub { padding-left: 28px; font-size: 11.5px; color: var(--text-dim); } + .toc-sub:hover { color: var(--accent); } + .toc-divider { height: 1px; background: var(--border); margin: 6px 0; } + .toc::-webkit-scrollbar { width: 4px; } + .toc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + + @media (max-width: 960px) { + .toc { position: static; width: 100%; height: auto; max-height: none; + border-right: none; border-bottom: 1px solid var(--border); + box-shadow: 0 4px 16px var(--shadow); } + .container { margin-left: 0; } + .page-wrapper { flex-direction: column; } + } + """; + #endregion + // ════════════════════════════════════════════════════════════════════ // 공통 CSS 컴포넌트 (모든 무드에 자동 첨부) // ════════════════════════════════════════════════════════════════════ @@ -602,6 +868,66 @@ public static class TemplateService #region Shared — 공통 컴포넌트 private const string CssShared = """ + /* ── 범용 다크 모드 (CSS 변수 미사용 무드용) ── */ + [data-theme="dark"] body { background: #0f172a; color: #e2e8f0; } + [data-theme="dark"] .container { background: #1e293b; color: #e2e8f0; border-color: #334155; } + [data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: #e2e8f0; } + [data-theme="dark"] p, [data-theme="dark"] li { color: #cbd5e1; } + [data-theme="dark"] th { background: #334155; color: #e2e8f0; border-color: #475569; } + [data-theme="dark"] td { border-color: #334155; color: #cbd5e1; } + [data-theme="dark"] tr:hover td { background: #1e293b; } + [data-theme="dark"] tr:nth-child(even) td { background: #1e293b; } + [data-theme="dark"] code { background: rgba(99,102,241,0.15); color: #a5b4fc; border-color: rgba(99,102,241,0.2); } + [data-theme="dark"] pre { background: #0f172a; color: #e2e8f0; } + [data-theme="dark"] blockquote { background: rgba(99,102,241,0.08); color: #cbd5e1; } + [data-theme="dark"] .card { background: #1e293b; border-color: #334155; color: #e2e8f0; } + [data-theme="dark"] nav.toc { background: #1e293b; border-color: #334155; } + [data-theme="dark"] nav.toc a { color: #818cf8; } + [data-theme="dark"] .callout { background: rgba(99,102,241,0.08); border-color: #6366f1; color: #cbd5e1; } + [data-theme="dark"] .callout-info { background: rgba(59,130,246,0.1); border-color: #3b82f6; } + [data-theme="dark"] .callout-warning { background: rgba(245,158,11,0.1); border-color: #f59e0b; } + [data-theme="dark"] .callout-tip { background: rgba(34,197,94,0.1); border-color: #22c55e; } + [data-theme="dark"] .callout-danger { background: rgba(239,68,68,0.1); border-color: #ef4444; } + [data-theme="dark"] .highlight, [data-theme="dark"] .highlight-box { background: rgba(99,102,241,0.1); } + [data-theme="dark"] .cover-page { background: linear-gradient(135deg, #312e81 0%, #4c1d95 100%); } + [data-theme="dark"] .header-bar { border-color: #334155; } + [data-theme="dark"] .meta { color: #94a3b8; } + [data-theme="dark"] .chart-bar .bar-track { background: #334155; } + [data-theme="dark"] .divider, [data-theme="dark"] .divider-thick { border-color: #334155; } + [data-theme="dark"] hr { background: #334155; } + [data-theme="dark"] .kpi-card, [data-theme="dark"] .chart-area { background: #1e293b; border-color: #334155; color: #e2e8f0; } + [data-theme="dark"] .kpi-card .kpi-value { color: #e2e8f0; } + [data-theme="dark"] .kpi-card .kpi-label { color: #94a3b8; } + [data-theme="dark"] .badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; } + [data-theme="dark"] .badge-green { background: rgba(34,197,94,0.15); color: #4ade80; } + [data-theme="dark"] .badge-red { background: rgba(239,68,68,0.15); color: #f87171; } + [data-theme="dark"] .badge-yellow { background: rgba(245,158,11,0.15); color: #fbbf24; } + [data-theme="dark"] .badge-purple { background: rgba(139,92,246,0.15); color: #a78bfa; } + [data-theme="dark"] .badge-gray { background: rgba(107,114,128,0.15); color: #9ca3af; } + [data-theme="light"] body { } /* light 기본값 — 각 무드 CSS가 우선 */ + + /* ── 테마 토글 버튼 ── */ + .ax-theme-toggle { position: fixed; top: 16px; right: 16px; z-index: 1000; + width: 40px; height: 40px; border-radius: 50%; border: 1px solid #d1d5db; + background: #fff; color: #374151; font-size: 18px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; } + .ax-theme-toggle:hover { transform: scale(1.1); box-shadow: 0 4px 16px rgba(0,0,0,0.15); } + [data-theme="dark"] .ax-theme-toggle { background: #1e293b; color: #e2e8f0; border-color: #334155; } + + /* ── 플로팅 TOC 버튼 ── */ + .ax-fab-toc { position: fixed; bottom: 24px; right: 24px; z-index: 999; + width: 44px; height: 44px; border-radius: 12px; border: 1px solid #d1d5db; + background: #fff; color: #4b5efc; font-size: 20px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 16px rgba(0,0,0,0.1); transition: all 0.3s ease; + opacity: 0; pointer-events: none; transform: translateY(12px); } + .ax-fab-toc.visible { opacity: 1; pointer-events: auto; transform: translateY(0); } + .ax-fab-toc:hover { background: #4b5efc; color: #fff; transform: translateY(-2px); + box-shadow: 0 6px 24px rgba(75,94,252,0.3); } + [data-theme="dark"] .ax-fab-toc { background: #1e293b; border-color: #334155; } + [data-theme="dark"] .ax-fab-toc:hover { background: #4b5efc; } + /* ── 목차 (TOC) ── */ nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px; padding: 20px 28px; margin: 24px 0 32px; } @@ -718,6 +1044,7 @@ public static class TemplateService /* ── 인쇄/PDF 최적화 ── */ @media print { + .ax-theme-toggle, .ax-fab-toc { display: none !important; } body { background: #fff !important; padding: 0 !important; } .container { box-shadow: none !important; border: none !important; max-width: none !important; padding: 20px !important; } diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs index dc571c4..0f23a5b 100644 --- a/src/AxCopilot/Services/Agent/ToolRegistry.cs +++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs @@ -15,7 +15,7 @@ public class ToolRegistry : IDisposable /// 도구를 이름으로 찾습니다. public IAgentTool? Get(string name) => - _tools.TryGetValue(name, out var tool) ? tool : null; + _tools.TryGetValue(AgentToolCatalog.Canonicalize(name), out var tool) ? tool : null; /// 도구를 등록합니다. public void Register(IAgentTool tool) => _tools[tool.Name] = tool; @@ -60,7 +60,7 @@ public class ToolRegistry : IDisposable public IReadOnlyCollection GetActiveTools(IEnumerable? disabledNames = null) { if (disabledNames == null) return All; - var disabled = new HashSet(disabledNames, StringComparer.OrdinalIgnoreCase); + var disabled = new HashSet(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase); if (disabled.Count == 0) return All; return OrderToolsForExposure(_tools.Values.Where(t => !disabled.Contains(t.Name))) .ToList() @@ -71,7 +71,7 @@ public class ToolRegistry : IDisposable public IReadOnlyCollection GetActiveToolsForTab(string activeTab, IEnumerable? disabledNames = null) { var disabled = disabledNames != null - ? new HashSet(disabledNames, StringComparer.OrdinalIgnoreCase) + ? new HashSet(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase) : null; return OrderToolsForExposure(_tools.Values.Where(t => @@ -90,17 +90,7 @@ public class ToolRegistry : IDisposable private static int GetToolExposureBucket(IAgentTool tool) { - return tool.Name switch - { - "file_read" or "file_write" or "file_edit" or "glob" or "grep" or "document_read" - or "process" or "dev_env_detect" or "build_run" or "git_tool" or "lsp_code_intel" - or "document_plan" or "document_assemble" or "docx_create" or "html_create" or "markdown_create" - or "excel_create" or "csv_create" or "pptx_create" or "chart_create" => 0, - "folder_map" or "document_review" or "format_convert" or "tool_search" or "code_search" => 1, - "mcp_list_resources" or "mcp_read_resource" or "spawn_agent" or "wait_agents" => 2, - _ when tool.Name.StartsWith("task_", StringComparison.OrdinalIgnoreCase) => 3, - _ => 1 - }; + return AgentToolCatalog.GetExposureBucket(tool.Name); } /// 도구가 해당 탭에서 사용 가능한지 확인합니다. @@ -122,114 +112,14 @@ public class ToolRegistry : IDisposable /// IAgentTool.TabCategory가 null인 도구는 이 맵을 참조합니다. /// 키: 도구 이름, 값: 허용 탭 (쉼표 구분). 맵에 없으면 = 모든 탭. ///
- private static readonly Dictionary ToolTabOverrides = new(StringComparer.OrdinalIgnoreCase) - { - // ════════════════════════════════════════════════════════════ - // Chat = 순수 대화 (도구 없음). 아래 맵에 없는 공통 도구도 - // Chat에선 제외하려면 여기에 "Cowork,Code"로 등록. - // ════════════════════════════════════════════════════════════ - - // ── 파일/검색 기본 도구: Cowork + Code ── - ["file_read"] = "Cowork,Code", - ["file_write"] = "Cowork,Code", - ["file_edit"] = "Cowork,Code", - ["glob"] = "Cowork,Code", - ["grep"] = "Cowork,Code", - ["process"] = "Cowork,Code", - ["folder_map"] = "Cowork,Code", - ["document_read"] = "Cowork,Code", - ["file_manage"] = "Cowork,Code", - ["file_info"] = "Cowork,Code", - ["multi_read"] = "Cowork,Code", - ["zip"] = "Cowork,Code", - ["open_external"] = "Cowork,Code", - - // ── 데이터/유틸리티: Cowork + Code ── - ["json"] = "Cowork,Code", - ["regex"] = "Cowork,Code", - ["base64"] = "Cowork,Code", - ["hash"] = "Cowork,Code", - ["datetime"] = "Cowork,Code", - ["math"] = "Cowork,Code", - ["encoding"] = "Cowork,Code", - ["http"] = "Cowork,Code", - ["clipboard"] = "Cowork,Code", - ["env"] = "Cowork,Code", - ["notify"] = "Cowork,Code", - ["user_ask"] = "Cowork,Code", - ["memory"] = "Cowork,Code", - ["skill_manager"] = "Cowork,Code", - ["tool_search"] = "Cowork,Code", - ["mcp_list_resources"] = "Cowork,Code", - ["mcp_read_resource"] = "Cowork,Code", - - // ── 문서 생성/처리: Cowork 전용 ── - ["xlsx_create"] = "Cowork", - ["excel_create"] = "Cowork", - ["docx_create"] = "Cowork", - ["csv_create"] = "Cowork", - ["md_create"] = "Cowork", - ["markdown_create"] = "Cowork", - ["html_create"] = "Cowork", - ["chart_create"] = "Cowork", - ["batch_create"] = "Cowork", - ["pptx_create"] = "Cowork", - ["document_plan"] = "Cowork", - ["document_assemble"] = "Cowork", - ["document_review"] = "Cowork", - ["format_convert"] = "Cowork", - ["data_pivot"] = "Cowork", - ["template_render"] = "Cowork", - ["text_summarize"] = "Cowork", - ["sql"] = "Cowork", - ["xml"] = "Cowork", - ["image_analyze"] = "Cowork", - - // ── 개발 도구: Code 전용 ── - ["dev_env_detect"] = "Code", - ["build_run"] = "Code", - ["git"] = "Code", - ["lsp"] = "Code", - ["code_search"] = "Code", - ["code_review"] = "Code", - ["project_rule"] = "Code", - ["snippet_run"] = "Code", - ["diff"] = "Code", - ["diff_preview"] = "Code", - ["sub_agent"] = "Code", - ["wait_agents"] = "Code", - ["test_loop"] = "Code", - ["file_watch"] = "Code", - - // ── 태스크/워크트리/팀: Code 전용 ── - ["task_tracker"] = "Code", - ["todo_write"] = "Code", - ["task_create"] = "Code", - ["task_get"] = "Code", - ["task_list"] = "Code", - ["task_update"] = "Code", - ["task_stop"] = "Code", - ["task_output"] = "Code", - ["enter_worktree"] = "Code", - ["exit_worktree"] = "Code", - ["team_create"] = "Code", - ["team_delete"] = "Code", - ["cron_create"] = "Code", - ["cron_delete"] = "Code", - ["cron_list"] = "Code", - ["checkpoint"] = "Code", - ["suggest_actions"] = "Code", - ["playbook"] = "Code", - }; - /// 도구의 실질 탭 카테고리를 결정합니다 (IAgentTool.TabCategory → 오버라이드 맵 순). private static string? ResolveTabCategory(IAgentTool tool) { // 도구 자체에 TabCategory가 명시되어 있으면 우선 if (!string.IsNullOrEmpty(tool.TabCategory)) return tool.TabCategory; - // 오버라이드 맵에서 조회 - return ToolTabOverrides.TryGetValue(tool.Name, out var cat) ? cat : null; + + return AgentToolCatalog.GetTabCategory(tool.Name); } /// IDisposable 도구를 모두 해제합니다. @@ -286,6 +176,7 @@ public class ToolRegistry : IDisposable registry.Register(new GitTool()); registry.Register(new LspTool()); registry.Register(new SubAgentTool()); + registry.Register(new SpawnAgentsTool()); registry.Register(new WaitAgentsTool()); registry.Register(new CodeSearchTool()); registry.Register(new TestLoopTool()); diff --git a/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs b/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs new file mode 100644 index 0000000..5f2d309 --- /dev/null +++ b/src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs @@ -0,0 +1,409 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace AxCopilot.Services.Agent; + +/// +/// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다. +/// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다. +/// +internal static class WorkspaceContextGenerator +{ + private const string ContextFileName = ".ax-context.md"; + private const int MaxDepth = 3; + private const int MaxReadmeChars = 2000; + private const int MaxContextChars = 4000; + + private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", "node_modules", "bin", "obj", ".vs", "__pycache__", ".idea", + ".vscode", "dist", "build", "target", ".next", ".nuget", "packages", + ".ax", "coverage", ".mypy_cache", "venv", ".venv", "env", + }; + + /// + /// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다. + /// + public static async Task EnsureContextAsync(string workFolder, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) + return null; + + var path = Path.Combine(workFolder, ContextFileName); + if (File.Exists(path)) + return LoadContext(workFolder); + + return await GenerateAsync(workFolder, ct).ConfigureAwait(false); + } + + /// + /// 강제 재생성합니다. + /// + public static async Task GenerateAsync(string workFolder, CancellationToken ct = default) + { + var sb = new StringBuilder(); + sb.AppendLine("# Workspace Context (auto-generated)"); + sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd}"); + sb.AppendLine(); + + // 1. 프로젝트 기본 정보 + var buildSystem = DetectBuildSystem(workFolder); + var extDist = GetExtensionDistribution(workFolder, ct); + var primaryLang = extDist.FirstOrDefault(); + + sb.AppendLine("## Project"); + var projectName = Path.GetFileName(workFolder); + sb.AppendLine($"- Name: {projectName}"); + if (buildSystem != null) + sb.AppendLine($"- Build System: {buildSystem}"); + if (primaryLang.Key != null) + sb.AppendLine($"- Primary Language: {GetLanguageName(primaryLang.Key)} ({primaryLang.Key}: {primaryLang.Value} files)"); + + // Git 정보 + var gitInfo = await GetGitInfoAsync(workFolder, ct).ConfigureAwait(false); + if (gitInfo.Branch != null) + sb.AppendLine($"- Git Branch: {gitInfo.Branch}"); + if (gitInfo.Remote != null) + sb.AppendLine($"- Git Remote: {gitInfo.Remote}"); + sb.AppendLine(); + + // 2. 디렉토리 구조 + sb.AppendLine("## Structure"); + var tree = BuildDirectoryTree(workFolder, MaxDepth); + foreach (var line in tree.Take(30)) // 최대 30줄 + sb.AppendLine(line); + sb.AppendLine(); + + // 3. 확장자 분포 + if (extDist.Count > 0) + { + sb.AppendLine("## File Distribution"); + sb.AppendLine(string.Join(", ", extDist.Take(10).Select(kv => $"{kv.Key}: {kv.Value}"))); + sb.AppendLine(); + } + + // 4. 기존 컨텍스트 파일 감지 + var contextFiles = DetectContextFiles(workFolder); + if (contextFiles.Count > 0) + { + sb.AppendLine("## Existing Context Files"); + foreach (var cf in contextFiles) + sb.AppendLine($"- {cf}"); + sb.AppendLine(); + } + + // 5. README 요약 + var readmeSummary = ExtractReadmeSummary(workFolder); + if (readmeSummary != null) + { + sb.AppendLine("## README Summary"); + sb.AppendLine(readmeSummary); + sb.AppendLine(); + } + + var content = sb.ToString().TrimEnd(); + + // 파일 저장 + try + { + var path = Path.Combine(workFolder, ContextFileName); + await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false); + } + catch + { + // 저장 실패는 무시 — 읽기 전용 폴더 등 + } + + return content; + } + + /// + /// 기존 .ax-context.md를 읽습니다. 없으면 null. + /// + public static string? LoadContext(string? workFolder) + { + if (string.IsNullOrEmpty(workFolder)) return null; + var path = Path.Combine(workFolder, ContextFileName); + if (!File.Exists(path)) return null; + + try + { + var content = File.ReadAllText(path); + return content.Length > MaxContextChars + ? content[..MaxContextChars] + "\n...(truncated)" + : content; + } + catch { return null; } + } + + // ════════════════════════════════════════════════════════════ + // 분석 로직 + // ════════════════════════════════════════════════════════════ + + private static string? DetectBuildSystem(string folder) + { + var checks = new (string Pattern, string Name)[] + { + ("*.sln", ".NET (Solution)"), + ("*.csproj", ".NET"), + ("package.json", "Node.js"), + ("Cargo.toml", "Rust"), + ("go.mod", "Go"), + ("pom.xml", "Java (Maven)"), + ("build.gradle", "Java (Gradle)"), + ("pyproject.toml", "Python"), + ("requirements.txt", "Python"), + ("Makefile", "Make"), + ("CMakeLists.txt", "CMake"), + }; + + foreach (var (pattern, name) in checks) + { + try + { + if (Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly).Length > 0) + return name; + } + catch { /* 무시 */ } + } + + return null; + } + + private static List> GetExtensionDistribution( + string folder, CancellationToken ct) + { + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + MaxRecursionDepth = 5, + })) + { + ct.ThrowIfCancellationRequested(); + + // 경로 세그먼트 단위로 SkipDirs 검사 (정확한 디렉토리명 매칭) + var dir = Path.GetDirectoryName(file) ?? ""; + if (ShouldSkipPath(dir)) + continue; + + var ext = Path.GetExtension(file); + if (string.IsNullOrEmpty(ext) || ext.Length > 8) continue; + + counts.TryGetValue(ext, out var count); + counts[ext] = count + 1; + } + } + catch (OperationCanceledException) { throw; } + catch { /* 무시 */ } + + return counts.OrderByDescending(kv => kv.Value).ToList(); + } + + private static List BuildDirectoryTree(string root, int maxDepth) + { + var result = new List(); + BuildTreeRecursive(root, root, 0, maxDepth, result); + return result; + } + + private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List result) + { + if (depth >= maxDepth || result.Count >= 30) return; + + IEnumerable dirs; + try { dirs = Directory.GetDirectories(current); } + catch { return; } + + foreach (var dir in dirs.OrderBy(d => d)) + { + if (result.Count >= 30) break; + + var name = Path.GetFileName(dir); + if (SkipDirs.Contains(name)) continue; + + // 심링크/junction 무한루프 방지: 속성 검사 + try + { + var attrs = File.GetAttributes(dir); + if (attrs.HasFlag(FileAttributes.ReparsePoint)) + continue; + } + catch { continue; } + + // TopDirectoryOnly로 제한하여 대규모 디렉토리 탐색 방지 + int fileCount; + try + { + fileCount = Directory.EnumerateFiles(dir, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + MaxRecursionDepth = 3, + }).Take(10_000).Count(); // 최대 10K개만 카운트 + } + catch { fileCount = 0; } + + var indent = new string(' ', depth * 2); + var relativePath = Path.GetRelativePath(root, dir).Replace('\\', '/'); + result.Add($"{indent}{relativePath}/ ({fileCount}{(fileCount >= 10_000 ? "+" : "")} files)"); + + BuildTreeRecursive(root, dir, depth + 1, maxDepth, result); + } + } + + private static string? ExtractReadmeSummary(string folder) + { + var names = new[] { "README.md", "readme.md", "README", "README.txt" }; + foreach (var name in names) + { + var path = Path.Combine(folder, name); + if (!File.Exists(path)) continue; + + try + { + var text = File.ReadAllText(path); + if (text.Length > MaxReadmeChars) + text = text[..MaxReadmeChars]; + + // 첫 번째 단락 추출 (제목 제외) + var lines = text.Split('\n'); + var paragraphLines = new List(); + var foundContent = false; + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith('#') && !foundContent) continue; // 제목 건너뛰기 + if (string.IsNullOrWhiteSpace(trimmed)) + { + if (foundContent && paragraphLines.Count > 0) break; + continue; + } + foundContent = true; + paragraphLines.Add(trimmed); + } + + if (paragraphLines.Count > 0) + return string.Join(" ", paragraphLines); + } + catch { /* 무시 */ } + } + + return null; + } + + private static List DetectContextFiles(string folder) + { + var files = new List(); + var names = new[] { "AGENTS.md", "AX.md", "CLAUDE.md", ".clinerules", ".ax-rules" }; + foreach (var name in names) + { + if (File.Exists(Path.Combine(folder, name))) + files.Add(name); + } + + var axDir = Path.Combine(folder, ".ax"); + if (Directory.Exists(axDir)) + { + try + { + var rulesDir = Path.Combine(axDir, "rules"); + if (Directory.Exists(rulesDir)) + { + var ruleFiles = Directory.GetFiles(rulesDir, "*.md"); + if (ruleFiles.Length > 0) + files.Add($".ax/rules/ ({ruleFiles.Length} files)"); + } + } + catch { /* 무시 */ } + } + + return files; + } + + private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync( + string folder, CancellationToken ct) + { + if (!Directory.Exists(Path.Combine(folder, ".git"))) + return (null, null); + + string? branch = null; + string? remote = null; + + try + { + branch = await RunGitAsync(folder, "rev-parse --abbrev-ref HEAD", ct).ConfigureAwait(false); + remote = await RunGitAsync(folder, "remote get-url origin", ct).ConfigureAwait(false); + } + catch { /* Git 없거나 실패 */ } + + return (branch?.Trim(), remote?.Trim()); + } + + private static async Task RunGitAsync(string folder, string args, CancellationToken ct) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = args, + WorkingDirectory = folder, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + return process.ExitCode == 0 ? output.Trim() : null; + } + catch { return null; } + } + + /// 경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사. + private static bool ShouldSkipPath(string dirPath) + { + var span = dirPath.AsSpan(); + while (span.Length > 0) + { + var sepIdx = span.IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var segment = sepIdx >= 0 ? span[..sepIdx] : span; + if (segment.Length > 0 && SkipDirs.Contains(segment.ToString())) + return true; + if (sepIdx < 0) break; + span = span[(sepIdx + 1)..]; + } + return false; + } + + private static string GetLanguageName(string ext) => ext.ToLowerInvariant() switch + { + ".cs" => "C#", + ".ts" or ".tsx" => "TypeScript", + ".js" or ".jsx" => "JavaScript", + ".py" => "Python", + ".rs" => "Rust", + ".go" => "Go", + ".java" => "Java", + ".cpp" or ".cc" or ".cxx" => "C++", + ".c" => "C", + ".rb" => "Ruby", + ".php" => "PHP", + ".swift" => "Swift", + ".kt" => "Kotlin", + ".xaml" => "XAML", + ".html" or ".htm" => "HTML", + ".css" => "CSS", + _ => ext.TrimStart('.').ToUpperInvariant(), + }; +} diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index 0778d74..660d641 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -542,7 +542,7 @@ public sealed class AppStateService : IAppStateService var description = effective switch { "AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.", - "Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.", + "Deny" => "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.", "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", "BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.", diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index efc8273..c9a253c 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -158,11 +158,27 @@ public sealed class ChatSessionStateService var normalizedTab = NormalizeTab(tab); var created = new ChatConversation { Tab = normalizedTab }; - // Code/Cowork 탭: 매 대화마다 폴더를 새로 선택하도록 빈 상태로 시작 + // ── 버그 수정: 현재 사용자가 선택한 권한(FilePermission)을 새 대화에 승계 ── + // 이전 버그: Permission=null인 상태로 생성되면 LoadConversationSettings가 + // DefaultAgentPermission(별도 필드, 기본 "Deny")으로 폴백하고 + // _settings.Llm.FilePermission을 덮어써버림. + // → UI엔 "권한 건너뛰기"가 표시돼도 실제 실행 시엔 Default/Deny 모드라 + // html_create/document_plan 등에서 승인 창이 뜸. + // Chat 탭은 기본 Deny가 안전하므로 승계하지 않음 (기존 동작 유지). if (string.Equals(normalizedTab, "Code", StringComparison.OrdinalIgnoreCase) || string.Equals(normalizedTab, "Cowork", StringComparison.OrdinalIgnoreCase)) { created.WorkFolder = ""; + try + { + var currentPerm = AxCopilot.Services.Agent.PermissionModeCatalog.NormalizeGlobalMode( + settings.Settings.Llm.FilePermission); + // Deny는 새 대화의 기본으로는 부적절(사용자가 의도적으로 Deny를 선택했을 가능성도 있지만 + // Cowork/Code에서는 에이전트 실행 자체가 주 목적이므로 그대로 두면 혼란). + // 단, 단순 승계가 사용자 의도에 가장 부합하므로 그대로 반영. + created.Permission = currentPerm; + } + catch { /* 설정 접근 실패 시 Permission=null 유지 (fallback 경로) */ } CurrentConversation = created; return created; } @@ -271,13 +287,20 @@ public sealed class ChatSessionStateService { var normalizedTab = NormalizeTab(tab); conversation.Tab = normalizedTab; - NormalizeLoadedConversation(conversation); + var normalized = NormalizeLoadedConversation(conversation); CurrentConversation = conversation; if (remember && !string.IsNullOrWhiteSpace(conversation.Id)) RememberConversation(normalizedTab, conversation.Id); - try { storage?.Save(conversation); } catch { } + // 대화 "선택"만으로는 대화 내용이 변하지 않음. 기존에는 무조건 Save()를 호출해 + // storage.Save()가 UpdatedAt = DateTime.Now로 갱신 → 목록에서 맨 위로 올라가는 부작용. + // 실제 정규화(NormalizeLoadedConversation)가 일어난 경우에만 저장한다. + // — 다른 경로(EnsureCurrentConversation 등)도 이미 if(normalized) 패턴을 따른다. + if (normalized) + { + try { storage?.Save(conversation); } catch { } + } return conversation; } diff --git a/src/AxCopilot/Services/ChatStorageService.cs b/src/AxCopilot/Services/ChatStorageService.cs index aecde89..e10e1da 100644 --- a/src/AxCopilot/Services/ChatStorageService.cs +++ b/src/AxCopilot/Services/ChatStorageService.cs @@ -90,6 +90,9 @@ public class ChatStorageService : IChatStorageService } } + /// 대화를 비동기로 로드합니다 (UI 스레드 블록 방지). + public Task LoadAsync(string id) => Task.Run(() => Load(id)); + // ── 메타 캐시 ───────────────────────────────────────────────────────── private List? _metaCache; private List? _metaOrderedCache; @@ -193,6 +196,7 @@ public class ChatStorageService : IChatStorageService return result; } + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); Lock.EnterReadLock(); try { @@ -202,7 +206,7 @@ public class ChatStorageService : IChatStorageService { var json = CryptoService.DecryptFromFile(file); var conv = JsonSerializer.Deserialize(json, JsonOpts); - if (conv != null) + if (conv != null && !string.IsNullOrWhiteSpace(conv.Id) && seenIds.Add(conv.Id)) { var meta = new ChatConversation { diff --git a/src/AxCopilot/Services/CryptoService.cs b/src/AxCopilot/Services/CryptoService.cs index 87e048d..4d9ebc2 100644 --- a/src/AxCopilot/Services/CryptoService.cs +++ b/src/AxCopilot/Services/CryptoService.cs @@ -201,12 +201,42 @@ public static class CryptoService File.WriteAllBytes(filePath, enc); } - /// PC별 키로 AES-256-GCM 암호화 파일을 복호화 + /// PC별 키로 AES-256-GCM 암호화 파일을 복호화. 평문 JSON 파일은 그대로 반환. public static string DecryptFromFile(string filePath) { if (!File.Exists(filePath)) return ""; - var enc = File.ReadAllBytes(filePath); - var plain = DecryptBytes(enc); - return Encoding.UTF8.GetString(plain); + var raw = File.ReadAllBytes(filePath); + if (raw.Length == 0) return ""; + + // 평문 JSON 파일 감지: UTF-8 텍스트가 '{' 또는 '[' 로 시작하면 암호화되지 않은 것으로 간주 + if (raw.Length > 0 && (raw[0] == (byte)'{' || raw[0] == (byte)'[' || raw[0] == 0xEF /* BOM */)) + { + try + { + return Encoding.UTF8.GetString(raw); + } + catch + { + // BOM이었지만 유효한 UTF-8이 아닌 경우 → 암호화된 데이터로 처리 + } + } + + try + { + var plain = DecryptBytes(raw); + return Encoding.UTF8.GetString(plain); + } + catch (System.Security.Cryptography.CryptographicException) + { + // 복호화 실패 시 평문 텍스트로 한 번 더 시도 (마이그레이션/손상 대응) + try + { + var text = Encoding.UTF8.GetString(raw); + if (text.Contains('"') && (text.TrimStart().StartsWith('{') || text.TrimStart().StartsWith('['))) + return text; + } + catch { /* 무시 */ } + throw; // 평문도 아니면 원래 예외 재전파 + } } } diff --git a/src/AxCopilot/Services/Interfaces/IChatStorageService.cs b/src/AxCopilot/Services/Interfaces/IChatStorageService.cs index 6249f2c..044a19c 100644 --- a/src/AxCopilot/Services/Interfaces/IChatStorageService.cs +++ b/src/AxCopilot/Services/Interfaces/IChatStorageService.cs @@ -7,6 +7,7 @@ public interface IChatStorageService { void Save(ChatConversation conversation); ChatConversation? Load(string id); + Task LoadAsync(string id); List LoadAllMeta(); void InvalidateMetaCache(); void UpdateMetaCache(ChatConversation conv); diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index 5a272e3..f874c08 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -528,6 +528,12 @@ public partial class LlmService url = endpoint.TrimEnd('/') + "/v1/chat/completions"; var json = JsonSerializer.Serialize(body); + if (isIbmDeployment) + { + IbmDiagInfo($"[IBM진단] ToolUse.Send: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}"); + IbmDiagDebug($"[IBM진단] ToolUse.Send 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "…" : json)}"); + } + // Raw 요청 로깅 (상세 로그 활성 시) WorkflowLogService.LogLlmRawRequestFromContext(url, json); @@ -543,6 +549,8 @@ public partial class LlmService { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); + if (isIbmDeployment) + IbmDiagError($"[IBM진단] ToolUse.Send API 오류: HTTP {(int)resp.StatusCode}, body={errBody}"); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); if (forceToolCall && (int)resp.StatusCode == 400) @@ -596,6 +604,23 @@ public partial class LlmService @"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}", System.Text.RegularExpressions.RegexOptions.Compiled); + // 패턴 4: 파이프-래핑 커스텀 포맷 (FastAPI로 호스팅된 Gemma 계열, 일부 IBM/Kimi/GLM 배포에서 leak) + // 예: <|tool_call>call;document_read{path:<|"|>전략보고서.html<|"|>} + // - 앞 여는 태그 `<|tool_call>` / 닫는 태그 `` 혹은 `` + // - 본문은 `call;NAME{args}` 또는 `NAME{args}` 형태 + // - args 내부의 `<|"|>` 는 따옴표로 디코딩, 비인용 키는 따옴표 부여 + private static readonly System.Text.RegularExpressions.Regex ToolCallPipeWrappedRegex = new( + @"<\|\s*tool_call\s*\|?>\s*(?:call\s*;\s*)?(\w+)\s*(\{[\s\S]*?\})\s*<\s*/?\s*tool_call\s*\|\s*>", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly System.Text.RegularExpressions.Regex PipeQuoteDecodeRegex = new( + @"<\|\s*""\s*\|>", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly System.Text.RegularExpressions.Regex UnquotedJsonKeyRegex = new( + @"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", + System.Text.RegularExpressions.RegexOptions.Compiled); + internal static List TryExtractToolCallsFromText(string text) { var results = new List(); @@ -628,9 +653,39 @@ public partial class LlmService } } + // 패턴 4: 파이프-래핑 커스텀 포맷 (<|tool_call>call;NAME{args}) + if (results.Count == 0) + { + foreach (System.Text.RegularExpressions.Match m in ToolCallPipeWrappedRegex.Matches(text)) + { + var name = m.Groups[1].Value; + var rawArgs = m.Groups[2].Value; + // `<|"|>` → `"` 디코딩 + var decoded = PipeQuoteDecodeRegex.Replace(rawArgs, "\""); + // 비인용 키를 JSON 키로 변환 ({path:"x"} → {"path":"x"}) + var normalized = UnquotedJsonKeyRegex.Replace(decoded, "\"$1\":"); + var block = TryParseToolCallJsonWithName(name, normalized); + if (block != null) results.Add(block); + } + } + return results; } + /// + /// 텍스트에서 파싱된 tool_call 태그(4가지 형식 전부)를 제거합니다. + /// 폴백 파싱으로 도구 호출이 추출된 경우, 사용자 화면에 원본 토큰이 남지 않도록 최종 표시 텍스트를 정화. + /// + internal static string StripToolCallTokens(string text) + { + if (string.IsNullOrEmpty(text)) return text; + text = ToolCallTagRegex.Replace(text, ""); + text = ToolCallFunctionRegex.Replace(text, ""); + text = ToolCallJsonRegex.Replace(text, ""); + text = ToolCallPipeWrappedRegex.Replace(text, ""); + return text.Trim(); + } + /// {"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환. private static ContentBlock? TryParseToolCallJson(string json) { @@ -1049,8 +1104,11 @@ public partial class LlmService }).ToArray(); // IBM watsonx: parameters 래퍼 사용, model 필드 없음 - // tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송 - // 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨 + // tool_choice: OpenAI 표준 필드만 전송. + // 이전에는 `tool_choice` + `tool_choice_option` 둘 다 보내 구버전 호환을 시도했지만, + // 최신 IBM vLLM 배포는 다음 오류로 요청을 거부합니다: + // "400 Json document validation error: tool_choice_option should not be defined if a value is given for ToolChoice" + // 구버전 배포(tool_choice_option 전용)는 상위 ToolCallNotSupportedException 폴백이 처리함. // Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨 if (forceToolCall && useToolChoice) { @@ -1059,7 +1117,6 @@ public partial class LlmService messages = msgs, tools = toolDefs, tool_choice = "required", - tool_choice_option = "required", parameters = new { temperature = ResolveToolTemperature(), @@ -1239,8 +1296,19 @@ public partial class LlmService if (prefetchToolCallAsync != null) block.PrefetchedExecutionTask = prefetchToolCallAsync(block); } + // 사용자 화면에 원본 tool_call 토큰이 남지 않도록 텍스트 정화 + var cleanedText = StripToolCallTokens(textBlock.Text); + result.Remove(textBlock); + if (!string.IsNullOrWhiteSpace(cleanedText)) + result.Add(new ContentBlock { Type = "text", Text = cleanedText }); result.AddRange(extracted); - LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)"); + var toolNames = string.Join(", ", extracted.Select(e => e.ToolName)); + IbmDiagInfo($"[IBM진단] 텍스트 폴백에서 도구 호출 {extracted.Count}건 추출: [{toolNames}]"); + } + else if (usesIbmDeploymentApi) + { + var preview = textBlock.Text.Length > 300 ? textBlock.Text[..300] + "…" : textBlock.Text; + IbmDiagError($"[IBM진단] 응답에 tool_calls 없음, 텍스트 폴백 파싱도 실패. 응답 텍스트: {preview}"); } } } @@ -1275,6 +1343,11 @@ public partial class LlmService else url = endpoint.TrimEnd('/') + "/v1/chat/completions"; var json = JsonSerializer.Serialize(body); + if (isIbmDeployment) + { + IbmDiagInfo($"[IBM진단] ToolUse.Stream: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}"); + IbmDiagDebug($"[IBM진단] ToolUse.Stream 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "…" : json)}"); + } WorkflowLogService.LogLlmRawRequestFromContext(url, json); using var req = new HttpRequestMessage(HttpMethod.Post, url) @@ -1287,6 +1360,8 @@ public partial class LlmService { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); + if (isIbmDeployment) + IbmDiagError($"[IBM진단] ToolUse.Stream API 오류: HTTP {(int)resp.StatusCode}, body={errBody}"); if (forceToolCall && (int)resp.StatusCode == 400) { LogService.Warn(isIbmDeployment @@ -1329,11 +1404,18 @@ public partial class LlmService { // Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리 var contentType = resp.Content.Headers.ContentType?.MediaType ?? ""; + if (usesIbmDeploymentApi) + IbmDiagDebug($"[IBM진단] ToolUse.ParseStream: ContentType={contentType}"); if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase) && !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase)) { // 비-SSE 전체 JSON 응답 (Ollama stream:false 등) var rawJson = await resp.Content.ReadAsStringAsync(ct); + if (usesIbmDeploymentApi) + { + var preview = rawJson.Length > 500 ? rawJson[..500] + "…" : rawJson; + IbmDiagInfo($"[IBM진단] ToolUse 비-SSE 응답(전체 JSON): len={rawJson.Length}자\n 미리보기: {preview}"); + } var respJson = ExtractJsonFromSseIfNeeded(rawJson); var trimmed = respJson.TrimStart(); if (trimmed.StartsWith('{') || trimmed.StartsWith('[')) @@ -1362,6 +1444,7 @@ public partial class LlmService var firstChunkReceived = false; var toolAccumulators = new Dictionary(); var lastIbmGeneratedText = ""; + var ibmToolChunkCount = 0; while (!reader.EndOfStream && !ct.IsCancellationRequested) { @@ -1380,12 +1463,41 @@ public partial class LlmService firstChunkReceived = true; var data = line["data: ".Length..].Trim(); if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase)) + { + if (usesIbmDeploymentApi) + IbmDiagDebug($"[IBM진단] ToolUse.ParseStream 완료: 총 {ibmToolChunkCount}개 청크, toolAccumulators={toolAccumulators.Count}개"); break; + } - using var doc = JsonDocument.Parse(data); + JsonDocument doc; + try + { + doc = JsonDocument.Parse(data); + } + catch (JsonException jex) + { + if (usesIbmDeploymentApi) + { + var preview = data.Length > 500 ? data[..500] + "…" : data; + IbmDiagError($"[IBM진단] ToolUse.ParseStream JSON 파싱 실패: {jex.Message}\n 원본: {preview}"); + } + continue; + } + using (doc) + { var root = doc.RootElement; TryParseOpenAiUsage(root); + if (usesIbmDeploymentApi) + { + ibmToolChunkCount++; + if (ibmToolChunkCount <= 3 || ibmToolChunkCount % 50 == 0) + { + var preview = data.Length > 300 ? data[..300] + "…" : data; + IbmDiagDebug($"[IBM진단] ToolUse.ParseStream chunk#{ibmToolChunkCount}: {preview}"); + } + } + if (usesIbmDeploymentApi && root.SafeTryGetProperty("status", out var statusEl) && string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase)) @@ -1393,6 +1505,7 @@ public partial class LlmService var detail = root.SafeTryGetProperty("message", out var msgEl) ? msgEl.SafeGetString() : "IBM vLLM 도구 호출 응답 오류"; + IbmDiagError($"[IBM진단] ToolUse.ParseStream 서버 오류: {detail}"); throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류"); } @@ -1497,13 +1610,25 @@ public partial class LlmService toolAccumulators[index] = acc; } + // IBM vLLM은 첫 청크 이후 후속 delta에서 id/name을 ""(빈 문자열)로 다시 보내는 경우가 있음. + // 빈 값으로 덮어쓰면 누적된 name/id가 사라져 TryCreateCompletedToolCallAsync의 + // IsNullOrWhiteSpace(acc.Name) 체크에 걸려 도구 호출이 방출되지 않는다. + // → 비-공백 값일 때만 갱신한다. if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) - acc.Id = idEl.SafeGetString() ?? acc.Id; + { + var idStr = idEl.SafeGetString(); + if (!string.IsNullOrWhiteSpace(idStr)) + acc.Id = idStr; + } if (toolCallEl.SafeTryGetProperty("function", out var functionEl)) { if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) - acc.Name = nameEl.SafeGetString() ?? acc.Name; + { + var nameStr = nameEl.SafeGetString(); + if (!string.IsNullOrWhiteSpace(nameStr)) + acc.Name = nameStr; + } if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl)) { @@ -1539,6 +1664,7 @@ public partial class LlmService } } } + } // using (doc) } foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index)) diff --git a/src/AxCopilot/Services/LlmService.cs b/src/AxCopilot/Services/LlmService.cs index 7a39e0b..80afdc1 100644 --- a/src/AxCopilot/Services/LlmService.cs +++ b/src/AxCopilot/Services/LlmService.cs @@ -26,6 +26,25 @@ public partial class LlmService : ILlmService private string? _systemPrompt; private const int MaxRetries = 2; + + /// IBM+Qwen 진단 로그 활성 여부 (EnableIbmDiagnosticLog 설정 연동). + private bool IsIbmDiagEnabled => _settings.Settings.Llm.EnableIbmDiagnosticLog; + + /// IBM 진단 전용 Debug 로그. EnableIbmDiagnosticLog=true 일 때만 출력. + private void IbmDiagDebug(string msg) + { + if (IsIbmDiagEnabled) LogService.Info($"[IBM진단:DBG] {msg}"); + } + + /// IBM 진단 전용 Info 로그. EnableIbmDiagnosticLog=true 일 때만 출력. + private void IbmDiagInfo(string msg) + { + if (IsIbmDiagEnabled) LogService.Info(msg); + } + + /// IBM 진단 전용 Error 로그. 설정 무관하게 항상 출력 (에러는 항상 기록). + private static void IbmDiagError(string msg) => LogService.Error(msg); + // 첫 청크: 모델이 컨텍스트를 처리하는 시간 (대용량 컨텍스트에서 3분까지 허용) private static readonly TimeSpan FirstChunkTimeout = TimeSpan.FromSeconds(180); // 이후 청크: 스트리밍이 시작된 후 청크 간 최대 간격 @@ -357,9 +376,11 @@ public partial class LlmService : ILlmService return false; var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant(); - return normalizedEndpoint.Contains("/ml/") || + var result = normalizedEndpoint.Contains("/ml/") || normalizedEndpoint.Contains("/deployments/") || normalizedEndpoint.Contains("/text/chat"); + LogService.Debug($"[IBM진단] UsesIbmDeploymentChatApi: service={service}, authType={authType}, endpoint={endpoint?.Length ?? 0}자, result={result}"); + return result; } private string BuildIbmDeploymentChatUrl(string endpoint, bool stream) @@ -369,14 +390,18 @@ public partial class LlmService : ILlmService throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다."); var normalized = trimmed.ToLowerInvariant(); + string url; if (normalized.Contains("/text/chat_stream")) - return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase); - if (normalized.Contains("/text/chat")) - return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed; - if (normalized.Contains("/deployments/")) - return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat"); + url = stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase); + else if (normalized.Contains("/text/chat")) + url = stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed; + else if (normalized.Contains("/deployments/")) + url = trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat"); + else + url = trimmed; - return trimmed; + IbmDiagDebug($"[IBM진단] BuildUrl: stream={stream}, url={url}"); + return url; } private object BuildIbmDeploymentBody(List messages) @@ -384,6 +409,7 @@ public partial class LlmService : ILlmService var msgs = new List(); if (!string.IsNullOrWhiteSpace(_systemPrompt)) msgs.Add(new { role = "system", content = _systemPrompt }); + IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody: messages={messages.Count}건, systemPrompt={(_systemPrompt?.Length ?? 0)}자"); foreach (var m in messages) { @@ -440,13 +466,16 @@ public partial class LlmService : ILlmService }); } + var temperature = ResolveTemperature(); + var maxTokens = ResolveOpenAiCompatibleMaxTokens(); + IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody 완료: finalMessages={msgs.Count}건, temp={temperature}, maxTokens={maxTokens}"); return new { messages = msgs, parameters = new { - temperature = ResolveTemperature(), - max_new_tokens = ResolveOpenAiCompatibleMaxTokens() + temperature, + max_new_tokens = maxTokens }, // Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨 chat_template_kwargs = new { enable_thinking = false }, @@ -509,11 +538,21 @@ public partial class LlmService : ILlmService if (registered != null && registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase)) { - var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey) - ? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled) - : GetDefaultApiKey(llm, activeService); - var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct); - return token; + IbmDiagDebug($"[IBM진단] IBM IAM 인증 시도: model={modelName}, hasApiKey={!string.IsNullOrWhiteSpace(registered.ApiKey)}"); + try + { + var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey) + ? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled) + : GetDefaultApiKey(llm, activeService); + var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct); + IbmDiagDebug($"[IBM진단] IBM IAM 토큰 발급 성공: tokenLen={token?.Length ?? 0}"); + return token; + } + catch (Exception ex) + { + IbmDiagError($"[IBM진단] IBM IAM 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}"); + throw; + } } // CP4D 인증 방식인 경우 @@ -523,10 +562,20 @@ public partial class LlmService : ILlmService registered.AuthType.Equals("cp4d_api_key", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(registered.Cp4dUrl)) { - var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled); - var token = await Cp4dTokenService.GetTokenAsync( - registered.Cp4dUrl, registered.Cp4dUsername, password, ct); - return token; + IbmDiagDebug($"[IBM진단] CP4D 인증 시도: authType={registered.AuthType}, cp4dUrl={registered.Cp4dUrl}, user={registered.Cp4dUsername}"); + try + { + var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled); + var token = await Cp4dTokenService.GetTokenAsync( + registered.Cp4dUrl, registered.Cp4dUsername, password, ct); + IbmDiagDebug($"[IBM진단] CP4D 토큰 발급 성공: tokenLen={token?.Length ?? 0}"); + return token; + } + catch (Exception ex) + { + IbmDiagError($"[IBM진단] CP4D 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}"); + throw; + } } // 기본 Bearer 인증 — 기존 API 키 반환 @@ -802,15 +851,38 @@ public partial class LlmService : ILlmService : ep.TrimEnd('/') + "/v1/chat/completions"; var json = JsonSerializer.Serialize(body); + if (usesIbmDeploymentApi) + IbmDiagInfo($"[IBM진단] SendOpenAi(비스트리밍): url={url}, bodyLen={json.Length}자, messages={messages.Count}건"); + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; await ApplyAuthHeaderAsync(req, ct); - using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct); + HttpResponseMessage resp; + try + { + resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct); + } + catch (Exception ex) + { + if (usesIbmDeploymentApi) + IbmDiagError($"[IBM진단] SendOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}"); + throw; + } + using (resp) + { var respBody = await resp.Content.ReadAsStringAsync(ct); + if (usesIbmDeploymentApi) + { + var contentType = resp.Content.Headers.ContentType?.MediaType ?? "(null)"; + var preview = respBody.Length > 500 ? respBody[..500] + "…" : respBody; + IbmDiagInfo($"[IBM진단] SendOpenAi 응답: HTTP {(int)resp.StatusCode}, ContentType={contentType}, bodyLen={respBody.Length}자"); + IbmDiagDebug($"[IBM진단] SendOpenAi 응답본문: {preview}"); + } + // IBM vLLM이 stream:false 요청에도 SSE 형식(id:/event/data: 라인)으로 응답하는 경우 처리 var effectiveBody = ExtractJsonFromSseIfNeeded(respBody); @@ -834,6 +906,7 @@ public partial class LlmService : ILlmService if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? ""; return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? ""; }, "vLLM 응답"); + } // using (resp) } /// @@ -931,14 +1004,39 @@ public partial class LlmService : ILlmService ? BuildIbmDeploymentChatUrl(ep, stream: true) : ep.TrimEnd('/') + "/v1/chat/completions"; + if (usesIbmDeploymentApi) + { + var bodyJson = JsonSerializer.Serialize(body); + IbmDiagInfo($"[IBM진단] StreamOpenAi: url={url}, bodyLen={bodyJson.Length}자, messages={messages.Count}건"); + IbmDiagDebug($"[IBM진단] StreamOpenAi 요청본문(앞500자): {(bodyJson.Length > 500 ? bodyJson[..500] + "…" : bodyJson)}"); + } + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; await ApplyAuthHeaderAsync(req, ct); - using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct); - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); + HttpResponseMessage resp; + try + { + resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct); + } + catch (Exception ex) + { + if (usesIbmDeploymentApi) + IbmDiagError($"[IBM진단] StreamOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}"); + throw; + } + + if (usesIbmDeploymentApi) + { + var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "(null)"; + IbmDiagInfo($"[IBM진단] StreamOpenAi 연결 성공: HTTP {(int)resp.StatusCode}, ContentType={ct2}"); + } + + using var stream2 = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream2); var firstChunkReceived = false; + var ibmChunkCount = 0; while (!reader.EndOfStream && !ct.IsCancellationRequested) { var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout; @@ -954,7 +1052,12 @@ public partial class LlmService : ILlmService firstChunkReceived = true; if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; var data = line["data: ".Length..]; - if (data == "[DONE]") break; + if (data == "[DONE]") + { + if (usesIbmDeploymentApi) + IbmDiagDebug($"[IBM진단] StreamOpenAi 완료: 총 {ibmChunkCount}개 청크 수신"); + break; + } string? text = null; try @@ -963,12 +1066,21 @@ public partial class LlmService : ILlmService TryParseOpenAiUsage(doc.RootElement); if (usesIbmDeploymentApi) { + ibmChunkCount++; + // 첫 3개 청크 + 이후 50개마다 로깅 (과도한 로그 방지) + if (ibmChunkCount <= 3 || ibmChunkCount % 50 == 0) + { + var preview = data.Length > 300 ? data[..300] + "…" : data; + IbmDiagDebug($"[IBM진단] StreamOpenAi chunk#{ibmChunkCount}: {preview}"); + } + if (doc.RootElement.SafeTryGetProperty("status", out var status) && string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase)) { var detail = doc.RootElement.SafeTryGetProperty("message", out var message) ? message.SafeGetString() : "IBM vLLM 스트리밍 오류"; + IbmDiagError($"[IBM진단] StreamOpenAi 서버 오류 응답: {detail}"); throw new InvalidOperationException(detail); } @@ -1029,7 +1141,13 @@ public partial class LlmService : ILlmService } catch (JsonException ex) { - LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}"); + if (usesIbmDeploymentApi) + { + var preview = data.Length > 500 ? data[..500] + "…" : data; + IbmDiagError($"[IBM진단] StreamOpenAi JSON 파싱 오류: {ex.Message}\n 청크 내용: {preview}"); + } + else + LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}"); } if (!string.IsNullOrEmpty(text)) yield return text; } diff --git a/src/AxCopilot/Themes/AgentClaudeDark.xaml b/src/AxCopilot/Themes/AgentClaudeDark.xaml new file mode 100644 index 0000000..512dad9 --- /dev/null +++ b/src/AxCopilot/Themes/AgentClaudeDark.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentClaudeLight.xaml b/src/AxCopilot/Themes/AgentClaudeLight.xaml new file mode 100644 index 0000000..9ce29ad --- /dev/null +++ b/src/AxCopilot/Themes/AgentClaudeLight.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentClaudeSystem.xaml b/src/AxCopilot/Themes/AgentClaudeSystem.xaml new file mode 100644 index 0000000..703ad63 --- /dev/null +++ b/src/AxCopilot/Themes/AgentClaudeSystem.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentClawDark.xaml b/src/AxCopilot/Themes/AgentClawDark.xaml index 82b754f..4ebffc8 100644 --- a/src/AxCopilot/Themes/AgentClawDark.xaml +++ b/src/AxCopilot/Themes/AgentClawDark.xaml @@ -1,18 +1,21 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentClawLight.xaml b/src/AxCopilot/Themes/AgentClawLight.xaml index 72da32a..71ca7b4 100644 --- a/src/AxCopilot/Themes/AgentClawLight.xaml +++ b/src/AxCopilot/Themes/AgentClawLight.xaml @@ -1,18 +1,21 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentClawSystem.xaml b/src/AxCopilot/Themes/AgentClawSystem.xaml index 0023654..5ec8f66 100644 --- a/src/AxCopilot/Themes/AgentClawSystem.xaml +++ b/src/AxCopilot/Themes/AgentClawSystem.xaml @@ -1,18 +1,21 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Themes/AgentCodexDark.xaml b/src/AxCopilot/Themes/AgentCodexDark.xaml index 610ddf5..85d0a63 100644 --- a/src/AxCopilot/Themes/AgentCodexDark.xaml +++ b/src/AxCopilot/Themes/AgentCodexDark.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentCodexLight.xaml b/src/AxCopilot/Themes/AgentCodexLight.xaml index ff3f7d7..9d6aa98 100644 --- a/src/AxCopilot/Themes/AgentCodexLight.xaml +++ b/src/AxCopilot/Themes/AgentCodexLight.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentCodexSystem.xaml b/src/AxCopilot/Themes/AgentCodexSystem.xaml index 8110f78..b9840b0 100644 --- a/src/AxCopilot/Themes/AgentCodexSystem.xaml +++ b/src/AxCopilot/Themes/AgentCodexSystem.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentEmberDark.xaml b/src/AxCopilot/Themes/AgentEmberDark.xaml index 68b6ec9..b6d867e 100644 --- a/src/AxCopilot/Themes/AgentEmberDark.xaml +++ b/src/AxCopilot/Themes/AgentEmberDark.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentEmberLight.xaml b/src/AxCopilot/Themes/AgentEmberLight.xaml index 9eee6d1..58f7561 100644 --- a/src/AxCopilot/Themes/AgentEmberLight.xaml +++ b/src/AxCopilot/Themes/AgentEmberLight.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentEmberSystem.xaml b/src/AxCopilot/Themes/AgentEmberSystem.xaml index 1acd183..d8e3dda 100644 --- a/src/AxCopilot/Themes/AgentEmberSystem.xaml +++ b/src/AxCopilot/Themes/AgentEmberSystem.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentNordDark.xaml b/src/AxCopilot/Themes/AgentNordDark.xaml index 77410c7..8a45cba 100644 --- a/src/AxCopilot/Themes/AgentNordDark.xaml +++ b/src/AxCopilot/Themes/AgentNordDark.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentNordLight.xaml b/src/AxCopilot/Themes/AgentNordLight.xaml index 4c8eab8..ef81e1e 100644 --- a/src/AxCopilot/Themes/AgentNordLight.xaml +++ b/src/AxCopilot/Themes/AgentNordLight.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentNordSystem.xaml b/src/AxCopilot/Themes/AgentNordSystem.xaml index f4fc471..95f24d2 100644 --- a/src/AxCopilot/Themes/AgentNordSystem.xaml +++ b/src/AxCopilot/Themes/AgentNordSystem.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentSlateDark.xaml b/src/AxCopilot/Themes/AgentSlateDark.xaml index 6f32950..aa06dba 100644 --- a/src/AxCopilot/Themes/AgentSlateDark.xaml +++ b/src/AxCopilot/Themes/AgentSlateDark.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentSlateLight.xaml b/src/AxCopilot/Themes/AgentSlateLight.xaml index a70976f..bc389d2 100644 --- a/src/AxCopilot/Themes/AgentSlateLight.xaml +++ b/src/AxCopilot/Themes/AgentSlateLight.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/Themes/AgentSlateSystem.xaml b/src/AxCopilot/Themes/AgentSlateSystem.xaml index 6dfb9d4..54e9234 100644 --- a/src/AxCopilot/Themes/AgentSlateSystem.xaml +++ b/src/AxCopilot/Themes/AgentSlateSystem.xaml @@ -15,4 +15,7 @@ + + + diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index bab8346..0ad97b3 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -747,7 +747,7 @@ public class SettingsViewModel : INotifyPropertyChanged set { var normalized = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim().ToLowerInvariant(); - _agentThemePreset = normalized == "claw" ? "claude" : normalized; + _agentThemePreset = normalized; OnPropertyChanged(); } } @@ -1175,7 +1175,7 @@ public class SettingsViewModel : INotifyPropertyChanged _aiEnabled = _service.Settings.AiEnabled; _operationMode = string.IsNullOrWhiteSpace(_service.Settings.OperationMode) ? "internal" : _service.Settings.OperationMode; _agentTheme = string.IsNullOrWhiteSpace(llm.AgentTheme) ? "system" : llm.AgentTheme; - _agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claude" : (llm.AgentThemePreset.Trim().ToLowerInvariant() == "claw" ? "claude" : llm.AgentThemePreset); + _agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claude" : llm.AgentThemePreset; _agentLogLevel = llm.AgentLogLevel; _agentUiExpressionLevel = (llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant() switch { @@ -1286,6 +1286,7 @@ public class SettingsViewModel : INotifyPropertyChanged EncryptedModelName = rm.EncryptedModelName, Service = rm.Service, ExecutionProfile = rm.ExecutionProfile ?? "balanced", + PromptFamily = rm.PromptFamily ?? "", Endpoint = rm.Endpoint, ApiKey = rm.ApiKey, AllowInsecureTls = rm.AllowInsecureTls, @@ -1726,6 +1727,7 @@ public class SettingsViewModel : INotifyPropertyChanged EncryptedModelName = rm.EncryptedModelName, Service = rm.Service, ExecutionProfile = rm.ExecutionProfile ?? "balanced", + PromptFamily = rm.PromptFamily ?? "", Endpoint = rm.Endpoint, ApiKey = rm.ApiKey, AllowInsecureTls = rm.AllowInsecureTls, @@ -2052,6 +2054,7 @@ public class RegisteredModelRow : INotifyPropertyChanged private string _encryptedModelName = ""; private string _service = "ollama"; private string _executionProfile = "balanced"; + private string _promptFamily = ""; private string _endpoint = ""; private string _apiKey = ""; private bool _allowInsecureTls; @@ -2083,6 +2086,13 @@ public class RegisteredModelRow : INotifyPropertyChanged set { _executionProfile = value; OnPropertyChanged(); OnPropertyChanged(nameof(ProfileLabel)); } } + /// 프롬프트 전략 패밀리. 비어있으면 모델명에서 자동 감지. + public string PromptFamily + { + get => _promptFamily; + set { _promptFamily = value; OnPropertyChanged(); } + } + /// 이 모델 전용 서버 엔드포인트. 비어있으면 기본 엔드포인트 사용. public string Endpoint { diff --git a/src/AxCopilot/Views/AboutWindow.xaml b/src/AxCopilot/Views/AboutWindow.xaml index 626a091..90ca56f 100644 --- a/src/AxCopilot/Views/AboutWindow.xaml +++ b/src/AxCopilot/Views/AboutWindow.xaml @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="AX Copilot — 정보" - Width="460" SizeToContent="Height" + Width="520" SizeToContent="Height" MaxHeight="720" WindowStyle="None" AllowsTransparency="True" UseLayoutRounding="True" @@ -37,6 +37,76 @@ + + + + + + + + + @@ -49,7 +119,7 @@ - + @@ -190,13 +260,16 @@ - + + - + - + - + @@ -291,7 +364,65 @@ - + + + + + + + + + + + + + + OpenHands + (전 OpenDevin) — MIT License + + © 2024 OpenHands Contributors + + OpenCode + — Apache License 2.0 + + © 2024 OpenCode Contributors + + + + + + + + + Markdig — BSD 2-Clause + | + DocumentFormat.OpenXml — MIT + + UglyToad.PdfPig — Apache 2.0 + | + QRCoder — MIT + + WebView2 — MIT + | + Microsoft.Data.Sqlite — MIT + + + + + + + + + @@ -304,6 +435,7 @@ + diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml b/src/AxCopilot/Views/AgentSettingsWindow.xaml index 9c6f1b5..1f59c75 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml @@ -135,8 +135,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabBasicCard_MouseLeftButtonUp"> @@ -145,8 +145,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabChatCard_MouseLeftButtonUp"> @@ -155,8 +155,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabCoworkCard_MouseLeftButtonUp"> @@ -165,8 +165,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabCodeCard_MouseLeftButtonUp"> @@ -175,8 +175,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabDevCard_MouseLeftButtonUp"> @@ -185,8 +185,8 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" - Margin="0,0,8,0" + Padding="12,8" + Margin="0,0,10,0" MouseLeftButtonUp="AgentTabToolsCard_MouseLeftButtonUp"> @@ -195,7 +195,7 @@ CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderColor}" - Padding="10,7" + Padding="12,8" MouseLeftButtonUp="AgentTabEtcCard_MouseLeftButtonUp"> @@ -208,10 +208,10 @@ - + @@ -222,14 +222,14 @@ FontSize="12"/> + FontSize="11.5" + Margin="0,3,0,0"/> - + @@ -240,8 +240,8 @@ FontSize="12"/> + FontSize="11.5" + Margin="0,3,0,0"/> - + - + - + - + @@ -418,7 +418,7 @@ Style="{StaticResource OutlineHoverBtn}" Click="BtnOperationMode_Click"/> - + @@ -445,7 +445,7 @@ Style="{StaticResource OutlineHoverBtn}" Click="BtnDefaultOutputFormat_Click"/> - + @@ -472,15 +472,15 @@ Click="BtnDefaultMood_Click"/> - + - + @@ -507,7 +507,7 @@ Style="{StaticResource OutlineHoverBtn}" Click="BtnPermissionMode_Click"/> - + @@ -533,7 +533,7 @@ Style="{StaticResource OutlineHoverBtn}" Click="BtnReasoningMode_Click"/> - + @@ -545,7 +545,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -557,7 +557,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -569,7 +569,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -591,7 +591,7 @@ - + @@ -617,7 +617,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -629,7 +629,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -641,7 +641,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -653,7 +653,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -668,13 +668,13 @@ - + - + @@ -686,7 +686,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -696,14 +696,14 @@ VerticalAlignment="Center"/> - + @@ -723,14 +723,14 @@ - + @@ -750,7 +750,7 @@ + + + + + + + + + + + + + + + + IBM watsonx + Qwen 조합 사용 시 요청/응답/인증/파싱 등 상세 진단 로그를 기록합니다. 로그 파일: %APPDATA%\AxCopilot\logs\ + + + + + + + + + - + @@ -787,7 +823,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -809,7 +845,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -821,7 +857,7 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + @@ -833,39 +869,39 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> - + - + - +