19 Commits
Author SHA1 Message Date
lacvet da11029284 claude-code 기준 provider 호환성과 compact 후속 흐름을 보강한다
- OpenAI 호환 tool_choice 400 오류에 대한 일반 fallback을 추가하고 Qwen·LLaMA·DeepSeek 계열 vLLM의 도구 호출 프로파일을 더 보수적으로 조정

- compact 이후 branch context와 최근 tool state를 query view에 재주입하고 UI 표현 수준에 맞춰 compact 카드/컨텍스트 사용 팝업/최종 보고 밀도를 세분화

- README와 DEVELOPMENT 문서 이력을 2026-04-12 23:45 KST 기준으로 갱신

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-12 22:32:40 +09:00
lacvet 58b798d3e4 compact 이후 복원 메모 계층화와 compact UI 메타 축소
- post_compact_context 메시지에 compact summary 수와 structured tool history 블록 수를 추가해 compact 뒤 첫 query turn의 복원 맥락을 더 명확히 전달함

- compact 메타 카드를 더 짧은 한 줄 요약과 파일 개수 중심으로 줄여 transcript에서 운영 메타 밀도를 낮춤

- 컨텍스트 사용 팝업의 compact 디테일을 짧은 한국어 표현으로 정리해 claw-code 스타일의 얇은 운영 표현에 가깝게 맞춤

- README.md 및 docs/DEVELOPMENT.md를 2026-04-12 23:23 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:22:18 +09:00
lacvet c7b2bba063 compact 이후 컨텍스트 재주입과 일반 작업 최종 보고를 claw-code 스타일로 경량화
- compact boundary가 적용된 query view에 post_compact_context system 메시지를 추가해 복원된 파일/이미지 참조를 짧게 다시 전달함

- 일반 Cowork/Code 작업은 final-report 품질 프롬프트를 3줄 요약 중심으로 줄이고 review/high-impact 작업만 구조적 상세 보고를 유지함

- README.md 및 docs/DEVELOPMENT.md를 2026-04-12 23:14 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:18:55 +09:00
lacvet 4db75d46cd 코워크 문서 생성 게이트와 코드 후속 검증 게이트를 claw-code 기준으로 경량화
- balanced/tool_call_strict 프로필의 document_plan 재시도와 공격적 문서 fallback 개입을 줄여 Cowork 루프를 더 얇게 정리함

- document_plan 성공 직후 강제 user follow-up 주입을 제거하고 terminal 문서 도구 성공 시 Cowork에서 바로 종료할 수 있게 조정함

- CodeDiffGate, RecentExecutionGate, ExecutionSuccessGate를 review 작업 중심으로 제한해 일반 코드 수정의 과검증을 완화함

- TaskTypePolicy, SystemPromptBuilder, cowork preset을 함께 맞춰 문서 생성/분석형 요청의 종료 조건을 일관되게 정리함

- README.md 및 docs/DEVELOPMENT.md를 2026-04-12 23:05 (KST) 기준으로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:15:26 +09:00
lacvet fb0bea41f7 AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리
- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:02:14 +09:00
lacvet b8f4df1892 compact 뒤 첨부 참조가 이어지도록 요약/경계 메시지를 보강
- ContextCondenser가 오래된 메시지 구간의 첨부 파일 이름과 이미지 개수를 수집해 microcompact boundary와 요약 메시지에 함께 기록
- 요약 메시지에 AttachedFiles를 보존해 compact 이후 query view에서도 파일 참조 continuity가 유지되도록 조정
- README와 DEVELOPMENT 문서에 2026-04-12 22:36 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-12 21:58:55 +09:00
lacvet d8cd04aa4f compact 후행 운영 메타를 더 줄여 응답 표시를 단순화
- ResponsePresentation에서 compact 직후 꼬리표를 제거해 일반 응답과 같은 밀도로 보이도록 정리
- ContextUsagePresentation 팝업 detail에서 compact 후 첫 응답 대기 문구를 제거하고 실제 컨텍스트 정보만 남김
- README와 DEVELOPMENT 문서에 2026-04-12 22:27 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-12 21:56:25 +09:00
lacvet ef58e93e38 모델별 time-based compact 기준과 compact 메타 노출을 경량화
- service:model 조합별로 time-based tool_result 정리 기준을 분리해 Claude는 보수적으로, Qwen/vLLM 계열은 빠르게 오래된 결과를 걷어내도록 조정
- compact 메타 카드를 제목과 한 줄 요약 중심으로 단순화해 transcript 운영 노이즈를 축소
- README와 DEVELOPMENT 문서에 2026-04-12 22:19 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-12 21:53:05 +09:00
lacvet bdd4444deb 오래된 tool_result를 시간 기반으로 정리하고 compact 직후 안내를 단순화
- 마지막 assistant 이후 긴 휴지기가 있으면 오래된 tool_result를 cleared marker로 교체하는 time-based microcompact 성격의 분기 추가
- compact 직후 첫 턴에서 운영성 thinking 문구를 다시 띄우지 않도록 AgentLoopCompactionPolicy 정리
- README와 DEVELOPMENT 문서에 2026-04-12 22:11 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-12 21:50:22 +09:00
lacvet 1dd10e0664 컨텍스트 tool_result 예산 규칙을 공용화해 query view와 압축 단계 정합성 강화
- AgentToolResultBudget helper를 추가해 오래된 tool_result를 최근 보호 구간과 aggregate budget 기준으로 공용 축약
- AgentQueryContextBuilder와 ContextCondenser가 같은 budget 규칙을 사용하도록 정리
- README와 DEVELOPMENT 문서에 2026-04-12 22:02 (KST) 기준 작업 이력 반영
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0, 오류 0)
2026-04-12 21:47:02 +09:00
lacvet 0a2e5b2d49 압축 경계에서도 tool pair 불변식을 유지하도록 보강
claw-code의 sessionMemoryCompact 흐름을 참고해 AX Agent가 recent window를 자를 때도 tool_use/tool_result 짝이 끊기지 않도록 보정 범위를 넓혔다.

- AgentMessageInvariantHelper를 추가해 tool_result와 assistant _tool_use_blocks 대응 관계를 공용 로직으로 정리

- AgentQueryContextBuilder는 공용 helper를 사용하도록 단순화

- ContextCondenser의 microcompact, session memory compact, snip, 요약 단계가 모두 같은 split helper로 recent window를 계산하도록 조정

- compaction split 시 recent window 안의 tool_result가 old window의 tool_use를 잃는 상황을 줄여 API pairing 안정성을 높임

- README.md, docs/DEVELOPMENT.md에 2026-04-12 21:39 (KST) 기준 이력 반영

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
2026-04-12 21:40:22 +09:00
lacvet 0f83dc802c 컨텍스트 전송 뷰와 압축 트리거를 claw-code 기준으로 정리
claw-code의 query.ts, autoCompact.ts, sessionMemoryCompact.ts 흐름을 참고해 AX Agent의 컨텍스트 관리와 압축 동작을 더 가깝게 맞췄다.

- AgentQueryContextBuilder를 추가해 저장된 전체 대화와 실제 LLM 전송용 query view를 분리

- compact boundary 이후만 전송하고 tool_result/tool_use 짝이 끊기지 않도록 start index를 보정

- 오래된 tool_result는 query view에서만 별도 budget으로 축약하도록 조정

- ContextCondenser의 자동 압축 시작점을 effective context window, summary reserve, buffer 기준으로 재계산

- 미사용 입력 높이 캐시 필드를 제거해 빌드 경고를 해소

- README.md, docs/DEVELOPMENT.md에 2026-04-12 21:34 (KST) 기준 작업 이력 반영

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
2026-04-12 21:36:50 +09:00
lacvet 9175dfe657 코워크·코드 실행 중 UI 멈춤 체감 완화
에이전트 이벤트를 UI 스레드에서 과하게 즉시 처리하던 경로를 정리해 코워크·코드 실행 중 입력과 렌더가 먼저 흐르도록 조정했다.

- ChatWindow agent dispatcher 우선순위를 Background로 낮춰 이벤트 폭주 시 UI 응답성을 확보

- OnAgentEvent 즉시 갱신을 완료/오류 중심으로 축소하고 나머지는 배치 타이머 경로로 이관

- ChatWindow.AgentEventProcessor에서 execution event 중복 append를 제거해 대화 히스토리 반영을 백그라운드 1회 처리로 단순화

- README.md, docs/DEVELOPMENT.md에 2026-04-10 09:03 (KST) 기준 작업 이력 반영

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
2026-04-10 09:08:14 +09:00
lacvet 5511c620de AX Agent의 Cowork·Code 강제 로직을 claude-code 기준으로 추가 완화
Cowork·Code 프롬프트의 text-only 완료 조건을 완화하고, 실제 산출물 생성이나 코드 수정이 필요한 경우에만 도구 재강제를 걸도록 AgentLoopService를 조정했다.

Code 검증 게이트는 diff 또는 최근 build/test 근거가 있으면 중복 재검증을 덜 하도록 줄였고, docs 정책은 creation tool 우선 + document_plan 선택형으로 정리했으며 folder_map 노출 우선순위를 한 단계 낮췄다.

README.md와 docs/DEVELOPMENT.md에 2026-04-10 09:02 (KST) 기준 변경 이력을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-10 09:00:10 +09:00
lacvet 36a9af8210 AX Agent 계획 승인 UI를 테마에 맞게 정리하고 플랜 뷰어 상호작용을 개선
계획 확인 카드와 플랜 뷰어 하단 승인 영역을 AX Agent 테마 리소스 기준으로 다시 정리했다.

승인 카드의 표면/버튼/입력 패널 간격을 통일하고 플랜 뷰어에는 검토 안내 카드, 수정 요청 입력 패널, 테마 호버를 추가했다.

README.md와 docs/DEVELOPMENT.md에 2026-04-10 08:47 (KST) 기준 변경 이력을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-10 08:48:36 +09:00
lacvet d24c1f9edc AX Agent 계획 선행 잔재 제거 및 최종 후속 권유 조건부 완화
이번 커밋은 claude-code와 비교했을 때 AX에 남아 있던 계획 선행 흐름과 과한 최종 후속 권유를 줄이는 데 집중했다.

핵심 변경 사항:

- AgentLoopService에서 사용되지 않는 plan prelude/승인용 계획 생성 블록 제거

- FinalReportGate를 review 작업 및 고영향 변경에만 적용하도록 축소

- FinalReportQuality 프롬프트에서 remaining risk/next action은 실제 미해결 사항이 있을 때만 쓰도록 완화

- Cowork 프롬프트에서 document_plan을 기본 선행 단계처럼 밀지 않고 필요 시만 사용하도록 조정

- Code REPORT 단계도 변경 내용과 검증 요약 중심으로 정리하고 미해결 리스크만 선택적으로 언급하도록 수정

문서 반영:

- README.md, docs/DEVELOPMENT.md에 2026-04-10 00:08 (KST) 기준 이력 추가

검증 결과:

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

- 경고 0개, 오류 0개
2026-04-10 08:39:52 +09:00
lacvet 6bd8d5bb2c AX Agent IBM 응답 정규화 및 도구 노출 순서 추가 보강
이번 커밋은 후속 과제로 남아 있던 IBM Qwen 응답 포맷 차이와 보조 도구 과노출 문제를 추가 정리했다.

핵심 변경 사항:

- LlmService.ToolUse에서 content/reasoning_content/generated_text/output_text가 배열 또는 블록 형태로 와도 텍스트를 추출하도록 메시지 파서 보강

- content 배열 안의 tool_use/tool_call 블록도 직접 ContentBlock으로 복구해 IBM/Qwen 응답 변형에 더 유연하게 대응

- ToolRegistry 활성 도구 목록 노출 순서를 기본 파일/검색/생성/실행 도구 우선으로 재정렬하고 tool_search, MCP, spawn_agent, task 계열은 뒤로 배치

문서 반영:

- README.md, docs/DEVELOPMENT.md에 2026-04-09 23:02 (KST) 기준 이력 추가

검증 결과:

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

- 경고 0개, 오류 0개
2026-04-09 23:08:33 +09:00
lacvet 1fab215344 AX Agent 코워크·코드 후속 호환 과제 정리 및 IBM Qwen 도구호출 경로 보강
이번 커밋은 claude-code 기준 후속 과제를 이어서 반영해 Cowork/Code의 도구 선택 강도와 IBM 배포형 vLLM(Qwen) 호환 경로를 정리했다.

핵심 변경 사항:

- IBM 전용 tool body에서 과거 assistant tool_calls 및 role=tool 이력을 OpenAI 형식으로 재전송하지 않고 평탄한 transcript로 직렬화하도록 변경

- Cowork 프롬프트에서 document_review 및 format_convert를 기본 단계처럼 강제하지 않고 file_read/document_read 중심의 가벼운 검증 흐름으로 완화

- unknown/disallowed tool recovery에서 tool_search를 항상 강제하지 않고 alias 후보나 활성 도구 예시로 바로 선택 가능하면 직접 사용하도록 조정

- Code 탐색에서 정의/참조/구현/호출관계 의도는 lsp_code_intel을 더 우선하도록 보강하고 LSP 결과 요약 품질 개선

- 문서 검증 근거는 file_read/document_read면 충분하도록 단순화

문서 반영:

- README.md, docs/DEVELOPMENT.md에 2026-04-09 22:48 (KST) 기준 작업 이력 추가

검증 결과:

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

- 경고 0개, 오류 0개
2026-04-09 22:50:27 +09:00
lacvet 3c6d2f1ce4 핵심 엔진을 claude-code 기준으로 정렬하고 스트리밍 재시도 경계를 정리한다
- StreamingToolExecutionCoordinator에서 조기 실행 대상을 file_read/document_read 중심으로 축소하고 folder_map 등 구조 탐색 도구를 prefetch 대상에서 제거함

- 스트리밍 재시도 전에 RetryReset 이벤트를 추가해 중간 응답 미리보기 누적을 끊고 AgentLoopService가 재시도 경계를 명확히 표시하도록 조정함

- AxAgentExecutionEngine의 Cowork/Code 빈 응답 합성을 보수적으로 바꿔 실행 근거가 있을 때만 완료 요약을 만들고 근거가 없으면 로그 확인 안내를 반환하도록 정리함

- Code 루프의 post-tool verification과 completion gate도 직전 수정에서 함께 정리해 일반 수정의 과검증을 줄였음

- README.md, docs/DEVELOPMENT.md에 2026-04-09 21:03 (KST) 기준 변경 이력과 검증 결과를 반영함

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-09 21:07:49 +09:00
153 changed files with 24337 additions and 10336 deletions
+138 -17
View File
@@ -7,10 +7,87 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서 개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md` `docs/claw-code-parity-plan.md`
- 업데이트: 2026-04-09 13:05 (KST) - 업데이트: 2026-04-12 23:45 (KST)
- AX Agent transcript 표시를 `claude-code`식 row 타입으로 재정리했습니다. `thinking / waiting / compact / tool activity / permission / tool result / status`를 별도 row 의미로 정규화하고, process feed는 같은 활동 그룹이면 이어붙여 append 수를 줄이도록 바꿨습니다. - `claude-code`으로 남아 있던 provider/compact/UI 후속 차이를 더 줄였습니다. OpenAI 호환 도구 호출은 이제 모델 계열별 호환 프로파일을 적용해 Qwen·LLaMA·DeepSeek 같은 취약한 vLLM 계열에서 최근 structured tool history 범위를 더 좁히고, 병렬 tool call과 reasoning_effort도 더 보수적으로 전송합니다.
- 권한 요청과 도구 결과 카탈로그를 다시 정리해 Cowork/Code 진행 중 무엇을 하는지, 어떤 결과 상태인지가 기본 transcript에서 더 읽히도록 맞췄습니다. 동시에 render 성능 로그에 row kind 분포와 process feed append/merge 수치를 남겨 이후 실검증 때 구조 개선 효과를 바로 판단할 수 있게 했습니다. - tool forcing fallback도 보강했습니다. IBM 배포형뿐 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면, plain text 지시를 덧붙인 대체 요청으로 한 번 더 재시도해 `채팅은 되지만 Cowork/Code만 막히는` 조합을 줄였습니다.
- Cowork/Chat 하단 프리셋 안내 카드는 실제 메시지뿐 아니라 execution event가 생긴 뒤에도 자동으로 숨겨 결과/진행 화면을 가리지 않도록 조정했고, 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. - compact 이후 query view에는 복원된 branch context와 최근 tool state까지 함께 다시 주입합니다. UI는 표현 수준(`rich/balanced/simple`)에 맞춰 compact 카드와 컨텍스트 사용 팝업 밀도를 다르게 보여주고, 최종 보고 프롬프트도 같은 수준에 맞춰 더 짧거나 더 구조적으로 정리되도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-10 14:30 (KST)
- **UI 프리징 근본 수정**: 스트리밍 중 렌더링 쓰로틀(1.5초 최소 간격), 이중 RenderMessages 제거, 타이머 Stop→Start 무한 루프 차단, 불필요 타이머 4개 일시 정지, 타이머 간격 2-10배 증가(350ms→5s, 500ms→2s 등). 에이전트 이벤트 디스패처 우선순위를 Normal→Background로 하향.
- **모델 프로파일 도구 사용 버그 수정**: Ollama 모델에 `tool_choice: "required"` 미전달 버그 수정 — `BuildOpenAiToolBody`에서 Ollama 조기 리턴 전에 `tool_choice` 주입. `FindRegisteredModel`의 대소문자 민감 비교를 `OrdinalIgnoreCase`로 변경하여 프로파일 매칭 실패 방지.
- **트랜스크립트 UI 개선**: "처리 중..." / "작업을 준비하는 중입니다..." 트랜스크립트 힌트를 제거하고 PulseDotBar로만 상태 표시 (Claude Desktop 스타일). 완료 이벤트의 이모지 깨짐("?챗셝?콺듦") 수정 — 서로게이트 쌍(비-BMP 유니코드) 자동 제거.
- **PPT 기능 확장**: 이미지 삽입(BlipFill+aspect ratio), 아이콘 라이브러리(170+ 유니코드/120+ OpenXML), 슬라이드 복제(전체/개별), 네이티브 차트(bar/line/pie ChartPart), theme_file 마스터 복제.
- **아이콘 공유**: 새 `IconLibrary.cs`를 DOCX/XLSX/HTML 스킬에서 공유. `{icon:name}` 인라인 구문 + 블록 아이콘 지원.
- **마스코트 개선**: 3배 크기(300px), NearestNeighbor 픽셀아트 렌더, 투명 배경, 좌우 이동 애니메이션 10종.
- 검증: `dotnet build` 경고 0 / 오류 0
- 업데이트: 2026-04-10 09:02 (KST)
- `claude-code` 기준으로 Cowork/Code의 남은 차이를 더 줄였습니다. Cowork/Code 프롬프트의 텍스트-only 완료 조건을 완화해, 작업이 이미 끝났거나 충분한 근거가 있을 때는 불필요한 도구 호출을 더 강제하지 않도록 정리했습니다.
- 에이전트 루프도 같이 손봤습니다. 텍스트-only 재시도는 이제 실제 산출물 생성이나 코드 수정처럼 구체적인 실행이 필요한 경우에만 다시 도구를 강제하고, 문서 생성 fallback도 같은 범위로 좁혔습니다.
- Code 검증 게이트는 `diff` 또는 최근 build/test 근거가 이미 있으면 추가 재검증을 덜 하도록 줄였고, `folder_map`은 기본 노출 우선순위에서 한 단계 내려 보조 탐색 도구로 배치했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-10 08:47 (KST)
- 계획 확인 UI를 AX Agent 테마에 맞춰 다시 정리했습니다. 채팅 안 승인 카드는 `LauncherBackground`/`ItemBackground`/`BorderColor`/`AccentColor`를 기준으로 표면, 버튼 모서리, 입력 패널 간격을 통일해 기본 컨트롤 느낌을 줄였습니다.
- 플랜 뷰어 창의 하단 승인 영역도 같은 시각 언어로 손봤습니다. 승인 전 안내 카드를 추가하고, 수정 입력 패널은 테마 배경과 테두리를 쓰도록 바꿨으며, 액션 버튼 호버도 `ItemHoverBackground` 중심으로 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-10 00:08 (KST)
- `claude-code`와 비교했을 때 AX에 남아 있던 계획 선행과 후속 권유 톤을 더 줄였습니다. 기본 Cowork/Code 루프 안에 남아 있던 plan prelude/승인용 죽은 코드를 제거해, 별도 계획 생성 단계를 끼우지 않고 바로 모델+도구 실행으로 들어갑니다.
- Code 최종 보고 재강제도 review 작업과 고영향 변경으로 좁혔습니다. 일반 수정은 변경 내용과 검증 근거가 충분하면 후속 계획이나 남은 리스크를 덧붙이도록 다시 유도하지 않고 마무리할 수 있습니다.
- Cowork/Code 시스템 프롬프트도 같은 방향으로 정리했습니다. Cowork는 새 문서 생성 시 `document_plan`을 기본 선행 단계처럼 밀지 않고 필요할 때만 쓰게 했고, Code는 마지막 보고에서 미해결 사항이 있을 때만 리스크를 언급하게 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-10 00:15 (KST)
- Virtual DOM Diff 렌더 구현 완료: React reconciliation 방식의 키 기반 diff를 WPF에 적용. 렌더 체인이 StreamingAppend → Incremental → **DiffRender** → FullRender 4단계로 확장되어, prefix-match 실패 시에도 전체 재빌드 없이 변경분만 반영합니다.
- 검증: `dotnet build` 경고 0 / 오류 0
- 업데이트: 2026-04-09 23:02 (KST)
- IBM/Qwen 후속 호환을 한 단계 더 보강했습니다. 이제 IBM 배포형 응답에서 `generated_text`, `output_text`, `message.content`, `reasoning_content`가 문자열뿐 아니라 배열/블록 형태로 와도 텍스트를 추출하고, `content` 배열 안의 `tool_use/tool_call` 블록도 직접 읽어 도구 호출로 복구합니다.
- 활성 도구 노출 순서도 다시 정리했습니다. `file_read/file_edit/glob/grep/lsp_code_intel/build_run/document_plan` 같은 기본 도구를 먼저 보여주고, `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)
- `claude-code` 기준 후속 과제를 이어서 반영했습니다. IBM 배포형 vLLM/Qwen 경로에서는 과거 `tool_calls`/`role=tool` 이력을 그대로 재전송하지 않고, `<tool_call>...</tool_call>` 중심의 평탄한 transcript로 직렬화해 엄격한 tool history 검사에 덜 걸리도록 전용 분기를 넣었습니다.
- Cowork 문서 생성 프롬프트는 `document_review``format_convert`를 상시 기본 단계처럼 밀지 않도록 다시 낮췄습니다. 이제 기본 검증은 `file_read/document_read` 중심이고, `document_review`는 큰 문서 품질 점검이나 명시적 요청일 때만 권장합니다.
- Code/Cowork 공통 런타임 복구 문구도 정리했습니다. `tool_search`는 후보만으로 충분할 때는 바로 실제 도구를 고르도록 하고, 정말 모호할 때만 사용하게 바꿨습니다. `spawn_agent`도 기본 전면 노출 대신 병렬 조사가 실제로 도움이 될 때만 고려하도록 완화했습니다.
- Code의 의미 기반 탐색도 한 단계 더 보강했습니다. 정의/참조/구현/호출관계 질문은 `lsp_code_intel`을 더 앞에 두고, LSP 결과는 파일 수·대표 위치·첫 결과를 함께 요약해 다음 액션 판단에 바로 쓸 수 있게 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 22:38 (KST)
- Code 탭의 구조적 코드 읽기를 `claude-code`에 더 가깝게 확장했습니다. AX에 이미 있던 `lsp_code_intel` 도구를 `goto_definition`, `find_references`, `hover`, `goto_implementation`, `workspace_symbols`, `prepare_call_hierarchy`, `incoming_calls`, `outgoing_calls`까지 넓혀, 단순 `grep/file_read`만이 아니라 정의/참조/호출관계를 입체적으로 볼 수 있게 했습니다.
- Code 프롬프트와 탐색 우선순위도 이에 맞춰 조정했습니다. 이제 특정 심볼의 정의, 참조, 구현, 호출자/피호출자를 볼 때는 `lsp_code_intel`을 우선 후보로 제시하고, 텍스트 검색이 더 적합한 경우에만 `grep/glob`를 쓰도록 유도합니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 21:48 (KST)
- Cowork/Code 생성 로직을 `claude-code` 기준으로 다시 정리했습니다. Cowork는 순수 문서 생성 요청이면 `glob/grep/document_read/folder_map`로 먼저 새지 않고 `document_plan -> 생성 도구`로 바로 가도록 프롬프트와 탐색 우선순위를 맞췄습니다.
- Code는 시작 흐름을 더 얇게 바꿨습니다. `targeted file_read` 또는 `grep/glob`로 가장 작은 범위만 확인한 뒤 바로 `file_edit/file_write`로 이어지고, `build_run/test_loop``git_tool(diff)`는 필요할 때 쓰는 방향으로 정리했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 21:58 (KST)
- Cowork/Code 기본 실행 정책을 더 `claude-code`답게 완화했습니다. `balanced`, `reasoning_first` 프로필에서 초기 도구 호출 강제와 post-tool 검증, 문서 검증 게이트를 기본 비활성화해 불필요한 재호출을 줄였습니다.
- Cowork는 문서 생성 완료 뒤 추가 검증 턴을 기본으로 붙이지 않고, 결과 파일이 만들어지면 바로 완료 경로로 빠지도록 정리했습니다. 문서 품질 확인은 가벼운 self-check 성격으로 프롬프트도 완화했습니다.
- Code는 일반 수정의 완료 근거를 `diff` 또는 최근 build/test 같은 가벼운 증거로도 인정하도록 바꿔, 고영향 수정이 아닐 때 `CodeQualityGate`가 과하게 재발동하지 않게 했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 21:03 (KST)
- 핵심 엔진 계층도 `claude-code` 기준으로 더 정리했습니다. 스트리밍 도구 코디네이터는 재시도 전에 중간 스트림 상태를 끊는 `RetryReset` 이벤트를 보내도록 바꿔, 부분 응답이 누적된 채 다시 이어지는 현상을 줄였습니다.
- 조기 실행 대상 읽기 도구는 `file_read``document_read` 중심의 가벼운 도구로 다시 좁혔고, `folder_map` 같은 구조 탐색 도구는 더 이상 엔진 레벨 prefetch 대상에 넣지 않도록 조정했습니다.
- Cowork/Code에서 최종 텍스트 응답이 비어 있을 때도 무조건 완료 문구를 합성하지 않고, 실행 이벤트 근거가 있을 때만 요약을 만들도록 보수적으로 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 20:46 (KST)
- AX Code 핵심 루프의 과한 검증 개입을 줄여 `claude-code`에 더 가깝게 정리했습니다. 일반 코드 수정 뒤에는 즉시 별도 검증 루프를 다시 돌리지 않고, 고영향 수정일 때만 post-tool verification을 유지하도록 조정했습니다.
- Code 종료 게이트도 순서를 다듬어, diff/build/test 근거가 이미 있는 일반 수정은 `CodeQualityGate`를 과하게 반복하지 않게 했고 `FinalReportGate`는 실제 검증 공백이 해소된 뒤에만 작동하도록 바꿨습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- 업데이트: 2026-04-09 20:29 (KST)
- Cowork/Code 입력창에 파일명 후보 칩을 추가했습니다. 작업 폴더가 잡힌 상태에서 파일명 일부, 경로 조각, 확장자 힌트를 입력하면 관련 파일 후보를 바로 보여주고 클릭이나 `Tab`으로 삽입할 수 있어 `folder_map` 없이도 특정 파일 지정을 더 빠르게 시작할 수 있습니다.
- `folder_map``0 files, 0 dirs`를 반환했는데 실제로는 보이는 파일이 있는 경우를 자동 복구하도록 에이전트 루프를 보강했습니다. 이제 같은 경로에 대해 `folder_map`을 반복하지 않고, 내부 fallback 스캔으로 후보 파일을 찾아 `glob -> file_read/document_read` 쪽으로 바로 재유도합니다.
- 워크플로우 로그에는 마지막 도구 결과 이후 다음 LLM 호출까지의 대기 구간을 `llm_wait_after_tool_result` 전이로 남기고, `folder_map_empty_recovery` 복구 전이도 함께 기록합니다. 멈춤처럼 보이는 상황을 다음부터는 로그만 보고 더 빨리 구분할 수 있습니다.
- `DocumentPlannerTool`과 스킬 예시 가이드의 탐색 문구도 다시 정리해, 내부 계획/스킬 층까지 `glob/grep -> targeted read -> folder_map(필요 시만)` 기준으로 맞췄습니다.
- Code 루프에는 같은 경로 반복 접근 가드를 추가했습니다. `file_read/document_read/grep/glob`가 동일 파일 또는 동일 프로젝트 경로를 연속으로 재확인하면 4회째부터는 그대로 읽지 않고, 호출부 탐색·`git_tool(diff)`·`build_run/test_loop` 쪽으로 전환하라는 복구 메시지를 먼저 주입합니다.
- 업데이트: 2026-04-09 01:33 (KST) - 업데이트: 2026-04-09 01:33 (KST)
- AX Agent transcript 호스트를 `ObservableCollection<UIElement>` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다. - AX Agent transcript 호스트를 `ObservableCollection<UIElement>` 직접 주입 구조에서 `TranscriptVisualItem + TranscriptVisualHost` 기반의 지연 materialization 구조로 올렸습니다. `MessageList`는 virtualization 설정을 유지한 채 필요한 시점에만 실제 버블 UI를 생성할 수 있는 기반을 갖습니다.
@@ -1546,16 +1623,60 @@ MIT License
- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다.
- [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다.
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다.
- 업데이트: 2026-04-09 10:36 (KST) - 업데이트: 2026-04-10 09:03 (KST)
- `claude-code`의 선택적 탐색 흐름을 다시 대조해, Cowork/Code가 질문 범위와 무관한 워크스페이스 전체를 훑는 경향을 줄이기 시작했습니다. - Cowork/Code 실행 중 앱 화면이 끝날 때까지 멈춰 보이던 현상을 줄이기 위해, 에이전트 이벤트의 UI 전달 우선순위와 처리 경로를 다시 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 Cowork/Code 시스템 프롬프트를 조정해 `folder_map`을 항상 첫 단계로 요구하지 않고, 좁은 질문에서는 `glob/grep + targeted read`를 우선하도록 바꿨습니다. - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 agent dispatcher는 `DispatcherPriority.Normal` 대신 `Background`를 사용해 입력/렌더가 먼저 흐르도록 조정했습니다.
- [FolderMapTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/FolderMapTool.cs)는 기본 depth를 2로, `include_files` 기본값을 `false`로 조정해 첫 탐색 폭을 더 보수적으로 만들었습니다. - 같은 파일의 `OnAgentEvent()`는 이벤트마다 라이브 카드와 상태 서브아이템을 즉시 갱신하지 않고, 완료/오류 같은 종료 신호만 즉시 처리한 뒤 나머지는 기존 배치 타이머로 넘기도록 단순화했습니다.
- [MultiReadTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MultiReadTool.cs)는 한 번에 읽을 수 있는 최대 파일 수를 20개에서 8개로 낮춰 초기 과탐색 토큰 낭비를 줄이도록 습니다. - [ChatWindow.AgentEventProcessor.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs)는 execution event를 UI 스레드와 백그라운드에서 두 번 append하던 구조를 제거하고, 백그라운드 단일 리더에서 한 번만 대화 히스토리를 반영하도록 바꿨습니다.
- [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)와 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)에 탐색 범위 분류기와 broad-scan corrective hint를 추가해, 좁은 질문에서 반복적인 `folder_map`/대량 `multi_read`가 나오면 관련 파일만 다시 고르도록 교정합니다. - 업데이트: 2026-04-12 21:34 (KST)
- [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)는 `%APPDATA%\\AxCopilot\\perf` `exploration_breadth` 로그를 남겨 `folder_map` 호출 수, 총 읽은 파일 수, broad scan 여부를 실사용 기준으로 확인할 수 있게 했습니다. - `claw-code``messagesForQuery`, `autoCompact`, `sessionMemoryCompact` 흐름을 기준으로 AX Agent의 컨텍스트 전송 뷰와 압축 트리거를 한 단계 더 정리했습니다.
- 업데이트: 2026-04-09 11:12 (KST) - [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)를 추가해, 저장된 전체 대화와 실제 LLM에 전송할 query view를 분리했습니다. 이 뷰는 마지막 compact boundary부터만 다시 보내고, 오래된 `tool_result`는 전송 직전에만 budget 기준으로 더 줄입니다.
- `claude-code``Messages.tsx`, `MessageRow.tsx`, `GroupedToolUseContent.tsx`, `UserToolResultMessage`, `PermissionRequest` 구조를 다시 대조해 AX transcript 표시 계약을 row 중심으로 정리했습니다. - 같은 helper에서 `tool_result`가 남아 있는 kept range를 검사해, 대응되는 assistant `_tool_use_blocks`가 잘리지 않도록 window start를 뒤로 보정합니다.
- [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)에 `TranscriptRowKind``AgentTranscriptRowPresentation`을 도입해 thinking, waiting, compact, tool activity, permission, tool result, status를 한 번에 정규화하도록 바꿨습니다. - [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 각 반복에서 `messagesForQuery`에 해당하는 전송 뷰를 만든 뒤 `SendWithToolsWithRecoveryAsync()`와 텍스트 fallback 호출에 사용하도록 바꿨습니다.
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)는 이 row 메타를 사용해 고빈도 process feed를 같은 group key 기준으로 교체 렌더하도록 바꿨고, 연속 읽기/검색/단계 이벤트를 더 적은 row 수로 보여주게 했습니다. - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `triggerPercent`만 보던 기준에서 `effective context window - output reserve - buffer` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다.
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)와 [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)를 정리해 실패/거부/취소/승인 필요와 권한 행위를 유형별 카드 메타로 다시 맞췄습니다. - 업데이트: 2026-04-12 21:39 (KST)
- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs), [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 grouped process feed append/merge 수를 추적하도록 바꿔, `claude-code`식 activity grouping이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다. - `claw-code``sessionMemoryCompact`처럼 압축 경계 자체에서도 `tool_use / tool_result` 짝이 끊기지 않도록 보정 범위를 넓혔습니다.
- [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)를 추가해 `tool_result`가 남는 kept range에서 필요한 assistant `_tool_use_blocks`를 공용 로직으로 다시 포함시키게 했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `MicrocompactOlderMessages`, `SessionMemoryCompactOlderMessages`, `CollapseAndSnipOlderMessages`, `SummarizeOldMessagesAsync`가 모두 같은 split helper를 사용하도록 바꿨습니다.
- 이제 recent window가 `tool_result`로 시작하는 경우에도, 그 결과를 참조하는 이전 `tool_use`가 요약/압축 쪽으로 잘려 나가 API pairing 오류를 만드는 상황을 줄일 수 있습니다.
- 업데이트: 2026-04-12 22:02 (KST)
- `claw-code``applyToolResultBudget` 방향을 따라, AX도 query view와 압축 1단계가 같은 `tool_result` 예산 규칙을 공유하도록 정리했습니다.
- [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)를 추가해 오래된 `tool_result`를 최근 보호 구간과 aggregate budget 기준으로 공용 축약하도록 만들었습니다.
- [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)는 query view에서 이 공용 helper를 사용하게 바뀌어, 전송 직전 budget과 축약 결과가 압축 본체와 같은 규칙을 따릅니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 1단계에서 `tool_result`를 별도 공용 budget으로 먼저 줄이고, 그 외 긴 assistant/user 메시지만 추가 절단하도록 역할을 분리했습니다.
- 업데이트: 2026-04-12 22:11 (KST)
- `claw-code`의 time-based microcompact 방향을 따라, 오래 쉬었다가 다시 호출할 때 오래된 `tool_result`를 먼저 정리하는 분기를 AX에 추가했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 마지막 assistant 이후 20분 이상 경과한 경우, 가장 최근 `tool_result` 1개만 남기고 나머지는 작은 cleared marker로 바꾸도록 처리합니다.
- [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 cleared `tool_result` JSON을 만드는 helper를 추가해, 기존 `tool_use_id`/`tool_name`은 유지하면서 content만 가볍게 비우도록 보강했습니다.
- [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)는 compact 직후 첫 턴에서 추가 운영성 thinking 문구를 다시 띄우지 않고 내부 상태만 갱신하도록 바꿔, compact 후 흐름이 더 조용하게 이어지도록 정리했습니다.
- 업데이트: 2026-04-12 22:19 (KST)
- `time-based` tool result 정리 기준을 모델/서비스별로 세분화해, Claude는 더 보수적으로, Qwen/vLLM 계열은 더 빠르게 오래된 결과를 걷어내도록 조정했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `service:model` 조합에 따라 `gapThresholdMinutes``keepRecent`를 다르게 계산하도록 바뀌었습니다.
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 긴 상세 줄 목록 대신 한 줄 요약 카드로 단순화해, transcript에서 운영성 카드 밀도를 더 낮췄습니다.
- 업데이트: 2026-04-12 22:27 (KST)
- compact 이후 응답/컨텍스트 사용 표시에서 남아 있던 후행 운영 메타를 더 줄였습니다.
- [ChatWindow.ResponsePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs)는 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거해, 일반 응답과 같은 밀도로 보이도록 정리했습니다.
- [ChatWindow.ContextUsagePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs)는 token usage 팝업 detail에서 `compact 후 첫 응답 대기 중` 문구를 빼고 실제 컨텍스트/압축 정보만 보여주도록 단순화했습니다.
- 업데이트: 2026-04-12 22:36 (KST)
- `claw-code`의 post-compact attachments 흐름을 참고해, AX도 요약/경계 메시지에 오래된 첨부 참조를 다시 실어 compact 뒤 컨텍스트 연속성을 보강했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 오래된 메시지에서 첨부 파일 이름과 이미지 개수를 수집해 `microcompact_boundary`와 요약 메시지에 함께 기록합니다.
- 요약 메시지에는 `AttachedFiles`도 같이 보존해, compact 이후에도 관련 파일 참조가 query view로 이어질 수 있게 맞췄습니다.
- 업데이트: 2026-04-12 22:44 (KST)
- OpenAI/vLLM 호환 경로의 tool history 직렬화를 더 가볍게 만들어, 최근 구간만 구조화된 `tool_calls/tool` 형식을 유지하고 오래된 구간은 평탄한 transcript로 낮췄습니다.
- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs)는 최근 비-system 메시지 8개만 구조화된 tool history로 유지하고, 그 이전의 `_tool_use_blocks``tool_result`는 plain assistant/user transcript로 변환해 재전송합니다.
- 이로써 strict provider에서 오래된 `tool_calls/tool` 이력이 계속 누적되던 부담과 pairing 위험을 함께 줄였습니다.
- 업데이트: 2026-04-12 23:05 (KST)
- `claw-code`와 비교했을 때 여전히 무겁던 Cowork 문서 생성 게이트와 Code 후속 검증 게이트를 한 단계 더 줄였습니다.
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) 는 `document_plan` 뒤의 강제 재시도/자동 HTML fallback을 공격적인 실행 프로파일에서만 동작하도록 제한했습니다.
- [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs) 는 `document_plan` 성공 직후 추가 user 압박 메시지를 넣지 않고, terminal 문서 도구 성공 시 Cowork에서 바로 종료할 수 있게 정리했습니다.
- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs) 는 `CodeDiffGate`, `RecentExecutionGate`, `ExecutionSuccessGate`를 review 작업 중심으로 제한해 일반 코드 수정의 과검증을 줄였습니다.
- [TaskTypePolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/TaskTypePolicy.cs), [ChatWindow.SystemPromptBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs), [cowork_문서작성.json](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Assets/Presets/cowork_%EB%AC%B8%EC%84%9C%EC%9E%91%EC%84%B1.json), [cowork_보고서.json](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Assets/Presets/cowork_%EB%B3%B4%EA%B3%A0%EC%84%9C.json) 도 같은 기준으로 문서 생성/텍스트 종료 조건을 더 얇게 맞췄습니다.
- 업데이트: 2026-04-12 23:14 (KST)
- compact 이후 query view에 복원된 파일/이미지 참조를 짧게 다시 주입해, `claw-code`의 post-compact attachment continuity에 더 가깝게 맞췄습니다.
- [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)는 compact boundary가 적용된 query view에 `post_compact_context` system 메시지를 추가해, 복원된 파일 참조와 이미지 참조 개수를 함께 전달합니다.
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 final-report 품질 프롬프트는 일반 작업에서는 더 짧고 명확한 3줄 요약 중심으로 축소하고, review/high-impact 작업에만 구조적 상세 보고를 유지하도록 조정했습니다.
- 업데이트: 2026-04-12 23:23 (KST)
- compact 뒤 query view의 복원 메모를 한 단계 더 구조화해, 요약 경계 수와 구조화된 tool history 블록 수도 함께 실어주도록 보강했습니다.
- [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)는 `post_compact_context` 메시지에 `restored compact summaries`, `restored tool history blocks`, `restored file refs`, `restored image refs`를 분리해 담습니다.
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 더 짧은 설명과 파일 개수 정도만 보이도록 줄였습니다.
- [ChatWindow.ContextUsagePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs)는 컨텍스트 사용 팝업의 compact 디테일 문구를 짧고 읽기 쉬운 표현으로 다시 정리했습니다.
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# AX Copilot - Quick Dev Build (no encryption, no obfuscation, no installer)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
APP="$ROOT/src/AxCopilot/AxCopilot.csproj"
OUT="$ROOT/dist"
APP_OUT="$OUT/AxCopilot"
RUNTIME="win-x64"
echo ""
echo "========================================"
echo " AX Copilot - Quick Build (dev)"
echo "========================================"
echo ""
# Stop running process (best-effort)
if command -v taskkill &>/dev/null; then
taskkill //IM AxCopilot.exe //F 2>/dev/null || true
fi
rm -rf "$OUT"
mkdir -p "$APP_OUT"
echo "[1/1] Publishing main app (self-contained $RUNTIME)..."
dotnet publish "$APP" \
-c Release \
-r "$RUNTIME" \
--self-contained true \
-o "$APP_OUT" \
--nologo \
-v minimal \
-p:DebugType=None \
-p:DebugSymbols=false \
-p:CopyOutputSymbolsToPublishDirectory=false \
-p:EnableSourceLink=false \
-p:PublishSingleFile=true \
-p:EnableCompressionInSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:PublishReadyToRun=true
# Cleanup debug/metadata artifacts
rm -f "$APP_OUT"/*.pdb "$APP_OUT"/*.xml "$APP_OUT"/*.deps.json "$APP_OUT"/*.runtimeconfig.json 2>/dev/null || true
echo ""
echo "========================================"
echo " Quick Build Complete!"
echo "========================================"
echo ""
echo " Output: $APP_OUT"
echo ""
echo " Skipped: encryption, obfuscation, AxKeyEncryptor, installer"
echo ""
+271
View File
@@ -1,6 +1,89 @@
# AX Copilot - 媛쒕컻 臾몄꽌 # AX Copilot - 媛쒕컻 臾몄꽌
## 계획 승인 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``<tool_call>...</tool_call>` 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 표시 구조 정리 ## claude-code식 transcript 표시 구조 정리
- 업데이트: 2026-04-09 13:05 (KST) - 업데이트: 2026-04-09 13:05 (KST)
@@ -472,3 +555,191 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs` - `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs`
- performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎. - performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎.
## Cowork/Code 실행 중 UI 멈춤 완화 (2026-04-10 09:03 KST)
- `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 스레드 부담이 완화됩니다.
## 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`의 미사용 필드를 제거해 빌드 경고를 없앴습니다.
## 압축 경계에서 tool pair 불변식 보정 확대 (2026-04-12 21:39 KST)
- 전송 직전 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 보정이 같은 규칙을 쓰므로, 저장 상태와 전송 상태 사이의 불일치가 줄어듭니다.
## query view / 압축 1단계 tool_result budget 통일 (2026-04-12 22:02 KST)
- `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`의 토큰 사용량이 더 예측 가능해집니다.
## time-based tool_result 정리 / post-compact 안내 축소 (2026-04-12 22:11 KST)
- `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에 운영 문구가 한 번 더 끼어드는 노이즈가 줄어듭니다.
## 모델별 time-based 기준 / compact 메타 카드 경량화 (2026-04-12 22:19 KST)
- `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)
- 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` 스타일의 얇은 표현에 가까워집니다.
## compact 후 첨부 참조 재주입 보강 (2026-04-12 22:36 KST)
- `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가 이전보다 더 자연스럽게 유지됩니다.
## OpenAI/vLLM tool history 직렬화 경량화 (2026-04-12 22:44 KST)
- `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 검사에 걸릴 가능성을 낮추면서도 최근 실행 흐름은 그대로 유지할 수 있습니다.
## 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`와 더 비슷한 얇은 루프를 유지합니다.
## 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`처럼 더 짧고 메타 밀도가 낮아집니다.
## post-compact 복원 메모 계층화 / compact UI 메타 축소 (2026-04-12 23:23 KST)
- `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`처럼 더 얇고 조용한 운영 메타 표현을 유지합니다.
## 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
+32
View File
@@ -1,6 +1,7 @@
using System.Windows; using System.Windows;
using System.Windows.Forms; using System.Windows.Forms;
using Microsoft.Win32; using Microsoft.Win32;
using Microsoft.Extensions.DependencyInjection;
using AxCopilot.Core; using AxCopilot.Core;
using AxCopilot.Handlers; using AxCopilot.Handlers;
using AxCopilot.Services; using AxCopilot.Services;
@@ -119,6 +120,9 @@ public partial class App : System.Windows.Application
_appState.AttachChatSession(_chatSessionState); _appState.AttachChatSession(_chatSessionState);
_appState.LoadFromSettings(settings); _appState.LoadFromSettings(settings);
// ─── DI 컨테이너 초기화 (기존 인스턴스 등록, 점진적 전환) ──────────────
ConfigureDependencyInjection(settings, _memoryService, _chatSessionState, _appState);
_indexService = new IndexService(settings); _indexService = new IndexService(settings);
var indexService = _indexService; var indexService = _indexService;
// 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지 // 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지
@@ -1008,4 +1012,32 @@ public partial class App : System.Windows.Application
_fileDialogWatcher.Stop(); _fileDialogWatcher.Stop();
} }
// ─── DI 컨테이너 구성 ─────────────────────────────────────────────────────
// 기존 수동 생성 인스턴스를 DI 컨테이너에 등록합니다.
// 향후 ViewModel/Window도 DI에서 resolve할 수 있도록 점진 확장합니다.
private static void ConfigureDependencyInjection(
SettingsService settings,
AgentMemoryService memoryService,
ChatSessionStateService chatSessionState,
AppStateService appState)
{
ServiceLocator.Configure(services =>
{
// ── Singleton: 기존 인스턴스 등록 ────────────────────────────────
services.AddSingleton<ISettingsService>(settings);
services.AddSingleton(settings); // 하위 호환: 구체 타입 직접 주입도 허용
services.AddSingleton<IChatStorageService, ChatStorageService>();
services.AddSingleton<IAppStateService>(appState);
services.AddSingleton(appState);
services.AddSingleton(chatSessionState);
services.AddSingleton(memoryService);
// ── Transient: 요청마다 새 인스턴스 ──────────────────────────────
services.AddTransient<ILlmService>(sp => new LlmService(sp.GetRequiredService<SettingsService>()));
services.AddSingleton<IModelRouterService>(sp => new ModelRouterService(sp.GetRequiredService<SettingsService>()));
});
LogService.Info("DI 컨테이너 초기화 완료");
}
} }
@@ -6,6 +6,6 @@
"symbol": "\uE943", "symbol": "\uE943",
"color": "#3B82F6", "color": "#3B82F6",
"description": "새 기능 개발, 코드 작성, 프로젝트 구성", "description": "새 기능 개발, 코드 작성, 프로젝트 구성",
"systemPrompt": "당신은 AX Copilot Code Agent — 사내 소프트웨어 개발 전문 에이전트입니다.\n\n## 역할\n새 기능 개발, 코드 작성, 프로젝트 구성을 담당합니다.\n\n## 워크플로우\n1. dev_env_detect로 설치된 개발 도구 확인\n2. folder_map + grep으로 기존 코드베이스 구조 분석\n3. 기존 코드 패턴과 컨벤션을 파악 (네이밍, 아키텍처, 의존성)\n4. 단계별 구현 계획을 사용자에게 제시\n5. 승인 후 file_write/file_edit코드 작성\n6. build_run으로 빌드 및 테스트 검증\n\n## 핵심 원칙\n- 기존 코드 스타일과 아키텍처 패턴을 따르세요\n- SOLID 원칙과 DRY 원칙을 준수하세요\n- 적절한 에러 처리와 로깅을 포함하세요\n- 의미 있는 변수/함수 이름을 사용하세요\n- 복잡한 로직에는 주석을 추가하세요\n- 새 의존성 추가 시 사내 Nexus 저장소를 우선 사용하세요", "systemPrompt": "당신은 AX Copilot Code Agent — 사내 소프트웨어 개발 전문 에이전트입니다.\n\n## 역할\n새 기능 개발, 코드 작성, 프로젝트 구성을 담당합니다.\n\n## 워크플로우\n1. dev_env_detect로 설치된 개발 도구 확인\n2. grep/glob으로 관련 파일과 심볼을 먼저 좁히기\n3. file_read로 기존 코드 패턴과 컨벤션을 파악 (네이밍, 아키텍처, 의존성)\n4. 필요할 때만 계획을 제시하고, 바로 file_edit/file_write구현\n5. git diff, build_run, test_loop로 결과 검증\n6. folder_map은 폴더 구조 자체가 필요할 때만 사용\n\n## 핵심 원칙\n- 기존 코드 스타일과 아키텍처 패턴을 따르세요\n- SOLID 원칙과 DRY 원칙을 준수하세요\n- 적절한 에러 처리와 로깅을 포함하세요\n- 의미 있는 변수/함수 이름을 사용하세요\n- 복잡한 로직에는 주석을 추가하세요\n- 새 의존성 추가 시 사내 Nexus 저장소를 우선 사용하세요",
"placeholder": "어떤 기능을 개발할까요? (프로젝트 폴더를 먼저 선택하세요)" "placeholder": "어떤 기능을 개발할까요? (프로젝트 폴더를 먼저 선택하세요)"
} }
@@ -6,6 +6,6 @@
"symbol": "\uE71B", "symbol": "\uE71B",
"color": "#10B981", "color": "#10B981",
"description": "코드 품질 분석, 모범 사례 검토, 개선 제안", "description": "코드 품질 분석, 모범 사례 검토, 개선 제안",
"systemPrompt": "당신은 AX Copilot Code Reviewer — 코드 품질 분석 전문 에이전트입니다.\n\n## 역할\n코드 리뷰를 수행하여 품질, 가독성, 유지보수성, 성능을 평가합니다.\n\n## 리뷰 관점 (Google Code Review 가이드 기반)\n1. **정확성**: 논리 오류, 경계 조건, null 처리\n2. **가독성**: 네이밍, 주석, 코드 구조\n3. **유지보수성**: 결합도, 응집도, 확장성\n4. **성능**: 불필요한 연산, 메모리 누수, N+1 쿼리\n5. **보안**: 입력 검증, SQL 인젝션, XSS, 하드코딩된 시크릿\n6. **테스트**: 테스트 커버리지, 엣지 케이스\n\n## 워크플로우\n1. folder_map으로 프로젝트 전체 구조 파악\n2. 대상 파일을 file_read로 꼼꼼히 읽기\n3. 관련 파일도 grep/glob으로 확인 (의존성, 호출 관계)\n4. 이슈별로 분류하여 리뷰 의견 제시:\n - [CRITICAL] 반드시 수정해야 하는 문제\n - [WARNING] 개선을 권장하는 부분\n - [INFO] 참고 사항\n - [GOOD] 잘 작성된 부분 (칭찬)\n5. 전체 코드 품질을 A~F 등급으로 평가\n6. 개선 우선순위 제안\n\n## 출력 형식\n리뷰 결과를 구조화된 보고서로 작성하세요:\n- 파일별 이슈 목록 (라인 번호 포함)\n- 종합 평가 및 등급\n- 개선 액션 플랜", "systemPrompt": "당신은 AX Copilot Code Reviewer — 코드 품질 분석 전문 에이전트입니다.\n\n## 역할\n코드 리뷰를 수행하여 품질, 가독성, 유지보수성, 성능을 평가합니다.\n\n## 리뷰 관점 (Google Code Review 가이드 기반)\n1. **정확성**: 논리 오류, 경계 조건, null 처리\n2. **가독성**: 네이밍, 주석, 코드 구조\n3. **유지보수성**: 결합도, 응집도, 확장성\n4. **성능**: 불필요한 연산, 메모리 누수, N+1 쿼리\n5. **보안**: 입력 검증, SQL 인젝션, XSS, 하드코딩된 시크릿\n6. **테스트**: 테스트 커버리지, 엣지 케이스\n\n## 워크플로우\n1. grep/glob으로 대상 파일과 관련 호출부를 먼저 좁히기\n2. 대상 파일을 file_read로 꼼꼼히 읽기\n3. 필요 시 git diff와 추가 file_read로 근거 보강\n4. 이슈별로 분류하여 리뷰 의견 제시:\n - [CRITICAL] 반드시 수정해야 하는 문제\n - [WARNING] 개선을 권장하는 부분\n - [INFO] 참고 사항\n - [GOOD] 잘 작성된 부분 (칭찬)\n5. 전체 코드 품질을 A~F 등급으로 평가\n6. 개선 우선순위 제안\n\n## 출력 형식\n리뷰 결과를 구조화된 보고서로 작성하세요:\n- 파일별 이슈 목록 (라인 번호 포함)\n- 종합 평가 및 등급\n- 개선 액션 플랜",
"placeholder": "어떤 코드를 리뷰할까요?" "placeholder": "어떤 코드를 리뷰할까요?"
} }
@@ -6,6 +6,6 @@
"symbol": "\uE777", "symbol": "\uE777",
"color": "#6366F1", "color": "#6366F1",
"description": "코드 구조 개선, 중복 제거, 성능 최적화", "description": "코드 구조 개선, 중복 제거, 성능 최적화",
"systemPrompt": "당신은 AX Copilot Refactoring Agent — 코드 품질 개선 전문 에이전트입니다.\n\n## 역할\n기존 코드의 구조를 개선하고 기술 부채를 줄이는 리팩터링을 수행합니다.\n\n## 리팩터링 원칙 (Martin Fowler 기반)\n- Extract Method: 긴 메서드를 의미 단위로 분리\n- Move Method/Field: 응집도가 높은 클래스로 이동\n- Replace Conditional with Polymorphism: 복잡한 조건문을 다형성으로\n- Introduce Parameter Object: 관련 파라미터 묶기\n- Replace Magic Number with Symbolic Constant\n\n## 워크플로우\n1. folder_map + grep으로 대상 코드 구조 분석\n2. 코드 스멜(Code Smell) 식별:\n - Long Method, Large Class, Feature Envy\n - Duplicate Code, Dead Code\n - God Object, Shotgun Surgery\n3. 리팩터링 계획을 사용자에게 제시 (변경 전/후 설명)\n4. 승인 후 file_edit으로 점진적 수정 (한 번에 하나의 리팩터링)\n5. 각 단계마다 build_run으로 빌드/테스트 검증\n6. 동작 변경 없이 구조만 개선되었는지 확인\n\n## 주의사항\n- 기능을 변경하지 마세요 (행동 보존 리팩터링)\n- 테스트가 있으면 테스트를 먼저 실행하여 기준선 확보\n- 대규모 변경은 단계별로 나누어 진행\n- 변경 사항을 명확히 설명하세요", "systemPrompt": "당신은 AX Copilot Refactoring Agent — 코드 품질 개선 전문 에이전트입니다.\n\n## 역할\n기존 코드의 구조를 개선하고 기술 부채를 줄이는 리팩터링을 수행합니다.\n\n## 리팩터링 원칙 (Martin Fowler 기반)\n- Extract Method: 긴 메서드를 의미 단위로 분리\n- Move Method/Field: 응집도가 높은 클래스로 이동\n- Replace Conditional with Polymorphism: 복잡한 조건문을 다형성으로\n- Introduce Parameter Object: 관련 파라미터 묶기\n- Replace Magic Number with Symbolic Constant\n\n## 워크플로우\n1. grep/glob으로 대상 코드와 호출 범위를 먼저 좁히기\n2. file_read로 코드 스멜(Code Smell) 식별\n - Long Method, Large Class, Feature Envy\n - Duplicate Code, Dead Code\n - God Object, Shotgun Surgery\n3. 리팩터링 계획을 내부적으로 정리하고 필요할 때만 제시\n4. file_edit으로 점진적 수정 (한 번에 하나의 리팩터링)\n5. 각 단계마다 git diff, build_run, test_loop로 검증\n6. 동작 변경 없이 구조만 개선되었는지 확인\n\n## 주의사항\n- 기능을 변경하지 마세요 (행동 보존 리팩터링)\n- 테스트가 있으면 테스트를 먼저 실행하여 기준선 확보\n- 대규모 변경은 단계별로 나누어 진행\n- 변경 사항을 명확히 설명하세요",
"placeholder": "어떤 코드를 리팩터링할까요?" "placeholder": "어떤 코드를 리팩터링할까요?"
} }
@@ -1,4 +1,4 @@
{ {
"category": "문서", "category": "문서",
"tab": "Cowork", "tab": "Cowork",
"order": 60, "order": 60,
@@ -6,6 +6,6 @@
"symbol": "\uE8A5", "symbol": "\uE8A5",
"color": "#F59E0B", "color": "#F59E0B",
"description": "Word, Markdown, HTML 문서를 작성합니다", "description": "Word, Markdown, HTML 문서를 작성합니다",
"systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 문서를 작성합니다.\n\n## 핵심 원칙\n- 문서 내용을 **상세하고 완결성 있게** 작성합니다.\n- 목차, 소제목, 번호 매기기를 활용하여 구조화합니다.\n- 전문 용어에는 간단한 설명을 병기합니다.\n- 결과물은 Word(.docx), Markdown(.md), HTML(.html) 중 적합한 형식을 선택합니다.\n- 작업 전 계획을 설명하고 도구를 사용하여 파일을 생성합니다.\n\n## 문서 품질 가이드\n\n### HTML 문서 (html_create)\n- **toc: true** 로 목차 자동 생성. **numbered: true** 로 섹션 번호 자동 부여.\n- **cover** 파라미터로 커버 페이지 추가: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃: <div class=\"callout callout-info\">핵심 내용</div> (info/warning/tip/danger/note).\n- 배지: <span class=\"badge badge-blue\">완료</span>.\n- 타임라인: <div class=\"timeline\"><div class=\"timeline-item\">...</div></div>.\n- mood 추천: professional(공식), elegant(격식), minimal(학술), magazine(뉴스레터).\n\n### Word 문서 (docx_create)\n- **header** 파라미터로 머리글 추가. **footer** 에 {page}로 페이지 번호 삽입.\n- sections에서 type: \"table\" 로 스타일 테이블 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 섹션 간 페이지 구분.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문에 **볼드**, *이탤릭*, `코드` 인라인 서식 지원.\n- level: 1(대제목) / 2(소제목) 로 제목 크기 구분.\n\n## 사용 가능한 도구\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- markdown_create: Markdown 문서 생성\n- html_create: HTML 문서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- document_read: 기존 문서(PDF, DOCX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- template_render: 템플릿 기반 문서 렌더링\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n문서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 문서를 한 번에 생성합니다.\n- 문서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — document_plan으로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble으로 하나의 문서로 결합합니다.", "systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 문서를 작성합니다.\n\n## 핵심 원칙\n- 문서 내용을 **상세하고 완결성 있게** 작성합니다.\n- 목차, 소제목, 번호 매기기를 활용하여 구조화합니다.\n- 전문 용어에는 간단한 설명을 병기합니다.\n- 결과물은 Word(.docx), Markdown(.md), HTML(.html) 중 적합한 형식을 선택합니다.\n- 필요할 때만 계획을 드러내고, 도구를 사용하여 실제 파일을 생성합니다.\n- 파일 탐색은 glob/grep과 document_read/file_read를 우선하고, folder_map은 폴더 구조 확인이 필요할 때만 사용합니다.\n- 새 문서를 바로 작성하는 요청이면 탐색보다 생성 도구를 우선합니다.\n\n## 문서 품질 가이드\n\n### HTML 문서 (html_create)\n- **toc: true** 로 목차 자동 생성. **numbered: true** 로 섹션 번호 자동 부여.\n- **cover** 파라미터로 커버 페이지 추가: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃: <div class=\"callout callout-info\">핵심 내용</div> (info/warning/tip/danger/note).\n- 배지: <span class=\"badge badge-blue\">완료</span>.\n- 타임라인: <div class=\"timeline\"><div class=\"timeline-item\">...</div></div>.\n- mood 추천: professional(공식), elegant(격식), minimal(학술), magazine(뉴스레터).\n\n### Word 문서 (docx_create)\n- **header** 파라미터로 머리글 추가. **footer** 에 {page}로 페이지 번호 삽입.\n- sections에서 type: \"table\" 로 스타일 테이블 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 섹션 간 페이지 구분.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문에 **볼드**, *이탤릭*, `코드` 인라인 서식 지원.\n- level: 1(대제목) / 2(소제목) 로 제목 크기 구분.\n\n## 사용 가능한 도구\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- markdown_create: Markdown 문서 생성\n- html_create: HTML 문서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- document_read: 기존 문서(PDF, DOCX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- template_render: 템플릿 기반 문서 렌더링\n- text_summarize: 긴 텍스트 요약\n\n## 기본 전략\n- 새 문서를 작성하는 요청이면 html_create 또는 docx_create를 직접 호출하여 완성된 문서를 한 번에 생성합니다.\n- 기존 자료를 참고하라는 요청일 때만 glob/grep/document_read/file_read를 먼저 사용합니다.\n- 분석이나 조언이 목적이고 파일 생성이 핵심이 아니라면 텍스트 응답으로 마무리할 수 있습니다.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — 필요할 때만 document_plan으로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble으로 하나의 문서로 결합합니다.",
"placeholder": "어떤 문서를 작성할까요? (예: 프로젝트 기획서 작성)" "placeholder": "어떤 문서를 작성할까요? (예: 프로젝트 기획서 작성)"
} }
@@ -1,4 +1,4 @@
{ {
"category": "보고서", "category": "보고서",
"tab": "Cowork", "tab": "Cowork",
"order": 10, "order": 10,
@@ -6,6 +6,6 @@
"symbol": "\uE9F9", "symbol": "\uE9F9",
"color": "#3B82F6", "color": "#3B82F6",
"description": "Excel, Word, HTML 보고서를 상세하게 작성합니다", "description": "Excel, Word, HTML 보고서를 상세하게 작성합니다",
"systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 보고서를 작성합니다.\n\n## 핵심 원칙\n- 데이터를 **상세하고 구체적으로** 작성합니다. 항목을 생략하지 않습니다.\n- 표(테이블)는 가능한 많은 행과 열을 포함합니다.\n- 수치 데이터는 단위를 명확히 표기합니다.\n- 결과물은 Excel(.xlsx), Word(.docx), HTML(.html) 중 가장 적합한 형식을 선택합니다.\n- 작업 전 계획을 설명하고 도구를 사용하여 파일을 생성합니다.\n\n## 문서 품질 가이드\n\n### HTML 보고서 (html_create)\n- **toc: true** 로 목차를 자동 생성하세요.\n- **numbered: true** 로 섹션 번호(1., 1-1.)를 자동 부여하세요.\n- **cover** 파라미터로 커버 페이지를 추가하세요: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃을 활용하세요: <div class=\"callout callout-info\">중요 정보</div> (info/warning/tip/danger/note).\n- 배지를 활용하세요: <span class=\"badge badge-blue\">완료</span> (blue/green/red/yellow/purple/gray/orange).\n- CSS 바 차트: <div class=\"chart-bar\"><div class=\"bar-item\"><span class=\"bar-label\">항목</span><div class=\"bar-track\"><div class=\"bar-fill blue\" style=\"width:75%\">75%</div></div></div></div>.\n- 그리드 레이아웃: <div class=\"grid-2\"> 또는 grid-3, grid-4로 카드 배치.\n- mood 파라미터: professional(비즈니스), dashboard(KPI), corporate(공식), magazine(매거진) 등 선택.\n\n### Excel (excel_create)\n- 기본 style: 'styled' — 파란 헤더, 줄무늬, 테두리 자동 적용.\n- **freeze_header: true** 로 헤더 행 틀 고정.\n- **summary_row** 로 합계/평균 행 자동 생성: {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}.\n- 수식은 셀 값에 '=SUM(B2:B10)' 형태로 입력.\n- **col_widths** 로 열 너비 지정: [20, 15, 12].\n\n### Word (docx_create)\n- **header/footer** 파라미터로 머리글/바닥글 추가. {page}로 페이지 번호.\n- sections에서 type: \"table\" 로 스타일 테이블 삽입 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 페이지 나누기.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문 텍스트에 **볼드**, *이탤릭*, `코드` 인라인 서식 사용 가능.\n\n## 사용 가능한 도구\n- excel_create: Excel 문서 생성 (서식, 수식, 틀 고정, 요약행 지원)\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- html_create: HTML 보고서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- markdown_create: Markdown 문서 생성\n- csv_create: CSV 파일 생성\n- document_read: 기존 문서(PDF, DOCX, XLSX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성 1단계)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립 (멀티패스 생성 3단계)\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- data_pivot: CSV/JSON 데이터 집계/피벗\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n보고서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create, excel_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 보고서를 한 번에 생성합니다.\n- 보고서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — document_plan 도구로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble 도구로 하나의 문서로 결합합니다.", "systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 보고서를 작성합니다.\n\n## 핵심 원칙\n- 데이터를 **상세하고 구체적으로** 작성합니다. 항목을 생략하지 않습니다.\n- 표(테이블)는 가능한 많은 행과 열을 포함합니다.\n- 수치 데이터는 단위를 명확히 표기합니다.\n- 결과물은 Excel(.xlsx), Word(.docx), HTML(.html) 중 가장 적합한 형식을 선택합니다.\n- 필요할 때만 계획을 드러내고, 도구를 사용하여 실제 파일을 생성합니다.\n- 파일 탐색은 glob/grep과 document_read/file_read를 우선하고, folder_map은 폴더 구조 확인이 필요할 때만 사용합니다.\n- 새 보고서를 바로 작성하는 요청이면 탐색보다 생성 도구를 우선합니다.\n\n## 문서 품질 가이드\n\n### HTML 보고서 (html_create)\n- **toc: true** 로 목차를 자동 생성하세요.\n- **numbered: true** 로 섹션 번호(1., 1-1.)를 자동 부여하세요.\n- **cover** 파라미터로 커버 페이지를 추가하세요: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃을 활용하세요: <div class=\"callout callout-info\">중요 정보</div> (info/warning/tip/danger/note).\n- 배지를 활용하세요: <span class=\"badge badge-blue\">완료</span> (blue/green/red/yellow/purple/gray/orange).\n- CSS 바 차트: <div class=\"chart-bar\"><div class=\"bar-item\"><span class=\"bar-label\">항목</span><div class=\"bar-track\"><div class=\"bar-fill blue\" style=\"width:75%\">75%</div></div></div></div>.\n- 그리드 레이아웃: <div class=\"grid-2\"> 또는 grid-3, grid-4로 카드 배치.\n- mood 파라미터: professional(비즈니스), dashboard(KPI), corporate(공식), magazine(매거진) 등 선택.\n\n### Excel (excel_create)\n- 기본 style: 'styled' — 파란 헤더, 줄무늬, 테두리 자동 적용.\n- **freeze_header: true** 로 헤더 행 틀 고정.\n- **summary_row** 로 합계/평균 행 자동 생성: {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}.\n- 수식은 셀 값에 '=SUM(B2:B10)' 형태로 입력.\n- **col_widths** 로 열 너비 지정: [20, 15, 12].\n\n### Word (docx_create)\n- **header/footer** 파라미터로 머리글/바닥글 추가. {page}로 페이지 번호.\n- sections에서 type: \"table\" 로 스타일 테이블 삽입 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 페이지 나누기.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문 텍스트에 **볼드**, *이탤릭*, `코드` 인라인 서식 사용 가능.\n\n## 사용 가능한 도구\n- excel_create: Excel 문서 생성 (서식, 수식, 틀 고정, 요약행 지원)\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- html_create: HTML 보고서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- markdown_create: Markdown 문서 생성\n- csv_create: CSV 파일 생성\n- document_read: 기존 문서(PDF, DOCX, XLSX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성 1단계)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립 (멀티패스 생성 3단계)\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- data_pivot: CSV/JSON 데이터 집계/피벗\n- text_summarize: 긴 텍스트 요약\n\n## 기본 전략\n- 새 보고서를 작성하는 요청이면 html_create, docx_create, excel_create 중 적합한 도구로 완성된 결과를 바로 생성합니다.\n- 기존 자료를 참고하라는 요청일 때만 glob/grep/document_read/file_read를 먼저 사용합니다.\n- 분석이나 설명이 목적이고 파일 생성이 핵심이 아니라면 텍스트 응답으로 마무리할 수 있습니다.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — 필요할 때만 document_plan 도구로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble 도구로 하나의 문서로 결합합니다.",
"placeholder": "어떤 보고서를 작성할까요? (예: 삼성디스플레이 연혁 보고서)" "placeholder": "어떤 보고서를 작성할까요? (예: 삼성디스플레이 연혁 보고서)"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

+197
View File
@@ -0,0 +1,197 @@
from PIL import Image, ImageDraw, ImageFont
# 1. 색상 정의 (RGB 값) - 확장된 팔레트
B = (20, 20, 20) # Background (Dark Gray)
G1 = (121, 210, 180) # Mint Green (Main body)
G2 = (100, 180, 160) # Mint Green (Shadow)
W1 = (255, 255, 255) # White (Face/Belly)
W2 = (240, 240, 240) # White (Shadow)
P1 = (255, 180, 210) # Pink (Main Body)
P2 = (255, 150, 190) # Pink (Fluffy texture detail)
P3 = (240, 130, 170) # Pink (Shadow)
E1 = (0, 0, 0) # Eyes (Black)
E2 = (40, 40, 40) # Eyes (Pupil detail)
C_RED = (255, 80, 80) # Ear pattern Red
C_YLW = (255, 230, 80) # Ear pattern Yellow
C_BLU = (80, 150, 255) # Ear pattern Blue
C_PUR = (180, 80, 255) # Ear pattern Purple
C_GRN = (80, 220, 120) # Pink hair pattern Green
ORNG = (255, 145, 77) # Feet (Orange)
# 2. 텍스트 그리기 함수 (픽셀 폰트)
def draw_pixel_text(draw, text, position, font_size=16, color=(255, 255, 255)):
# 실제 픽셀 폰트를 사용하려면 폰트 파일이 필요합니다.
# 여기서는 Pillow의 기본 폰트를 사용하지만, 픽셀 폰트처럼 보이게 할 수 있습니다.
# 더 좋은 결과를 위해 실제 .ttf 픽셀 폰트를 로드하는 것이 좋습니다.
try:
font = ImageFont.truetype("Arial.ttf", font_size) # 실제 폰트 파일 경로
except IOError:
font = ImageFont.load_default() # 폰트 파일이 없으면 기본 폰트 사용
font_size = 12 # 기본 폰트 크기
draw.text(position, text, font=font, fill=color)
# 3. 캐릭터 그리기 함수 (개념적 블록 조합)
def draw_pixel_bunny(draw, position, size, pose, is_detailed=True):
# size: 캐릭터의 기본 크기 (예: 32x32)
# is_detailed: 원본의 단순화된 스타일 대신 더 정교한 디테일을 추가할지 여부
x_off, y_off = position
scale = size[0] // 32
# 기본 형태 그리기
# 머리
head_color = G1
head_size = int(size[0] * 0.5)
head_y = y_off + int(size[1] * 0.1)
draw.ellipse((x_off + int(size[0]*0.25), head_y,
x_off + int(size[0]*0.75), head_y + head_size),
fill=head_color)
# 얼굴 흰 부분
draw.ellipse((x_off + int(size[0]*0.3), head_y + int(head_size*0.1),
x_off + int(size[0]*0.7), head_y + int(head_size*0.9)),
fill=W1)
# 귀
draw.rectangle((x_off + int(size[0]*0.3), head_y - int(size[1]*0.1),
x_off + int(size[0]*0.4), head_y),
fill=head_color)
draw.rectangle((x_off + int(size[0]*0.6), head_y - int(size[1]*0.1),
x_off + int(size[0]*0.7), head_y),
fill=head_color)
# 귀 디테일 (원본의 X 패턴 대신 더 복잡한 줄무늬)
if is_detailed:
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.08),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.06)), fill=C_RED)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.06),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.04)), fill=C_YLW)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.04),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.02)), fill=C_BLU)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.02),
x_off + int(size[0]*0.39), head_y), fill=C_PUR)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.08),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.06)), fill=C_RED)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.06),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.04)), fill=C_YLW)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.04),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.02)), fill=C_BLU)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.02),
x_off + int(size[0]*0.69), head_y), fill=C_PUR)
# 눈
draw.ellipse((x_off + int(size[0]*0.4), head_y + int(head_size*0.3),
x_off + int(size[0]*0.48), head_y + int(head_size*0.5)),
fill=E1)
draw.ellipse((x_off + int(size[0]*0.52), head_y + int(head_size*0.3),
x_off + int(size[0]*0.6), head_y + int(head_size*0.5)),
fill=E1)
# 눈동자 디테일
if is_detailed:
draw.ellipse((x_off + int(size[0]*0.42), head_y + int(head_size*0.35),
x_off + int(size[0]*0.46), head_y + int(head_size*0.45)),
fill=W1)
draw.ellipse((x_off + int(size[0]*0.54), head_y + int(head_size*0.35),
x_off + int(size[0]*0.58), head_y + int(head_size*0.45)),
fill=W1)
# 몸통
draw.rectangle((x_off + int(size[0]*0.3), head_y + head_size,
x_off + int(size[0]*0.7), head_y + head_size + int(size[1]*0.3)),
fill=head_color)
# 배 흰 부분
draw.ellipse((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.05),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.25)),
fill=W1)
# 다리
draw.rectangle((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.3),
x_off + int(size[0]*0.45), head_y + head_size + int(size[1]*0.4)),
fill=head_color)
draw.rectangle((x_off + int(size[0]*0.55), head_y + head_size + int(size[1]*0.3),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.4)),
fill=head_color)
# 발 (주황색)
draw.rectangle((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.38),
x_off + int(size[0]*0.45), head_y + head_size + int(size[1]*0.43)),
fill=ORNG)
draw.rectangle((x_off + int(size[0]*0.55), head_y + head_size + int(size[1]*0.38),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.43)),
fill=ORNG)
# 측면 포즈일 경우 디테일 추가
if pose == 'side':
# 눈 모양 변경
# ... 측면 포즈에 대한 더 많은 코드 ...
pass
def draw_pixel_pink_fluffball(draw, position, size, pose, is_detailed=True):
# draw_pixel_bunny와 유사한 구조, 하지만 털 질감을 추가
x_off, y_off = position
scale = size[0] // 32
# 머리 (털 질감)
head_y = y_off + int(size[1] * 0.1)
for _ in range(int(size[0]*0.5)):
for _ in range(int(size[0]*0.5)):
draw.point((x_off + int(size[0]*0.25) + _, head_y + _), fill=P1)
# ... 더 많은 털 질감과 디테일 코드 ...
pass
# 4. 이미지 크기 정의 및 배경 생성
canvas_width = 800
canvas_height = 600
img = Image.new('RGB', (canvas_width, canvas_height), color=B)
draw = ImageDraw.Draw(img)
# 5. 이미지 구성
# 상단 제목 그리기
draw_pixel_text(draw, "정교한 캐릭터 시트", (int(canvas_width*0.35), int(canvas_height*0.05)), font_size=32)
# 왼쪽 패널 그리기 (민트 토끼 캐릭터 '더 자세히' 업그레이드)
panel_bunny_y = int(canvas_height*0.2)
panel_title_bunny = "민트 토끼 슈트 캐릭터"
draw_pixel_text(draw, panel_title_bunny, (int(canvas_width*0.1), panel_bunny_y), font_size=20)
bunny_size_detailed = (200, 200) # 원본보다 더 큰 크기로 그려서 디테일 표현
bunny_pos_detailed = (int(canvas_width*0.1), panel_bunny_y + 40)
draw_pixel_bunny(draw, bunny_pos_detailed, bunny_size_detailed, 'front', is_detailed=True)
draw_pixel_text(draw, "정면", (int(canvas_width*0.1 + bunny_size_detailed[0]*0.4), panel_bunny_y + 40 + bunny_size_detailed[1]), font_size=16)
bunny_pos_detailed_side = (int(canvas_width*0.1 + bunny_size_detailed[0] + 50), panel_bunny_y + 40)
draw_pixel_bunny(draw, bunny_pos_detailed_side, bunny_size_detailed, 'side', is_detailed=True)
draw_pixel_text(draw, "측면 45도", (int(canvas_width*0.1 + bunny_size_detailed[0] + 50 + bunny_size_detailed[0]*0.3), panel_bunny_y + 40 + bunny_size_detailed[1]), font_size=16)
# 오른쪽 패널 그리기 (핑크 털뭉치 캐릭터 '더 자세히' 업그레이드)
panel_pink_y = int(canvas_height*0.2)
panel_title_pink = "핑크 털 뭉치 슈트 캐릭터"
draw_pixel_text(draw, panel_title_pink, (int(canvas_width*0.5), panel_pink_y), font_size=20)
pink_size_detailed = (200, 200) # 원본보다 더 큰 크기로 그려서 디테일 표현
pink_pos_detailed = (int(canvas_width*0.5), panel_pink_y + 40)
draw_pixel_pink_fluffball(draw, pink_pos_detailed, pink_size_detailed, 'front', is_detailed=True)
draw_pixel_text(draw, "정면", (int(canvas_width*0.5 + pink_size_detailed[0]*0.4), panel_pink_y + 40 + pink_size_detailed[1]), font_size=16)
pink_pos_detailed_side = (int(canvas_width*0.5 + pink_size_detailed[0] + 50), panel_pink_y + 40)
draw_pixel_pink_fluffball(draw, pink_pos_detailed_side, pink_size_detailed, 'side', is_detailed=True)
draw_pixel_text(draw, "측면 45도", (int(canvas_width*0.5 + pink_size_detailed[0] + 50 + pink_size_detailed[0]*0.3), panel_pink_y + 40 + pink_size_detailed[1]), font_size=16)
# 6. 보기 좋게 확대 및 픽셀 격자 표현 (옵션)
# 픽셀 아트 격자 효과를 주려면, 이미지를 작게 그린 후 크게 확대합니다.
# 이 코드는 개념적인 블록 그리기 방식을 사용하여 직접 디테일한 픽셀 아트를 생성하므로,
# 실제 격자 확대 효과는 생략합니다.
# 7. 결과 저장 및 보여주기
output_filename = "detailed_pixel_characters_sheet.png"
img.save(output_filename)
img.show()
print(f"정교한 캐릭터 시트 이미지가 '{output_filename}'으로 저장되었습니다. {canvas_width}x{canvas_height} 크기")
+14
View File
@@ -68,6 +68,7 @@
<!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) --> <!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) -->
<!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) --> <!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" /> <PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" />
<PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
@@ -114,6 +115,11 @@
<Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" /> <Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" />
<Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" /> <Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" />
<Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" /> <Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" />
<Resource Include="Assets\foldy_qdy.png" Condition="Exists('Assets\foldy_qdy.png')" />
<Resource Include="Assets\pixel_art.png" Condition="Exists('Assets\pixel_art.png')" />
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
<!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) --> <!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) -->
<Content Include="Assets\SearchEngines\*.png"> <Content Include="Assets\SearchEngines\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -125,6 +131,14 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<!-- 마스코트 GIF 애니메이션: 빌드 출력 Assets/gif/ 폴더에 복사 -->
<ItemGroup>
<Content Include="Assets\gif\*.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Assets\gif\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 --> <!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 -->
<ItemGroup> <ItemGroup>
<Content Include="skills\*.skill.md"> <Content Include="skills\*.skill.md">
@@ -0,0 +1,422 @@
<Project>
<PropertyGroup>
<AssemblyName>AxCopilot</AssemblyName>
<IntermediateOutputPath>obj\Release\</IntermediateOutputPath>
<BaseIntermediateOutputPath>obj\</BaseIntermediateOutputPath>
<MSBuildProjectExtensionsPath>E:\AX Copilot - Claude\src\AxCopilot\obj\</MSBuildProjectExtensionsPath>
<_TargetAssemblyProjectName>AxCopilot</_TargetAssemblyProjectName>
<RootNamespace>AxCopilot</RootNamespace>
</PropertyGroup>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>AxCopilot</RootNamespace>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<!--
★ 버전 관리 규칙 ★
이 <Version> 값 하나만 변경하면 앱 전체에 자동 반영됩니다:
- 설정 창 하단 버전 표시 (SettingsWindow.xaml.cs → SetVersionText())
- Windows 속성 탭 파일 버전 / 제품 버전
- DEVELOPMENT.md 변경 이력은 수동으로 함께 업데이트하세요.
설정 스키마 버전(마이그레이션)은 Services/SettingsService.cs → CurrentSettingsVersion 을 별도로 관리합니다.
-->
<Version>0.7.3</Version>
<Company>AX</Company>
<Product>AX Copilot</Product>
<Description>AI 기반 업무 자동화 런처 &amp; 코파일럿</Description>
<!-- ScreenCaptureHandler의 LockBits 포인터 연산에 필요 -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 배포: build.bat에서 self-contained / framework-dependent 두 종류로 publish -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!-- ─── 디컴파일 방지 ─────────────────────────────────────────────── -->
<!-- 디버그 심볼 제거 (Release) -->
<DebugType Condition="'$(Configuration)'=='Release'">none</DebugType>
<DebugSymbols Condition="'$(Configuration)'=='Release'">false</DebugSymbols>
<!-- 임베디드 소스 제거 -->
<EmbedAllSources>false</EmbedAllSources>
<!-- Suppress source link -->
<EnableSourceLink>false</EnableSourceLink>
<DeterministicSourcePaths>false</DeterministicSourcePaths>
</PropertyGroup>
<!-- Release 빌드 시 추가 난독화 설정 -->
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<Optimize>true</Optimize>
<!-- 사용하지 않는 멤버 제거 (IL trimming) -->
<PublishTrimmed>false</PublishTrimmed>
<!-- PDB 제거 -->
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<!-- 배포판 보호 수준 강화 -->
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의
System.Windows.Input과 충돌(KeyEventArgs 모호성)하므로 전역 제거.
System.Drawing도 전역 제거: System.Windows.Media의 Color/ColorConverter/FontFamily와
모호한 참조 충돌 방지. Drawing 타입이 필요한 파일에서만 명시적 using으로 추가. -->
<ItemGroup>
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AxCopilot.SDK\AxCopilot.SDK.csproj" />
</ItemGroup>
<!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) -->
<!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>
<!-- Assets -->
<ItemGroup>
<!-- 가이드 원본 HTML: 편집용. 출력에 복사하지 않음 (암호화 버전 사용) -->
<None Include="Assets\AX Copilot 사용가이드.htm" />
<None Include="Assets\AX Copilot 개발자가이드.htm" />
<!-- 암호화된 가이드: 빌드 출력에 복사 (앱 내장 뷰어로 복호화) -->
<Content Include="Assets\guide_user.enc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\guide_dev.enc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- 아이콘: 앱 내장 리소스 + 출력에도 복사 (트레이용) -->
<None Update="Assets\icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- about.json: 빌드 시 exe에 내장되는 리소스. 런타임 파일 수정 불가. -->
<!-- 격려/명언 문구: 빌드 시 내장. 인터넷 연결 불필요. -->
<!-- 대화 주제 프리셋: 9종 시스템 프롬프트 (빌드 시 내장) -->
<EmbeddedResource Include="Assets\Presets\*.json" />
<!-- 마스코트 이미지: 내장 리소스 (EXE에 포함됨, 교체 시 재빌드 필요) -->
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
<!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) -->
<Content Include="Assets\SearchEngines\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- 시스템 프롬프트: 빌드 출력에 복사 (동적 로딩) -->
<Content Include="Assets\system_prompt.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>system_prompt.txt</Link>
</Content>
</ItemGroup>
<!-- 마스코트 GIF 애니메이션: 빌드 출력 Assets/gif/ 폴더에 복사 -->
<ItemGroup>
<Content Include="Assets\gif\*.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Assets\gif\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 -->
<ItemGroup>
<Content Include="skills\*.skill.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>skills\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 테마 리소스 딕셔너리 -->
<ItemGroup>
</ItemGroup>
<!-- 단위 테스트 프로젝트에서 internal 멤버 접근 허용 -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AxCopilot.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Accessibility.dll" />
<ReferencePath Include="E:\AX Copilot - Claude\src\AxCopilot.SDK\bin\Release\net8.0-windows\AxCopilot.SDK.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\documentformat.openxml\3.2.0\lib\net8.0\DocumentFormat.OpenXml.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\documentformat.openxml.framework\3.2.0\lib\net8.0\DocumentFormat.OpenXml.Framework.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\markdig\0.37.0\lib\net8.0\Markdig.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.CSharp.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.data.sqlite.core\8.0.0\lib\net8.0\Microsoft.Data.Sqlite.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.extensions.dependencyinjection.abstractions\8.0.2\lib\net8.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.extensions.dependencyinjection\8.0.1\lib\net8.0\Microsoft.Extensions.DependencyInjection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.Core.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.Forms.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\netcoreapp3.0\Microsoft.Web.WebView2.Core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\netcoreapp3.0\Microsoft.Web.WebView2.WinForms.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\net5.0-windows10.0.17763.0\Microsoft.Web.WebView2.Wpf.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Registry.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Registry.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.SystemEvents.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\lib\net8.0\Microsoft.Windows.SDK.NET.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\mscorlib.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\netstandard.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationCore.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Aero.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Aero2.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.AeroLite.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Classic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Luna.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Royale.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationUI.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\qrcoder\1.6.0\lib\net6.0-windows7.0\QRCoder.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\ReachFramework.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.bundle_e_sqlite3\2.1.6\lib\netstandard2.0\SQLitePCLRaw.batteries_v2.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.core\2.1.6\lib\netstandard2.0\SQLitePCLRaw.core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.provider.e_sqlite3\2.1.6\lib\net6.0-windows7.0\SQLitePCLRaw.provider.e_sqlite3.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.AppContext.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Buffers.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.CodeDom.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Concurrent.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Immutable.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.NonGeneric.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Specialized.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.Annotations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.DataAnnotations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.EventBasedAsync.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.TypeConverter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Configuration.ConfigurationManager.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Configuration.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Console.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Core.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.Common.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.DataSetExtensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Contracts.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Debug.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.DiagnosticSource.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.EventLog.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.FileVersionInfo.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.PerformanceCounter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Process.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.StackTrace.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.TextWriterTraceListener.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Tools.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.TraceSource.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Tracing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.DirectoryServices.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.Common.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Drawing.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Dynamic.Runtime.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Formats.Asn1.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Formats.Tar.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.Calendars.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.Brotli.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.FileSystem.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.ZipFile.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.DriveInfo.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.Watcher.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.IsolatedStorage.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.MemoryMappedFiles.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.IO.Packaging.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Pipes.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Pipes.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.UnmanagedMemoryStream.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Expressions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Parallel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Queryable.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Memory.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Http.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Http.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.HttpListener.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Mail.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.NameResolution.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.NetworkInformation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Ping.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Quic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Requests.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Security.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.ServicePoint.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Sockets.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebClient.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebHeaderCollection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebProxy.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebSockets.Client.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebSockets.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Numerics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Numerics.Vectors.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ObjectModel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Printing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.DispatchProxy.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.ILGeneration.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.Lightweight.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Metadata.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.TypeExtensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Resources.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.Reader.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.ResourceManager.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.Writer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.CompilerServices.Unsafe.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.CompilerServices.VisualC.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Handles.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.JavaScript.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.RuntimeInformation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Intrinsics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Loader.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Numerics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Formatters.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Claims.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Algorithms.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Cng.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Csp.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Encoding.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.OpenSsl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Pkcs.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.ProtectedData.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.X509Certificates.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Permissions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Principal.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Principal.Windows.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.SecureString.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ServiceModel.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ServiceProcess.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\system.serviceprocess.servicecontroller\8.0.1\lib\net8.0\System.ServiceProcess.ServiceController.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.CodePages.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encodings.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.RegularExpressions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Threading.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Channels.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Overlapped.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Dataflow.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Parallel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Thread.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.ThreadPool.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Timer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Transactions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Transactions.Local.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ValueTuple.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Web.HttpUtility.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Controls.Ribbon.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Windows.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Design.Editors.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Input.Manipulations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Presentation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Xaml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.Linq.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.ReaderWriter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.Serialization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XDocument.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XmlDocument.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XmlSerializer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XPath.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XPath.XDocument.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.core\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.fonts\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Fonts.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.tokenization\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Tokenization.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.tokens\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Tokens.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationClient.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationClientSideProviders.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationProvider.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationTypes.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\WindowsBase.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\WindowsFormsIntegration.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\lib\net8.0\WinRT.Runtime.dll" />
</ItemGroup>
<ItemGroup>
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AboutWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AgentSettingsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AgentStatsDashboardWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\BatchRenameWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ChatWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ClipboardImagePreviewWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ColorPickResultWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\CommandPaletteWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\DockBarWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\EyeDropperWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\GuideViewerWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\HelpDetailWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\LargeTypeWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\LauncherWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\MacroEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\PreviewWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\QuickLookWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\RegionSelectWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ReminderPopupWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ResourceMonitorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ScheduleEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SessionEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SettingsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ShortcutHelpWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SkillEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SkillGalleryWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\StatisticsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\TextActionPopup.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\TrayMenuWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\WorkflowAnalyzerWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\App.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\AxCopilot_Content.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\GeneratedInternalTypeHelper.g.cs" />
</ItemGroup>
<ItemGroup>
<Analyzer Include="C:\Program Files\dotnet\sdk\10.0.201\Sdks\Microsoft.NET.Sdk\targets\..\analyzers\Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll" />
<Analyzer Include="C:\Program Files\dotnet\sdk\10.0.201\Sdks\Microsoft.NET.Sdk\targets\..\analyzers\Microsoft.CodeAnalysis.NetAnalyzers.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.net.illink.tasks\8.0.25\analyzers\dotnet\cs\ILLink.CodeFixProvider.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.net.illink.tasks\8.0.25\analyzers\dotnet\cs\ILLink.RoslynAnalyzer.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.ComInterfaceGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.JavaScript.JSImportGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.LibraryImportGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.SourceGeneration.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/System.Text.Json.SourceGeneration.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/System.Text.RegularExpressions.Generator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\analyzers/dotnet/System.Windows.Forms.Analyzers.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\analyzers/dotnet/cs/System.Windows.Forms.Analyzers.CSharp.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\analyzers/dotnet/cs/WinRT.SourceGenerator.dll" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>
+1 -1
View File
@@ -64,7 +64,7 @@ public class ChatHandler : IActionHandler
try try
{ {
var storage = new ChatStorageService(); var storage = ServiceLocator.Get<IChatStorageService>();
var metas = storage.LoadAllMeta(); var metas = storage.LoadAllMeta();
foreach (var conv in metas.Take(5)) foreach (var conv in metas.Take(5))
{ {
+44 -3
View File
@@ -185,6 +185,14 @@ public class LauncherSettings
[JsonPropertyName("enableIconAnimation")] [JsonPropertyName("enableIconAnimation")]
public bool EnableIconAnimation { get; set; } = true; public bool EnableIconAnimation { get; set; } = true;
/// <summary>채팅 본문 런처 아이콘 랜덤 애니메이션. false이면 숨쉬기만. 기본 false.</summary>
[JsonPropertyName("enableChatIconRandomAnimation")]
public bool EnableChatIconRandomAnimation { get; set; } = false;
/// <summary>채팅 본문 런처 아이콘 글로우 강도. strong/medium/weak. 기본 medium.</summary>
[JsonPropertyName("chatIconGlowIntensity")]
public string ChatIconGlowIntensity { get; set; } = "medium";
/// <summary>런처 안내 문구 랜덤 출력 활성화. false이면 고정 문구. 기본 true.</summary> /// <summary>런처 안내 문구 랜덤 출력 활성화. false이면 고정 문구. 기본 true.</summary>
[JsonPropertyName("enableRandomPlaceholder")] [JsonPropertyName("enableRandomPlaceholder")]
public bool EnableRandomPlaceholder { get; set; } = true; public bool EnableRandomPlaceholder { get; set; } = true;
@@ -804,10 +812,18 @@ public class LlmSettings
[JsonPropertyName("recentPromptTemplates")] [JsonPropertyName("recentPromptTemplates")]
public List<string> RecentPromptTemplates { get; set; } = new(); public List<string> RecentPromptTemplates { get; set; } = new();
/// <summary>작업 폴더 경로. 빈 문자열이면 미선택.</summary> /// <summary>작업 폴더 경로. 빈 문자열이면 미선택. (레거시 — 탭별 경로 미지정 시 폴백)</summary>
[JsonPropertyName("workFolder")] [JsonPropertyName("workFolder")]
public string WorkFolder { get; set; } = ""; public string WorkFolder { get; set; } = "";
/// <summary>코워크(Cowork) 탭 전용 작업 폴더.</summary>
[JsonPropertyName("coworkWorkFolder")]
public string CoworkWorkFolder { get; set; } = "";
/// <summary>코드(Code) 탭 전용 작업 폴더.</summary>
[JsonPropertyName("codeWorkFolder")]
public string CodeWorkFolder { get; set; } = "";
/// <summary>최근 사용한 작업 폴더 목록.</summary> /// <summary>최근 사용한 작업 폴더 목록.</summary>
[JsonPropertyName("recentWorkFolders")] [JsonPropertyName("recentWorkFolders")]
public List<string> RecentWorkFolders { get; set; } = new(); public List<string> RecentWorkFolders { get; set; } = new();
@@ -885,9 +901,9 @@ public class LlmSettings
[JsonPropertyName("maxTestFixIterations")] [JsonPropertyName("maxTestFixIterations")]
public int MaxTestFixIterations { get; set; } = 5; public int MaxTestFixIterations { get; set; } = 5;
/// <summary>에이전트 로그 표시 수준. simple | detailed | debug</summary> /// <summary>에이전트 로그 표시 수준. hidden | simple | detailed | debug</summary>
[JsonPropertyName("agentLogLevel")] [JsonPropertyName("agentLogLevel")]
public string AgentLogLevel { get; set; } = "simple"; public string AgentLogLevel { get; set; } = "detailed";
/// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary> /// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary>
[JsonPropertyName("agentUiExpressionLevel")] [JsonPropertyName("agentUiExpressionLevel")]
@@ -1055,6 +1071,14 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")] [JsonPropertyName("enableChatRainbowGlow")]
public bool EnableChatRainbowGlow { get; set; } = false; public bool EnableChatRainbowGlow { get; set; } = false;
/// <summary>새로운 계획 뷰어(V2 사이드바 레이아웃) 사용. 기본 true.</summary>
[JsonPropertyName("enableNewPlanViewer")]
public bool EnableNewPlanViewer { get; set; } = true;
/// <summary>새로운 채팅 렌더링(V2 상세 이력) 사용. 기본 false.</summary>
[JsonPropertyName("enableNewChatRendering")]
public bool EnableNewChatRendering { get; set; } = false;
/// <summary>AX Agent 전용 테마. system | light | dark</summary> /// <summary>AX Agent 전용 테마. system | light | dark</summary>
[JsonPropertyName("agentTheme")] [JsonPropertyName("agentTheme")]
public string AgentTheme { get; set; } = "system"; public string AgentTheme { get; set; } = "system";
@@ -1069,6 +1093,11 @@ public class LlmSettings
[JsonPropertyName("notifyOnComplete")] [JsonPropertyName("notifyOnComplete")]
public bool NotifyOnComplete { get; set; } = false; public bool NotifyOnComplete { get; set; } = false;
/// <summary>코워크 작업 완료 후 문서 자동 처리 방식.
/// "none" = 아무것도 안하기, "open" = 문서 실행(기본 앱), "preview" = 미리보기 뷰어.</summary>
[JsonPropertyName("coworkOnComplete")]
public string CoworkOnComplete { get; set; } = "none";
/// <summary>AI 대화창에서 팁 알림 표시 여부.</summary> /// <summary>AI 대화창에서 팁 알림 표시 여부.</summary>
[JsonPropertyName("showTips")] [JsonPropertyName("showTips")]
public bool ShowTips { get; set; } = false; public bool ShowTips { get; set; } = false;
@@ -1315,6 +1344,18 @@ public class CodeSettings
/// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary> /// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableCronTools")] [JsonPropertyName("enableCronTools")]
public bool EnableCronTools { get; set; } = true; public bool EnableCronTools { get; set; } = true;
/// <summary>
/// Code 탭 빈 화면 마스코트 캐릭터 출동 수준.
/// "none"=출동 안하기, "one"=한명만(1), "few"=적게(3), "mid"=중간(6), "all"=전부(9+)
/// 기본 "none" (메모리 절약).
/// </summary>
[JsonPropertyName("mascotLevel")]
public string MascotLevel { get; set; } = "none";
/// <summary>(하위호환) 이전 bool 설정 — 무시됨. MascotLevel로 대체.</summary>
[JsonPropertyName("enableMascotCharacter")]
public bool EnableMascotCharacter { get; set; } = false;
} }
/// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary> /// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary>
+5 -1
View File
@@ -63,6 +63,10 @@ public class ChatConversation
[JsonPropertyName("pinned")] [JsonPropertyName("pinned")]
public bool Pinned { get; set; } = false; public bool Pinned { get; set; } = false;
/// <summary>아카이브된 대화는 목록에서 기본 숨김 처리.</summary>
[JsonPropertyName("archived")]
public bool Archived { get; set; } = false;
/// <summary>대화가 속한 탭. "Chat" | "Cowork" | "Code".</summary> /// <summary>대화가 속한 탭. "Chat" | "Cowork" | "Code".</summary>
[JsonPropertyName("tab")] [JsonPropertyName("tab")]
public string Tab { get; set; } = "Chat"; public string Tab { get; set; } = "Chat";
@@ -126,7 +130,7 @@ public class ChatConversation
public List<DraftQueueItem> DraftQueueItems { get; set; } = new(); public List<DraftQueueItem> DraftQueueItems { get; set; } = new();
[JsonPropertyName("showExecutionHistory")] [JsonPropertyName("showExecutionHistory")]
public bool ShowExecutionHistory { get; set; } = true; public bool ShowExecutionHistory { get; set; } = false;
[JsonPropertyName("agentRunHistory")] [JsonPropertyName("agentRunHistory")]
public List<ChatAgentRunRecord> AgentRunHistory { get; set; } = new(); public List<ChatAgentRunRecord> AgentRunHistory { get; set; } = new();
@@ -19,17 +19,6 @@ public partial class AgentLoopService
runState.PendingPostCompactionTurn = false; runState.PendingPostCompactionTurn = false;
runState.PostCompactionTurnCounter++; runState.PostCompactionTurnCounter++;
SyncRunPostCompactionState(runState); SyncRunPostCompactionState(runState);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "기본"
: runState.LastCompactionStageSummary;
var saved = runState.LastCompactionSavedTokens > 0
? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감"
: "";
EmitEvent(
AgentEventType.Thinking,
"",
$"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}");
} }
private void SyncRunPostCompactionState(RunState runState) private void SyncRunPostCompactionState(RunState runState)
@@ -59,7 +48,7 @@ public partial class AgentLoopService
|| summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal); || summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal);
} }
private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState) private string BuildLoopToolResultMessage(ContentBlock call, ToolResult result, RunState runState)
{ {
var output = result.Output ?? ""; var output = result.Output ?? "";
if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output)) if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output))
@@ -11,6 +11,8 @@ public partial class AgentLoopService
TopicBased, TopicBased,
RepoWide, RepoWide,
OpenEnded, OpenEnded,
/// <summary>문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용.</summary>
DirectCreation,
} }
private sealed class ExplorationTrackingState private sealed class ExplorationTrackingState
@@ -22,6 +24,203 @@ public partial class AgentLoopService
public bool BroadScanDetected { get; set; } public bool BroadScanDetected { get; set; }
public bool SelectiveHit { get; set; } public bool SelectiveHit { get; set; }
public bool CorrectiveHintInjected { get; set; } public bool CorrectiveHintInjected { get; set; }
/// <summary>스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀.</summary>
public bool SkillAllowedToolsActive { get; set; }
}
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
IReadOnlyCollection<IAgentTool> tools,
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
if (tools.Count == 0)
return tools;
// 스킬 런타임 정책으로 allowed-tools가 명시된 경우 탐색 필터링을 건너뜀
// — 스킬이 의도적으로 허용한 도구(folder_map 등)를 정책이 차단하면 안 됨
if (state.SkillAllowedToolsActive)
return tools;
// 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치
if (state.Scope == ExplorationScope.DirectCreation)
{
var creationFirst = new List<IAgentTool>(tools.Count);
creationFirst.AddRange(tools.Where(IsDocumentCreationTool));
creationFirst.AddRange(tools.Where(t => !IsDocumentCreationTool(t)
&& !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
// 탐색 도구는 마지막에 (필요 시에만 사용)
creationFirst.AddRange(tools.Where(t =>
string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
return creationFirst
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
var allowFolderMap = ShouldAllowFolderMapForCurrentIteration(state, userQuery, activeTab, totalToolCalls);
var ordered = new List<IAgentTool>(tools.Count);
ordered.AddRange(tools.Where(IsSelectiveDiscoveryTool));
ordered.AddRange(tools.Where(t => !IsSelectiveDiscoveryTool(t) &&
(allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))));
if (allowFolderMap)
{
ordered.AddRange(tools.Where(t => string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)));
}
return ordered
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
private static bool IsDocumentCreationTool(IAgentTool tool)
{
return tool.Name is "document_plan" or "docx_create" or "html_create" or "excel_create"
or "markdown_create" or "csv_create" or "pptx_create" or "chart_create"
or "document_assemble" or "file_write";
}
private static bool ShouldAllowFolderMapForCurrentIteration(
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
// 문서 생성 모드에서는 folder_map 차단 — 탐색 없이 바로 생성
if (state.Scope == ExplorationScope.DirectCreation)
return false;
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return true;
if (HasExplicitFolderIntent(userQuery))
return true;
if (string.Equals(activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& IsExistingMaterialReferenceRequest(userQuery))
return true;
return totalToolCalls >= 2 && state.TotalFilesRead == 0 && state.FolderMapCalls == 0;
}
private static bool HasExplicitFolderIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"folder",
"directory",
"tree",
"structure",
"list files",
"workspace layout",
"work folder",
"project structure",
"폴더",
"디렉터리",
"폴더 구조",
"디렉터리 구조",
"파일 목록",
"작업 폴더",
"폴더 안",
"구조를 보여",
"구조 확인");
}
private static bool IsExistingMaterialReferenceRequest(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"existing file",
"existing files",
"existing document",
"existing documents",
"reference files",
"reference docs",
"existing materials",
"기존 파일",
"기존 문서",
"기존 자료",
"참고 파일",
"참고 문서",
"폴더 내 자료",
"안의 자료",
"작업 폴더 파일");
}
private static bool IsSelectiveDiscoveryTool(IAgentTool tool)
{
return tool.Name is "glob" or "grep" or "file_read" or "document_read" or "multi_read" or "lsp_code_intel";
}
private static bool HasSemanticNavigationIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"definition",
"reference",
"references",
"implementation",
"implementations",
"caller",
"callers",
"callee",
"call hierarchy",
"symbol",
"symbols",
"interface",
"override",
"정의",
"참조",
"구현",
"호출부",
"호출 관계",
"심볼",
"인터페이스",
"오버라이드");
}
private static string BuildPreferredInitialToolSequence(
ExplorationTrackingState state,
TaskTypePolicy taskPolicy,
string? activeTab,
string userQuery)
{
// 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용
if (state.Scope == ExplorationScope.DirectCreation)
return "document_plan -> docx_create/html_create/excel_create -> self-review";
if (HasExplicitFolderIntent(userQuery))
return "folder_map -> glob/grep -> targeted read";
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
return "glob/grep -> document_read/file_read -> drafting tool";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasSemanticNavigationIntent(userQuery))
return "lsp_code_intel -> targeted file_read -> file_edit/file_write -> build_run/test_loop as needed";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
return "targeted file_read or grep/glob/lsp -> file_edit/file_write -> build_run/test_loop as needed";
if (state.Scope == ExplorationScope.TopicBased)
return "glob/grep -> targeted read -> next action";
return "glob/grep -> file_read/document_read -> next action";
} }
private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab) private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab)
@@ -32,6 +231,11 @@ public partial class AgentLoopService
var q = userQuery.Trim(); var q = userQuery.Trim();
var lower = q.ToLowerInvariant(); var lower = q.ToLowerInvariant();
// Cowork 탭에서 문서 생성 의도가 있으면 탐색을 건너뛰고 바로 생성 도구 사용
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasDocumentCreationIntent(lower))
return ExplorationScope.DirectCreation;
if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") || if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") ||
lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") || lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") ||
lower.Contains("아키텍처") || lower.Contains("전체 점검")) lower.Contains("아키텍처") || lower.Contains("전체 점검"))
@@ -52,14 +256,47 @@ public partial class AgentLoopService
: ExplorationScope.OpenEnded; : ExplorationScope.OpenEnded;
} }
/// <summary>
/// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다.
/// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true.
/// </summary>
private static bool HasDocumentCreationIntent(string lowerQuery)
{
// 생성 동사 키워드
var hasCreationVerb = ContainsAny(lowerQuery,
"작성해", "써줘", "써 줘", "만들어", "생성해", "작성 해",
"만들어줘", "만들어 줘", "생성해줘", "생성해 줘",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"리포트 써", "보고서 써", "문서 써",
"작성 부탁", "만들어 부탁");
if (!hasCreationVerb)
return false;
// 생성 대상이 문서/보고서/자료 등인지 확인
return ContainsAny(lowerQuery,
"보고서", "문서", "제안서", "리포트", "분석서", "기획서",
"report", "document", "proposal", "analysis",
"요약서", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word",
"표", "차트", "스프레드시트", "프레젠테이션",
"정리해", "정리 해");
}
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope) private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
{ {
var guidance = scope switch var guidance = scope switch
{ {
ExplorationScope.DirectCreation =>
"Exploration scope = direct-creation. The user wants to CREATE a new document/report/file. " +
"Do NOT search for existing files with glob/grep/folder_map — skip exploration entirely. " +
"Call document_plan first to outline the document structure, then immediately call the appropriate creation tool " +
"(docx_create, html_create, excel_create, markdown_create, etc.) to produce the actual file. " +
"The output MUST be a real file on disk, not a text response.",
ExplorationScope.Localized => ExplorationScope.Localized =>
"Exploration scope = localized. Start with grep/glob and targeted file reads. Avoid folder_map unless structure is unclear.", "Exploration scope = localized. Start with lsp_code_intel when the request is about definitions/references/implementations/call hierarchy; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asks for folder structure or file listing.",
ExplorationScope.TopicBased => ExplorationScope.TopicBased =>
"Exploration scope = topic-based. Identify candidate files by topic keywords first, then read only a small targeted set.", "Exploration scope = topic-based. Identify candidate files by topic keywords first with glob/grep, then read only a small targeted set.",
ExplorationScope.RepoWide => ExplorationScope.RepoWide =>
"Exploration scope = repo-wide. Broad structure inspection is allowed when needed.", "Exploration scope = repo-wide. Broad structure inspection is allowed when needed.",
_ => _ =>
@@ -93,6 +330,15 @@ public partial class AgentLoopService
string toolName, string toolName,
string argsJson) string argsJson)
{ {
// 문서 생성 모드: 탐색 도구가 1회라도 호출되면 즉시 교정
if (state.Scope == ExplorationScope.DirectCreation)
{
return string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase)
|| state.TotalFilesRead >= 1;
}
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded) if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return false; return false;
@@ -0,0 +1,185 @@
using System.IO;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static readonly HashSet<string> FolderMapRecoveryIgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
"packages", ".nuget", "TestResults", "coverage", ".next",
"target", ".gradle", ".cargo",
};
private string BuildToolResultFeedbackMessage(
ContentBlock call,
ToolResult result,
RunState runState,
AgentContext context,
List<ChatMessage> messages,
ExplorationTrackingState explorationState,
int iteration)
{
var message = BuildLoopToolResultMessage(call, result, runState);
if (!TryBuildFolderMapEmptyRecoveryMessage(
call,
result,
context,
messages,
explorationState,
iteration,
out var recoveryAppendix))
return message;
return $"{message}\n\n{recoveryAppendix}";
}
private bool TryBuildFolderMapEmptyRecoveryMessage(
ContentBlock call,
ToolResult result,
AgentContext context,
List<ChatMessage> messages,
ExplorationTrackingState explorationState,
int iteration,
out string recoveryAppendix)
{
recoveryAppendix = "";
if (!result.Success
|| !string.Equals(call.ToolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| !LooksLikeEmptyFolderMapResult(result.Output))
return false;
var baseDir = ResolveFolderMapTargetPath(call.ToolInput, context);
if (string.IsNullOrWhiteSpace(baseDir) || !Directory.Exists(baseDir))
return false;
var candidates = CollectFolderMapRecoveryCandidates(baseDir, context, maxResults: 6);
if (candidates.Count == 0)
return false;
var displayPath = Path.GetRelativePath(context.WorkFolder, baseDir).Replace('\\', '/');
if (string.IsNullOrWhiteSpace(displayPath) || displayPath == ".")
displayPath = ".";
var suggestedPatterns = candidates
.Select(path => Path.GetExtension(path))
.Where(ext => !string.IsNullOrWhiteSpace(ext))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2)
.Select(ext => $"**/*{ext}")
.ToList();
var candidateList = string.Join("\n", candidates.Select(path => $"- {path}"));
var patternHint = suggestedPatterns.Count > 0
? $"\nSuggested glob patterns:\n- {string.Join("\n- ", suggestedPatterns)}"
: "";
recoveryAppendix =
"[Recovery] folder_map returned 0 files and 0 dirs, but a fallback scan found candidate paths. " +
"Do not repeat folder_map for the same path. Switch to glob first, then read only the best candidates.\n" +
$"Fallback candidates under '{displayPath}':\n{candidateList}{patternHint}";
messages.Add(new ChatMessage
{
Role = "system",
Content =
"[System:FolderMapEmptyRecovery] folder_map returned an empty result, but direct file scanning found visible candidates.\n" +
$"Path: {displayPath}\n" +
"Do not call folder_map again for the same path unless the user explicitly asks for structure only.\n" +
"Use glob to narrow the set, then use file_read or document_read on the best matches.\n" +
$"Candidates:\n{candidateList}{patternHint}"
});
explorationState.CorrectiveHintInjected = true;
explorationState.BroadScanDetected = true;
explorationState.SelectiveHit = false;
WorkflowLogService.LogTransition(
_conversationId,
_currentRunId,
iteration,
"folder_map_empty_recovery",
$"{displayPath} -> {candidates.Count} candidates");
EmitEvent(
AgentEventType.Thinking,
"folder_map",
"빈 folder_map 결과를 복구하는 중 · 파일 후보로 다시 좁히는 중");
return true;
}
private static string ResolveFolderMapTargetPath(JsonElement? toolInput, AgentContext context)
{
if (toolInput is not { ValueKind: JsonValueKind.Object } input)
return context.WorkFolder;
var rawPath = input.SafeTryGetProperty("path", out var pathElement)
? pathElement.SafeGetString() ?? ""
: "";
return string.IsNullOrWhiteSpace(rawPath)
? context.WorkFolder
: FileReadTool.ResolvePath(rawPath, context.WorkFolder);
}
private static bool LooksLikeEmptyFolderMapResult(string? output)
{
if (string.IsNullOrWhiteSpace(output))
return false;
return output.Contains("0 files, 0 dirs", StringComparison.OrdinalIgnoreCase)
|| output.Contains("0 files, 0 directories", StringComparison.OrdinalIgnoreCase);
}
private static List<string> CollectFolderMapRecoveryCandidates(string baseDir, AgentContext context, int maxResults)
{
var candidates = new List<string>(maxResults);
var pending = new Stack<(string Path, int Depth)>();
pending.Push((baseDir, 0));
while (pending.Count > 0 && candidates.Count < maxResults)
{
var (current, depth) = pending.Pop();
try
{
foreach (var file in Directory.EnumerateFiles(current))
{
if (candidates.Count >= maxResults)
break;
var fileName = Path.GetFileName(file);
if (string.IsNullOrWhiteSpace(fileName)
|| fileName.StartsWith('.')
|| !context.IsPathAllowed(file))
continue;
candidates.Add(Path.GetRelativePath(context.WorkFolder, file).Replace('\\', '/'));
}
if (depth >= 3)
continue;
foreach (var dir in Directory.EnumerateDirectories(current))
{
var name = Path.GetFileName(dir);
if (string.IsNullOrWhiteSpace(name)
|| name.StartsWith('.')
|| FolderMapRecoveryIgnoredDirs.Contains(name)
|| !context.IsPathAllowed(dir))
continue;
pending.Push((dir, depth + 1));
}
}
catch
{
}
}
return candidates;
}
}
@@ -25,11 +25,11 @@ public partial class AgentLoopService
}; };
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary> /// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential) private static (List<ContentBlock> Parallel, List<ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls) ClassifyToolCalls(List<ContentBlock> calls)
{ {
var parallel = new List<LlmService.ContentBlock>(); var parallel = new List<ContentBlock>();
var sequential = new List<LlmService.ContentBlock>(); var sequential = new List<ContentBlock>();
var collectParallelPrefix = true; var collectParallelPrefix = true;
foreach (var call in calls) foreach (var call in calls)
@@ -81,7 +81,7 @@ public partial class AgentLoopService
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary> /// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync( private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls, List<ContentBlock> calls,
List<ChatMessage> messages, List<ChatMessage> messages,
AgentContext context, AgentContext context,
List<string> planSteps, List<string> planSteps,
@@ -100,13 +100,13 @@ public partial class AgentLoopService
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
var executableCalls = new List<LlmService.ContentBlock>(); var executableCalls = new List<ContentBlock>();
foreach (var call in calls) foreach (var call in calls)
{ {
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames); var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase) var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
? call ? call
: new LlmService.ContentBlock : new ContentBlock
{ {
Type = call.Type, Type = call.Type,
Text = call.Text, Text = call.Text,
@@ -0,0 +1,103 @@
using System.IO;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private sealed class PathAccessTrackingState
{
public string? LastPath { get; set; }
public int ConsecutiveCount { get; set; }
}
private bool TryHandleRepeatedPathAccessTransition(
ContentBlock call,
AgentContext context,
PathAccessTrackingState state,
List<ChatMessage> messages,
string? lastModifiedCodeFilePath,
bool requireHighImpactCodeVerification,
TaskTypePolicy taskPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
var currentPath = ExtractTrackedTargetPath(call, context);
var shouldTrack = !string.IsNullOrWhiteSpace(currentPath) && IsPathBoundReadOnlyInspectionTool(call.ToolName);
if (!shouldTrack)
{
if (ResetsRepeatedPathTracking(call.ToolName))
{
state.LastPath = null;
state.ConsecutiveCount = 0;
}
return false;
}
if (string.Equals(state.LastPath, currentPath, StringComparison.OrdinalIgnoreCase))
state.ConsecutiveCount++;
else
{
state.LastPath = currentPath;
state.ConsecutiveCount = 1;
}
if (state.ConsecutiveCount < 4)
return false;
messages.Add(new ChatMessage
{
Role = "user",
Content =
"[System:RepeatedPathGuard] 동일 경로를 읽기 도구로 반복 확인하고 있습니다.\n" +
$"반복 경로: {currentPath}\n" +
"지금은 같은 파일/경로를 다시 읽지 말고, 다음 우선순위로 전환하세요.\n" +
"1. grep/glob으로 관련 호출부, 참조 지점, 테스트 파일을 찾기\n" +
"2. git_tool(diff)로 실제 변경 범위를 확인하기\n" +
"3. 필요한 경우에만 file_edit 또는 build_run/test_loop로 다음 단계 진행하기\n" +
"같은 경로에 대한 file_read/document_read/grep 반복은 중단하세요."
});
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"같은 경로 반복 접근을 감지해 흐름을 전환합니다 · {Path.GetFileName(currentPath)}");
state.ConsecutiveCount = 0;
state.LastPath = null;
return true;
}
private static bool IsPathBoundReadOnlyInspectionTool(string toolName)
=> toolName is "file_read" or "document_read" or "grep" or "glob";
private static bool ResetsRepeatedPathTracking(string toolName)
=> toolName is "file_edit" or "file_write" or "file_manage" or "git_tool" or "build_run" or "test_loop" or "folder_map";
private static string? ExtractTrackedTargetPath(ContentBlock call, AgentContext context)
{
if (call.ToolInput is not { ValueKind: JsonValueKind.Object } input)
return null;
var path = input.SafeTryGetProperty("path", out var pathProp)
? pathProp.SafeGetString()
: null;
if (string.IsNullOrWhiteSpace(path)
&& input.SafeTryGetProperty("project_path", out var projectPathProp))
path = projectPathProp.SafeGetString();
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
return FileReadTool.ResolvePath(path, context.WorkFolder);
}
catch
{
return path;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private void ApplyDocumentPlanSuccessTransitions( private void ApplyDocumentPlanSuccessTransitions(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
ref bool documentPlanCalled, ref bool documentPlanCalled,
@@ -28,16 +28,7 @@ public partial class AgentLoopService
return; return;
var toolHint = ResolveDocumentPlanFollowUpTool(po); var toolHint = ResolveDocumentPlanFollowUpTool(po);
messages.Add(new ChatMessage EmitEvent(AgentEventType.Thinking, "", $"Document structure ready · next creation candidate {toolHint}");
{
Role = "user",
Content =
"document_plan이 완료되었습니다. " +
"방금 생성된 골격의 [내용...] 자리와 각 섹션 내용을 실제 상세 본문으로 모두 채운 뒤 " +
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
"설명만 하지 말고 실제 문서 생성 도구 호출로 바로 이어가세요."
});
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완료 · {toolHint} 실행 유도");
} }
private static string? ExtractDocumentPlanScaffold(string output) private static string? ExtractDocumentPlanScaffold(string output)
@@ -47,7 +38,6 @@ public partial class AgentLoopService
var markers = new (string Start, string End)[] var markers = new (string Start, string End)[]
{ {
("--- body 시작 ---", "--- body 끝 ---"),
("--- body start ---", "--- body end ---"), ("--- body start ---", "--- body end ---"),
("<!-- body start marker -->", "<!-- body end marker -->"), ("<!-- body start marker -->", "<!-- body end marker -->"),
}; };
@@ -76,10 +66,10 @@ public partial class AgentLoopService
if (string.IsNullOrWhiteSpace(output)) if (string.IsNullOrWhiteSpace(output))
return false; return false;
return output.Contains("즉시 실행", StringComparison.OrdinalIgnoreCase) return output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase)
|| output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call html_create", StringComparison.OrdinalIgnoreCase) || output.Contains("call html_create", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase); || output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase)
|| output.Contains("call docx_create", StringComparison.OrdinalIgnoreCase);
} }
private static string ResolveDocumentPlanFollowUpTool(string output) private static string ResolveDocumentPlanFollowUpTool(string output)
@@ -96,9 +86,9 @@ public partial class AgentLoopService
} }
private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync( private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<LlmService.ContentBlock> toolCalls, List<ContentBlock> toolCalls,
List<ChatMessage> messages, List<ChatMessage> messages,
Models.LlmSettings llm, Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
@@ -109,10 +99,11 @@ public partial class AgentLoopService
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1) if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false); return (false, false);
// document_plan 없이 바로 문서 도구가 호출된 경우 — 아직 LLM이 추가 반복을 할 수 있음. if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
// 한 번에 생성된 문서는 내용이 부실할 수 있으므로 조기 종료하지 않고 LLM에 판단을 맡긴다. {
if (!_docFallbackAttempted && !documentPlanWasCalled) EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (false, false); return (true, false);
}
var verificationEnabled = executionPolicy.EnablePostToolVerification var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
@@ -134,7 +125,7 @@ public partial class AgentLoopService
} }
private async Task<bool> TryApplyPostToolVerificationTransitionAsync( private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
Models.LlmSettings llm, Models.LlmSettings llm,
@@ -156,6 +147,16 @@ public partial class AgentLoopService
if (!shouldVerify) if (!shouldVerify)
return false; return false;
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages);
var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages);
if (!highImpactCodeChange || (hasDiffEvidence && hasRecentBuildOrTestEvidence))
return false;
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
return true; return true;
} }
@@ -179,7 +179,7 @@ public partial class AgentLoopService
return sawDiff; return sawDiff;
} }
// 蹂€寃??꾧뎄 ?먯껜媛€ ?놁쑝硫?diff 寃뚯씠???꾧뎄?몄? ?뺤씤 // 변경 도구 자체가 없으면 diff 게이트 도구인지 확인
return true; return true;
} }
@@ -198,8 +198,8 @@ public partial class AgentLoopService
"insertions", "insertions",
"deletions", "deletions",
"file changed", "file changed",
"異붽?", "추가",
"?낅뜲?댄듃"); "업데이트");
} }
private bool TryApplyDocumentArtifactGateTransition( private bool TryApplyDocumentArtifactGateTransition(
@@ -300,7 +300,7 @@ public partial class AgentLoopService
}; };
var verificationTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase) var verificationTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
"file_read", "document_read", "document_review" "file_read", "document_read"
}; };
var sawVerification = false; var sawVerification = false;
@@ -392,9 +392,9 @@ public partial class AgentLoopService
{ {
var content = m.Content ?? ""; var content = m.Content ?? "";
if (content.StartsWith("{\"_tool_use_blocks\"")) if (content.StartsWith("{\"_tool_use_blocks\""))
content = "[?꾧뎄 ?몄텧 ?붿빟]"; content = "[도구 실행 요약]";
else if (content.StartsWith("{\"type\":\"tool_result\"")) else if (content.StartsWith("{\"type\":\"tool_result\""))
content = "[?꾧뎄 ?ㅽ뻾 寃곌낵 ?붿빟]"; content = "[도구 실행 결과 요약]";
else if (content.Length > 180) else if (content.Length > 180)
content = content[..180] + "..."; content = content[..180] + "...";
return $"- [{m.Role}] {content}"; return $"- [{m.Role}] {content}";
@@ -403,7 +403,7 @@ public partial class AgentLoopService
var summary = summaryLines.Count > 0 var summary = summaryLines.Count > 0
? string.Join("\n", summaryLines) ? string.Join("\n", summaryLines)
: "- ?댁쟾 ?€??留λ씫???먮룞 異뺤빟?섏뿀?듬땲??"; : "- 이전 대화 맥락이 자동 축약되었습니다";
var tail = nonSystem.Skip(Math.Max(0, nonSystem.Count - keepTailCount)).ToList(); var tail = nonSystem.Skip(Math.Max(0, nonSystem.Count - keepTailCount)).ToList();
@@ -414,14 +414,14 @@ public partial class AgentLoopService
{ {
Role = "user", Role = "user",
Timestamp = DateTime.Now, Timestamp = DateTime.Now,
Content = $"[?쒖뒪???먮룞 留λ씫 異뺤빟]\n?꾨옒???댁쟾 ?€?붿쓽 ?듭떖 ?붿빟?낅땲??\n{summary}" Content = $"[시스템 자동 맥락 축약]\n아래는 이전 대화의 축소 요약입니다.\n{summary}"
}); });
messages.AddRange(tail); messages.AddRange(tail);
return true; return true;
} }
private bool TryHandleRepeatedFailureGuardTransition( private bool TryHandleRepeatedFailureGuardTransition(
LlmService.ContentBlock call, ContentBlock call,
string toolCallSignature, string toolCallSignature,
List<ChatMessage> messages, List<ChatMessage> messages,
string? lastFailedToolSignature, string? lastFailedToolSignature,
@@ -458,12 +458,12 @@ public partial class AgentLoopService
EmitEvent( EmitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
call.ToolName, call.ToolName,
$"?숈씪 ?꾧뎄/?뚮씪誘명꽣 諛섎났 ?ㅽ뙣 ?⑦꽩 媛먯? - ?ㅻⅨ ?묎렐?쇰줈 ?꾪솚?⑸땲??({repeatedFailedToolSignatureCount}/{maxRetry})"); $"동일 도구/파라미터 반복 실패 패턴 감지 - 다른 접근으로 전환합니다({repeatedFailedToolSignatureCount}/{maxRetry})");
return true; return true;
} }
private bool TryHandleNoProgressReadOnlyLoopTransition( private bool TryHandleNoProgressReadOnlyLoopTransition(
LlmService.ContentBlock call, ContentBlock call,
string toolCallSignature, string toolCallSignature,
int repeatedSameSignatureCount, int repeatedSameSignatureCount,
List<ChatMessage> messages, List<ChatMessage> messages,
@@ -479,7 +479,7 @@ public partial class AgentLoopService
messages.Add(LlmService.CreateToolResultMessage( messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolId,
call.ToolName, call.ToolName,
$"[NO_PROGRESS_LOOP_GUARD] ?숈씪???쎄린 ?꾧뎄 ?몄텧??{repeatedSameSignatureCount}??諛섎났?섏뿀?듬땲?? {toolCallSignature}\n" + $"[NO_PROGRESS_LOOP_GUARD] 동일한 읽기 도구 실행이 {repeatedSameSignatureCount}회 반복되었습니다. {toolCallSignature}\n" +
"Stop repeating the same read-only call and switch to a concrete next action.")); "Stop repeating the same read-only call and switch to a concrete next action."));
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
@@ -493,7 +493,7 @@ public partial class AgentLoopService
EmitEvent( EmitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
call.ToolName, call.ToolName,
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽媛€ 媛먯??섏뼱 ?ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??"); $"무의미한 읽기 도구 반복 루프가 감지되어 다른 방향으로 전환합니다({repeatedSameSignatureCount}회)");
return true; return true;
} }
@@ -520,7 +520,7 @@ public partial class AgentLoopService
EmitEvent( EmitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
"", "",
$"?쎄린 ?꾩슜 ?꾧뎄媛€ ?곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜媛€ 媛먯??섏뿀?듬땲?? ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲??(threshold={threshold})"); $"읽기 전용 도구가 연속 {consecutiveReadOnlySuccessTools}회 실행되어 정체가 감지되었습니다. 실행 전략으로 전환합니다(threshold={threshold})");
return true; return true;
} }
@@ -554,7 +554,7 @@ public partial class AgentLoopService
EmitEvent( EmitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
"", "",
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎濡??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)"); $"실행 진전이 없어 강제 복구 전략으로 시작합니다({runState.NoProgressRecoveryRetry}/2)");
return true; return true;
} }
@@ -629,11 +629,11 @@ public partial class AgentLoopService
"forbidden", "forbidden",
"not found", "not found",
"invalid", "invalid",
"?ㅽ뙣", "실패",
"?ㅻ쪟", "오류",
"?덉쇅", "예외",
"?쒓컙 珥덇낵", "시간 초과",
"沅뚰븳 嫄곕?", "권한 거부",
"李⑤떒", "李⑤떒",
"찾을 수"); "찾을 수");
} }
@@ -911,22 +911,22 @@ public partial class AgentLoopService
} }
} }
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync( private async Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block, ContentBlock block,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
AgentContext context, AgentContext context,
CancellationToken ct) CancellationToken ct)
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct); => await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct);
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync( private async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct, CancellationToken ct,
string phaseLabel, string phaseLabel,
RunState? runState = null, RunState? runState = null,
bool forceToolCall = false, bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null, Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null) Func<ToolStreamEvent, Task>? onStreamEventAsync = null)
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync( => await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
messages, messages,
tools, tools,
@@ -938,7 +938,7 @@ public partial class AgentLoopService
onStreamEventAsync); onStreamEventAsync);
private void ApplyToolPostExecutionBookkeeping( private void ApplyToolPostExecutionBookkeeping(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
TokenUsage? tokenUsage, TokenUsage? tokenUsage,
Models.LlmSettings llm, Models.LlmSettings llm,
@@ -986,7 +986,7 @@ public partial class AgentLoopService
} }
private bool TryHandleToolFailureTransition( private bool TryHandleToolFailureTransition(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
AgentContext context, AgentContext context,
TaskTypePolicy taskPolicy, TaskTypePolicy taskPolicy,
@@ -1052,7 +1052,7 @@ public partial class AgentLoopService
result.Output) result.Output)
}); });
} }
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: ?ㅽ뙣 遺꾩꽍 ???ъ떆??({consecutiveErrors}/{maxRetry})"); EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도({consecutiveErrors}/{maxRetry})");
return true; return true;
} }
@@ -1120,7 +1120,7 @@ public partial class AgentLoopService
} }
private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync( private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync(
LlmService.ContentBlock call, ContentBlock call,
AgentContext context, AgentContext context,
List<ChatMessage> messages) List<ChatMessage> messages)
{ {
@@ -1139,7 +1139,7 @@ public partial class AgentLoopService
if (devShouldContinue) if (devShouldContinue)
{ {
messages.Add(LlmService.CreateToolResultMessage( messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 媛쒕컻?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??")); call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 개발자에 의해 도구 실행이 건너뛰어졌습니다"));
return (true, null); return (true, null);
} }
} }
@@ -1160,7 +1160,7 @@ public partial class AgentLoopService
if (scopeShouldContinue) if (scopeShouldContinue)
{ {
messages.Add(LlmService.CreateToolResultMessage( messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 媛쒕컻?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??")); call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자에 의해 작업이 건너뛰어졌습니다"));
return (true, null); return (true, null);
} }
} }
@@ -1193,4 +1193,3 @@ public partial class AgentLoopService
} }
@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private void ApplyCodeQualityFollowUpTransition( private void ApplyCodeQualityFollowUpTransition(
LlmService.ContentBlock call, ContentBlock call,
ToolResult result, ToolResult result,
List<ChatMessage> messages, List<ChatMessage> messages,
TaskTypePolicy taskPolicy, TaskTypePolicy taskPolicy,
@@ -13,9 +13,9 @@ public partial class AgentLoopService
ref string? lastModifiedCodeFilePath) ref string? lastModifiedCodeFilePath)
{ {
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);
if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result)) requireHighImpactCodeVerification = highImpactCodeChange;
if (highImpactCodeChange && ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result))
{ {
requireHighImpactCodeVerification = highImpactCodeChange;
lastModifiedCodeFilePath = result.FilePath; lastModifiedCodeFilePath = result.FilePath;
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
@@ -52,8 +52,14 @@ public partial class AgentLoopService
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages, messages,
requireHighImpactCodeVerification); requireHighImpactCodeVerification);
var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages);
var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages);
var hasSuccessfulBuildAndTestEvidence = HasSuccessfulBuildAndTestAfterLastModification(messages);
var hasLightweightCompletionEvidence = requireHighImpactCodeVerification
? hasCodeVerificationEvidence
: hasDiffEvidence || hasRecentBuildOrTestEvidence || hasCodeVerificationEvidence;
if (executionPolicy.CodeVerificationGateMaxRetries > 0 if (executionPolicy.CodeVerificationGateMaxRetries > 0
&& !hasCodeVerificationEvidence && !(requireHighImpactCodeVerification ? hasCodeVerificationEvidence : hasLightweightCompletionEvidence)
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries) && runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
{ {
runState.CodeVerificationGateRetry++; runState.CodeVerificationGateRetry++;
@@ -89,7 +95,13 @@ public partial class AgentLoopService
return true; return true;
} }
var hasBlockingCodeEvidenceGap = !(requireHighImpactCodeVerification ? hasCodeVerificationEvidence : hasLightweightCompletionEvidence)
|| (requireHighImpactCodeVerification && !hasSuccessfulBuildAndTestEvidence);
var shouldRequestStructuredFinalReport =
taskPolicy.IsReviewTask || requireHighImpactCodeVerification;
if (executionPolicy.FinalReportGateMaxRetries > 0 if (executionPolicy.FinalReportGateMaxRetries > 0
&& shouldRequestStructuredFinalReport
&& !hasBlockingCodeEvidenceGap
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages) && !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries) && runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
{ {
@@ -111,16 +123,20 @@ public partial class AgentLoopService
private bool TryApplyCodeDiffEvidenceGateTransition( private bool TryApplyCodeDiffEvidenceGateTransition(
List<ChatMessage> messages, List<ChatMessage> messages,
string? textResponse, string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState, RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{ {
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false; return false;
if (!taskPolicy.IsReviewTask)
return false;
if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries) if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries)
return false; return false;
if (HasDiffEvidenceAfterLastModification(messages)) if (HasDiffEvidenceAfterLastModification(messages) || HasBuildOrTestEvidenceAfterLastModification(messages))
return false; return false;
runState.CodeDiffGateRetry++; runState.CodeDiffGateRetry++;
@@ -145,9 +161,15 @@ public partial class AgentLoopService
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false; return false;
if (!taskPolicy.IsReviewTask)
return false;
if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries) if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries)
return false; return false;
if (HasDiffEvidenceAfterLastModification(messages))
return false;
if (!HasAnyBuildOrTestEvidence(messages)) if (!HasAnyBuildOrTestEvidence(messages))
return false; return false;
@@ -176,9 +198,15 @@ public partial class AgentLoopService
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false; return false;
if (!taskPolicy.IsReviewTask)
return false;
if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries) if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries)
return false; return false;
if (HasDiffEvidenceAfterLastModification(messages))
return false;
if (!HasAnyBuildOrTestAttempt(messages)) if (!HasAnyBuildOrTestAttempt(messages))
return false; return false;
@@ -4,11 +4,11 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private static (bool ShouldRun, List<LlmService.ContentBlock> ParallelBatch, List<LlmService.ContentBlock> SequentialBatch) private static (bool ShouldRun, List<ContentBlock> ParallelBatch, List<ContentBlock> SequentialBatch)
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls, int maxParallelBatch) CreateParallelExecutionPlan(bool parallelEnabled, List<ContentBlock> toolCalls, int maxParallelBatch)
{ {
if (!parallelEnabled || toolCalls.Count <= 1) if (!parallelEnabled || toolCalls.Count <= 1)
return (false, new List<LlmService.ContentBlock>(), toolCalls); return (false, new List<ContentBlock>(), toolCalls);
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls); var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch) if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch)
@@ -0,0 +1,122 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다.
/// </summary>
internal static class AgentMessageInvariantHelper
{
public static int AdjustStartIndexForToolPairs(
IReadOnlyList<ChatMessage> messages,
int startIndex,
out int preservedToolPairs)
{
preservedToolPairs = 0;
if (startIndex <= 0)
return startIndex;
var toolResultIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < messages.Count; i++)
{
if (TryGetToolResultId(messages[i], out var toolResultId))
toolResultIds.Add(toolResultId);
}
if (toolResultIds.Count == 0)
return startIndex;
var toolUseIdsInWindow = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < messages.Count; i++)
{
foreach (var toolUseId in EnumerateToolUseIds(messages[i]))
toolUseIdsInWindow.Add(toolUseId);
}
toolResultIds.ExceptWith(toolUseIdsInWindow);
if (toolResultIds.Count == 0)
return startIndex;
var adjustedStart = startIndex;
for (var i = startIndex - 1; i >= 0 && toolResultIds.Count > 0; i--)
{
var foundAny = false;
foreach (var toolUseId in EnumerateToolUseIds(messages[i]))
{
if (!toolResultIds.Remove(toolUseId))
continue;
preservedToolPairs++;
foundAny = true;
}
if (foundAny)
adjustedStart = i;
}
return adjustedStart;
}
public static bool TryGetToolResultId(ChatMessage message, out string toolResultId)
{
toolResultId = "";
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
return false;
var content = message.Content ?? "";
if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
return false;
try
{
using var doc = JsonDocument.Parse(content);
toolResultId = doc.RootElement.TryGetProperty("tool_use_id", out var idEl)
? idEl.GetString() ?? ""
: "";
return !string.IsNullOrWhiteSpace(toolResultId);
}
catch
{
return false;
}
}
public static IEnumerable<string> EnumerateToolUseIds(ChatMessage message)
{
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
yield break;
var content = message.Content ?? "";
if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal))
yield break;
JsonDocument? doc = null;
try
{
doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array)
yield break;
foreach (var block in blocksEl.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl)
|| !string.Equals(typeEl.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (block.TryGetProperty("id", out var idEl))
{
var toolUseId = idEl.GetString();
if (!string.IsNullOrWhiteSpace(toolUseId))
yield return toolUseId!;
}
}
}
finally
{
doc?.Dispose();
}
}
}
@@ -0,0 +1,235 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public sealed class AgentQueryContextWindowResult
{
public required List<ChatMessage> Messages { get; init; }
public int SourceMessageCount { get; init; }
public int ViewMessageCount { get; init; }
public int WindowStartIndex { get; init; }
public bool BoundaryApplied { get; init; }
public bool ToolPairExpanded { get; init; }
public int PreservedToolPairCount { get; init; }
public int TruncatedToolResultCount { get; init; }
public int TokensBeforeBudget { get; init; }
public int TokensAfterBudget { get; init; }
}
/// <summary>
/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다.
/// </summary>
public static class AgentQueryContextBuilder
{
private const int ProtectedRecentNonSystemMessages = 8;
private const string PostCompactContextMetaKind = "post_compact_context";
public static AgentQueryContextWindowResult Build(IReadOnlyList<ChatMessage> sourceMessages)
{
if (sourceMessages.Count == 0)
{
return new AgentQueryContextWindowResult
{
Messages = new List<ChatMessage>(),
SourceMessageCount = 0,
ViewMessageCount = 0,
WindowStartIndex = 0,
BoundaryApplied = false,
ToolPairExpanded = false,
PreservedToolPairCount = 0,
TruncatedToolResultCount = 0,
TokensBeforeBudget = 0,
TokensAfterBudget = 0,
};
}
var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied);
var adjustedStartIndex = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
sourceMessages,
startIndex,
out var preservedToolPairs);
var toolPairExpanded = adjustedStartIndex < startIndex;
var windowMessages = new List<ChatMessage>(sourceMessages.Count);
for (var i = 0; i < sourceMessages.Count; i++)
{
var include = string.Equals(sourceMessages[i].Role, "system", StringComparison.OrdinalIgnoreCase)
|| i >= adjustedStartIndex;
if (!include)
continue;
windowMessages.Add(CloneMessage(sourceMessages[i]));
}
if (boundaryApplied)
InjectPostCompactContextMessage(windowMessages);
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages);
var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
return new AgentQueryContextWindowResult
{
Messages = windowMessages,
SourceMessageCount = sourceMessages.Count,
ViewMessageCount = windowMessages.Count,
WindowStartIndex = adjustedStartIndex,
BoundaryApplied = boundaryApplied,
ToolPairExpanded = toolPairExpanded,
PreservedToolPairCount = preservedToolPairs,
TruncatedToolResultCount = budgetResult.TruncatedCount,
TokensBeforeBudget = tokensBeforeBudget,
TokensAfterBudget = tokensAfterBudget,
};
}
private static int FindWindowStartIndex(IReadOnlyList<ChatMessage> sourceMessages, out bool boundaryApplied)
{
for (var i = sourceMessages.Count - 1; i >= 0; i--)
{
if (IsQueryBoundaryMarker(sourceMessages[i]))
{
boundaryApplied = true;
return i;
}
}
boundaryApplied = false;
return 0;
}
private static bool IsQueryBoundaryMarker(ChatMessage message)
{
var metaKind = message.MetaKind ?? "";
if (metaKind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| metaKind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| metaKind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var content = message.Content ?? "";
return content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
}
private static ChatMessage CloneMessage(ChatMessage source, string? contentOverride = null)
{
return new ChatMessage
{
MsgId = source.MsgId,
Role = source.Role,
Content = contentOverride ?? source.Content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
ResponseElapsedMs = source.ResponseElapsedMs,
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,
MimeType = image.MimeType,
FileName = image.FileName,
}).ToList(),
};
}
private static void InjectPostCompactContextMessage(List<ChatMessage> messages)
{
if (messages.Count == 0)
return;
if (messages.Any(m => string.Equals(m.MetaKind, PostCompactContextMetaKind, StringComparison.OrdinalIgnoreCase)))
return;
var attachedFiles = messages
.SelectMany(m => m.AttachedFiles ?? Enumerable.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
var imageCount = messages.Sum(m => m.Images?.Count ?? 0);
var compactSummaryCount = messages.Count(m =>
string.Equals(m.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase));
var branchContextCount = messages.Count(m =>
string.Equals(m.MetaKind, "branch_context", StringComparison.OrdinalIgnoreCase));
var structuredToolHistoryCount = messages.Count(m =>
{
var content = m.Content ?? "";
return content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal);
});
var recentToolNames = messages
.Where(m => m.Role == "user")
.Select(m => TryExtractToolResultToolName(m, out var toolName) ? toolName : "")
.Where(toolName => !string.IsNullOrWhiteSpace(toolName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(4)
.ToList();
if (attachedFiles.Count == 0
&& imageCount == 0
&& compactSummaryCount == 0
&& structuredToolHistoryCount == 0
&& branchContextCount == 0
&& recentToolNames.Count == 0)
return;
var lines = new List<string> { "[post-compact context]" };
if (compactSummaryCount > 0)
lines.Add($"restored compact summaries: {compactSummaryCount}");
if (branchContextCount > 0)
lines.Add($"restored branch context: {branchContextCount}");
if (structuredToolHistoryCount > 0)
lines.Add($"restored tool history blocks: {structuredToolHistoryCount}");
if (recentToolNames.Count > 0)
lines.Add("restored recent tools: " + string.Join(", ", recentToolNames));
if (attachedFiles.Count > 0)
lines.Add("restored file refs: " + string.Join(", ", attachedFiles));
if (imageCount > 0)
lines.Add($"restored image refs: {imageCount}");
var insertIndex = 0;
while (insertIndex < messages.Count && string.Equals(messages[insertIndex].Role, "system", StringComparison.OrdinalIgnoreCase))
insertIndex++;
messages.Insert(insertIndex, new ChatMessage
{
Role = "system",
MetaKind = PostCompactContextMetaKind,
Content = string.Join("\n", lines),
Timestamp = DateTime.Now,
AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null,
});
}
private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
{
toolName = "";
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(message.Content)
|| !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(message.Content);
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
{
toolName = toolNameEl.GetString() ?? "";
return !string.IsNullOrWhiteSpace(toolName);
}
}
catch
{
}
return false;
}
}
@@ -0,0 +1,158 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public sealed class AgentToolResultBudgetResult
{
public int TruncatedCount { get; set; }
public int ProcessedCount { get; set; }
public int TotalCharsBefore { get; set; }
}
/// <summary>
/// 오래된 tool_result를 query view와 압축 단계에서 같은 기준으로 줄이기 위한 공용 helper.
/// </summary>
public static class AgentToolResultBudget
{
public const int DefaultSoftCharLimit = 900;
public const int DefaultAggregateBudgetChars = 7_500;
public static AgentToolResultBudgetResult Apply(
List<ChatMessage> messages,
int protectedRecentNonSystemMessages,
int softCharLimit = DefaultSoftCharLimit,
int aggregateBudgetChars = DefaultAggregateBudgetChars)
{
var result = new AgentToolResultBudgetResult();
var nonSystemIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
.Select(x => x.index)
.ToList();
if (nonSystemIndexes.Count <= protectedRecentNonSystemMessages)
return result;
var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - protectedRecentNonSystemMessages)];
var spentChars = 0;
for (var i = 0; i < protectedStart; i++)
{
var message = messages[i];
if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _))
continue;
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content))
continue;
result.ProcessedCount++;
result.TotalCharsBefore += content.Length;
spentChars += content.Length;
if (content.Length <= softCharLimit && spentChars <= aggregateBudgetChars)
continue;
var truncated = TruncateToolResultJson(content, softCharLimit);
if (string.Equals(truncated, content, StringComparison.Ordinal))
continue;
messages[i] = CloneMessage(message, truncated);
result.TruncatedCount++;
}
return result;
}
public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var toolType = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : "";
if (!string.Equals(toolType, "tool_result", StringComparison.Ordinal))
return json;
var toolUseId = root.TryGetProperty("tool_use_id", out var idEl) ? idEl.GetString() ?? "" : "";
var toolName = root.TryGetProperty("tool_name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var content = root.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
if (content.Length <= softCharLimit)
return json;
var keepHead = Math.Min(360, content.Length);
var keepTail = Math.Min(220, Math.Max(0, content.Length - keepHead));
var head = content[..keepHead];
var tail = keepTail > 0 ? content[^keepTail..] : "";
var compacted = head +
$"\n...[tool_result 축약: {content.Length:N0}자]...\n" +
tail;
return JsonSerializer.Serialize(new
{
type = "tool_result",
tool_use_id = toolUseId,
tool_name = toolName,
content = compacted
});
}
catch
{
if (json.Length <= softCharLimit)
return json;
var head = json[..Math.Min(softCharLimit, json.Length)];
return head + "...[tool_result 축약]";
}
}
public static string CreateClearedToolResultJson(string json, string clearedContent)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var toolType = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : "";
if (!string.Equals(toolType, "tool_result", StringComparison.Ordinal))
return json;
var toolUseId = root.TryGetProperty("tool_use_id", out var idEl) ? idEl.GetString() ?? "" : "";
var toolName = root.TryGetProperty("tool_name", out var nameEl) ? nameEl.GetString() ?? "" : "";
return JsonSerializer.Serialize(new
{
type = "tool_result",
tool_use_id = toolUseId,
tool_name = toolName,
content = clearedContent
});
}
catch
{
return clearedContent;
}
}
private static ChatMessage CloneMessage(ChatMessage source, string content)
{
return new ChatMessage
{
MsgId = source.MsgId,
Role = source.Role,
Content = content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
ResponseElapsedMs = source.ResponseElapsedMs,
PromptTokens = source.PromptTokens,
CompletionTokens = source.CompletionTokens,
AttachedFiles = source.AttachedFiles?.ToList(),
Images = source.Images?.Select(image => new ImageAttachment
{
Base64 = image.Base64,
MimeType = image.MimeType,
FileName = image.FileName,
}).ToList(),
};
}
}
@@ -1,4 +1,4 @@
using System; using System;
namespace AxCopilot.Services.Agent; namespace AxCopilot.Services.Agent;
@@ -125,7 +125,7 @@ internal static class AgentTranscriptDisplayCatalog
{ {
var summary = (evt.Summary ?? string.Empty).Trim(); var summary = (evt.Summary ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(summary)) if (!string.IsNullOrWhiteSpace(summary))
return summary; return StripNonBmpCharacters(summary);
return evt.Type switch return evt.Type switch
{ {
@@ -265,13 +265,16 @@ internal static class AgentTranscriptDisplayCatalog
if (resultPresentation != null) if (resultPresentation != null)
{ {
// ToolResult의 GroupKey를 선행 ToolCall과 동일한 activity 그룹으로 설정
// → ProcessFeed에서 ToolCall 카드를 ToolResult로 교체(머지)
var resultGroup = ResolveActivityGroup(toolName, summary);
return new AgentTranscriptRowPresentation( return new AgentTranscriptRowPresentation(
TranscriptRowKind.ToolResult, TranscriptRowKind.ToolResult,
"결과", "결과",
resultPresentation.Label, resultPresentation.Label,
resultPresentation.Description, resultPresentation.Description,
$"result:{resultPresentation.Kind}:{resultPresentation.StatusKind}", $"activity:{resultGroup}",
false, true,
resultPresentation.NeedsAttention); resultPresentation.NeedsAttention);
} }
@@ -420,4 +423,32 @@ internal static class AgentTranscriptDisplayCatalog
_ => "도구", _ => "도구",
}; };
} }
/// <summary>
/// WPF 기본 폰트(Segoe UI)에서 렌더링되지 않는 비-BMP 유니코드 문자(이모지 등)를 제거합니다.
/// LLM 응답에 이모지가 포함되면 깨져서 표시되는 문제를 방지합니다.
/// </summary>
public static string StripNonBmpCharacters(string text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
// Consolas / Segoe UI에서 렌더링 불가한 비-BMP 유니코드(이모지, 서로게이트 쌍) 제거
var sb = new System.Text.StringBuilder(text.Length);
for (int i = 0; i < text.Length; i++)
{
var c = text[i];
if (char.IsHighSurrogate(c))
{
if (i + 1 < text.Length && char.IsLowSurrogate(text[i + 1]))
i++;
continue;
}
if (char.IsLowSurrogate(c)) continue;
if (c >= 0x2600 && c <= 0x27BF) continue; // Misc symbols, Dingbats
if (c >= 0x2B50 && c <= 0x2B55) continue; // Additional symbols
if (c >= 0xFE00 && c <= 0xFE0F) continue; // Variation selectors
sb.Append(c);
}
return sb.ToString().Trim();
}
} }
@@ -133,7 +133,7 @@ public sealed class AxAgentExecutionEngine
int completionTokens = 0, int completionTokens = 0,
long? responseElapsedMs = null, long? responseElapsedMs = null,
string? metaRunId = null, string? metaRunId = null,
ChatStorageService? storage = null) IChatStorageService? storage = null)
{ {
var assistant = new ChatMessage var assistant = new ChatMessage
{ {
@@ -177,7 +177,7 @@ public sealed class AxAgentExecutionEngine
int completionTokens = 0, int completionTokens = 0,
long? responseElapsedMs = null, long? responseElapsedMs = null,
string? metaRunId = null, string? metaRunId = null,
ChatStorageService? storage = null) IChatStorageService? storage = null)
{ {
var normalized = NormalizeAssistantContentForUi(conversation, tab, content); var normalized = NormalizeAssistantContentForUi(conversation, tab, content);
if (tab is "Cowork" or "Code") if (tab is "Cowork" or "Code")
@@ -209,6 +209,10 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content)) if (!string.IsNullOrWhiteSpace(content))
return content; return content;
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
return BuildFallbackCompletionMessage(conversation, runTab); return BuildFallbackCompletionMessage(conversation, runTab);
} }
@@ -230,7 +234,7 @@ public sealed class AxAgentExecutionEngine
public SessionMutationResult AppendExecutionEvent( public SessionMutationResult AppendExecutionEvent(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,
@@ -247,7 +251,7 @@ public sealed class AxAgentExecutionEngine
public SessionMutationResult AppendAgentRun( public SessionMutationResult AppendAgentRun(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,
@@ -272,6 +276,10 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content)) if (!string.IsNullOrWhiteSpace(content))
return content; return content;
if (runTab is "Cowork" or "Code")
return TryBuildStrictExecutionCompletionMessage(conversation, runTab)
?? "(실행은 종료되었지만 최종 요약 응답이 비어 있습니다. 실행 로그를 확인하세요.)";
return BuildFallbackCompletionMessage(conversation, runTab); return BuildFallbackCompletionMessage(conversation, runTab);
} }
@@ -280,6 +288,9 @@ public sealed class AxAgentExecutionEngine
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다. /// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
/// </summary> /// </summary>
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab) private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
=> TryBuildEvidenceBackedCompletionMessage(conversation, runTab) ?? "(빈 응답)";
private static string? TryBuildEvidenceBackedCompletionMessage(ChatConversation conversation, string runTab)
{ {
static bool IsSignificantEventType(string t) static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase) => !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
@@ -290,7 +301,8 @@ public sealed class AxAgentExecutionEngine
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열) // 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
static bool IsInternalStatusSummary(string? summary) static bool IsInternalStatusSummary(string? summary)
{ {
if (string.IsNullOrWhiteSpace(summary)) return false; if (string.IsNullOrWhiteSpace(summary))
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase) return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
@@ -345,6 +357,68 @@ public sealed class AxAgentExecutionEngine
return completionLine ?? "(빈 응답)"; return completionLine ?? "(빈 응답)";
} }
private static string? TryBuildStrictExecutionCompletionMessage(ChatConversation conversation, string runTab)
{
static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
static bool IsInternalStatusSummary(string? summary)
{
if (string.IsNullOrWhiteSpace(summary))
return false;
return summary.StartsWith("LLM", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("[System:", StringComparison.OrdinalIgnoreCase);
}
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
"Code" => "코드 작업이 완료되었습니다.",
_ => null,
};
var artifactEvent = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type))
.OrderByDescending(evt => evt.Timestamp)
.FirstOrDefault();
if (artifactEvent != null)
{
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
? $"생성된 파일: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
return completionLine != null
? $"{completionLine}\n\n{fileLine}"
: fileLine;
}
var latestSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
&& IsSignificantEventType(evt.Type)
&& !IsInternalStatusSummary(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(latestSummary))
{
return completionLine != null
? $"{completionLine}\n\n{latestSummary}"
: latestSummary;
}
return null;
}
private static ChatMessage CloneMessage(ChatMessage source) private static ChatMessage CloneMessage(ChatMessage source)
{ {
return new ChatMessage return new ChatMessage
@@ -370,7 +444,7 @@ public sealed class AxAgentExecutionEngine
private static SessionMutationResult ApplyConversationMutation( private static SessionMutationResult ApplyConversationMutation(
ChatSessionStateService session, ChatSessionStateService session,
ChatStorageService storage, IChatStorageService storage,
ChatConversation? activeConversation, ChatConversation? activeConversation,
string activeTab, string activeTab,
string targetTab, string targetTab,
+4 -1
View File
@@ -100,15 +100,18 @@ public class BuildRunTool : IAgentTool
} }
else else
{ {
// "run" 액션은 빌드로 대체 — 프로그램 실행은 사용자가 직접 수행
command = action switch command = action switch
{ {
"build" => project.BuildCommand, "build" => project.BuildCommand,
"test" => project.TestCommand, "test" => project.TestCommand,
"run" => project.RunCommand, "run" => project.BuildCommand, // run → build로 대체 (실행은 사용자 몫)
"lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand, "lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand,
"format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand, "format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand,
_ => project.BuildCommand, _ => project.BuildCommand,
}; };
if (action == "run")
action = "build"; // 출력 메시지에도 build로 표시
if (command == null) if (command == null)
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다."); return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
} }
+206 -98
View File
@@ -40,8 +40,27 @@ public static class ContextCondenser
/// <summary>요약 시 유지할 최근 메시지 수</summary> /// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6; private const int RecentKeepCount = 6;
private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000;
private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다.";
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</summary> private readonly record struct TimeBasedToolResultCompactionConfig(bool Enabled, int GapThresholdMinutes, int KeepRecentToolResults);
private sealed class CompactionWindow
{
public ChatMessage? SystemMessage { get; init; }
public required List<ChatMessage> OldMessages { get; init; }
public required List<ChatMessage> RecentMessages { get; init; }
public int PreservedToolPairs { get; init; }
}
private sealed class CompactionAttachmentSummary
{
public required List<string> AttachedFiles { get; init; }
public int ImageCount { get; init; }
}
/// <summary>모델별 입력 토큰 한도 (대략).</summary>
private static int GetModelInputLimit(string service, string model) private static int GetModelInputLimit(string service, string model)
{ {
var key = $"{service}:{model}".ToLowerInvariant(); var key = $"{service}:{model}".ToLowerInvariant();
@@ -59,6 +78,13 @@ public static class ContextCondenser
}; };
} }
private static int GetEffectiveContextWindowSize(string service, string model, int configuredLimit)
{
var contextWindow = configuredLimit > 0 ? configuredLimit : GetModelInputLimit(service, model);
var reservedForSummary = Math.Min(SummaryReserveTokens, Math.Max(4_000, contextWindow / 8));
return Math.Max(8_000, contextWindow - reservedForSummary);
}
/// <summary> /// <summary>
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다. /// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음) /// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
@@ -67,7 +93,7 @@ public static class ContextCondenser
/// </summary> /// </summary>
public static async Task<bool> CondenseIfNeededAsync( public static async Task<bool> CondenseIfNeededAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
LlmService llm, ILlmService llm,
int maxOutputTokens, int maxOutputTokens,
bool proactiveEnabled = true, bool proactiveEnabled = true,
int triggerPercent = 80, int triggerPercent = 80,
@@ -80,7 +106,7 @@ public static class ContextCondenser
public static async Task<ContextCompactionResult> CondenseWithStatsAsync( public static async Task<ContextCompactionResult> CondenseWithStatsAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
LlmService llm, ILlmService llm,
int maxOutputTokens, int maxOutputTokens,
bool proactiveEnabled = true, bool proactiveEnabled = true,
int triggerPercent = 80, int triggerPercent = 80,
@@ -94,15 +120,25 @@ public static class ContextCondenser
// 현재 모델의 입력 토큰 한도 // 현재 모델의 입력 토큰 한도
var settings = llm.GetCurrentModelInfo(); var settings = llm.GetCurrentModelInfo();
// 사용자가 설정한 컨텍스트 크기를 우선 사용. 미설정 시 모델별 기본값 적용. // 사용자가 설정한 컨텍스트 크기를 우선 사용. 미설정 시 모델별 기본값 적용.
var inputLimit = GetModelInputLimit(settings.service, settings.model); var effectiveWindow = GetEffectiveContextWindowSize(settings.service, settings.model, maxOutputTokens);
var effectiveMax = maxOutputTokens > 0 ? maxOutputTokens : inputLimit;
var percent = Math.Clamp(triggerPercent, 50, 95); var percent = Math.Clamp(triggerPercent, 50, 95);
var threshold = (int)(effectiveMax * (percent / 100.0)); // 설정 임계치에서 압축 시작 var percentThreshold = (int)(effectiveWindow * (percent / 100.0));
var bufferedThreshold = Math.Max(4_000, effectiveWindow - AutoCompactBufferTokens);
var threshold = Math.Min(percentThreshold, bufferedThreshold);
var currentTokens = TokenEstimator.EstimateMessages(messages); var currentTokens = TokenEstimator.EstimateMessages(messages);
result.BeforeTokens = currentTokens; result.BeforeTokens = currentTokens;
var timeBasedConfig = GetTimeBasedToolResultCompactionConfig(settings.service, settings.model);
if (ApplyTimeBasedToolResultCompaction(messages, timeBasedConfig, out var clearedToolResults))
{
result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})");
currentTokens = TokenEstimator.EstimateMessages(messages);
}
if (!force && currentTokens < threshold) if (!force && currentTokens < threshold)
{ {
result.Changed = result.AppliedStages.Count > 0;
result.AfterTokens = currentTokens; result.AfterTokens = currentTokens;
return result; return result;
} }
@@ -202,15 +238,106 @@ public static class ContextCondenser
return result; return result;
} }
private static CompactionWindow SplitCompactionWindow(List<ChatMessage> messages, int extraTrailingProtection)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
var trailingCount = Math.Min(RecentKeepCount + Math.Max(0, extraTrailingProtection), nonSystemMessages.Count);
var splitStart = Math.Max(0, nonSystemMessages.Count - trailingCount);
var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
nonSystemMessages,
splitStart,
out var preservedToolPairs);
return new CompactionWindow
{
SystemMessage = systemMsg,
OldMessages = nonSystemMessages.Take(adjustedStart).ToList(),
RecentMessages = nonSystemMessages.Skip(adjustedStart).ToList(),
PreservedToolPairs = preservedToolPairs,
};
}
private static TimeBasedToolResultCompactionConfig GetTimeBasedToolResultCompactionConfig(string service, string model)
{
var key = $"{service}:{model}".ToLowerInvariant();
return key switch
{
_ when key.Contains(string.Concat("cl", "aude")) => new TimeBasedToolResultCompactionConfig(true, 60, 5),
_ when key.Contains("gemini") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
_ when key.Contains("gpt-4") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
_ when key.Contains("deepseek") => new TimeBasedToolResultCompactionConfig(true, 30, 2),
_ when key.Contains("qwen") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ when key.Contains("llama") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ when key.Contains("vllm") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ => new TimeBasedToolResultCompactionConfig(true, 30, 2),
};
}
private static bool ApplyTimeBasedToolResultCompaction(
List<ChatMessage> messages,
TimeBasedToolResultCompactionConfig config,
out int clearedCount)
{
clearedCount = 0;
if (!config.Enabled)
return false;
var lastAssistant = messages
.Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.Timestamp)
.FirstOrDefault();
if (lastAssistant?.Timestamp is not DateTime lastAssistantAt)
return false;
var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes;
if (!double.IsFinite(gapMinutes) || gapMinutes < config.GapThresholdMinutes)
return false;
var candidateIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => AgentMessageInvariantHelper.TryGetToolResultId(x.message, out _))
.Select(x => x.index)
.ToList();
if (candidateIndexes.Count <= config.KeepRecentToolResults)
return false;
var keepSet = candidateIndexes
.TakeLast(Math.Max(1, config.KeepRecentToolResults))
.ToHashSet();
foreach (var index in candidateIndexes)
{
if (keepSet.Contains(index))
continue;
var message = messages[index];
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content))
continue;
var cleared = AgentToolResultBudget.CreateClearedToolResultJson(content, TimeBasedClearedToolResultMessage);
if (string.Equals(cleared, content, StringComparison.Ordinal))
continue;
messages[index] = CloneWithContent(message, cleared);
clearedCount++;
}
return clearedCount > 0;
}
/// <summary> /// <summary>
/// 1단계: 대용량 도구 결과를 축약합니다. /// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고,
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다. /// 그 외 긴 assistant/user 메시지는 경량 절단합니다.
/// </summary> /// </summary>
private static bool TruncateToolResults(List<ChatMessage> messages) private static bool TruncateToolResults(List<ChatMessage> messages)
{ {
bool truncated = false; var budgetResult = AgentToolResultBudget.Apply(messages, RecentKeepCount);
bool truncated = budgetResult.TruncatedCount > 0;
// 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지)
var cutoff = Math.Max(0, messages.Count - RecentKeepCount); var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
for (int i = 0; i < cutoff; i++) for (int i = 0; i < cutoff; i++)
@@ -218,25 +345,20 @@ public static class ContextCondenser
var msg = messages[i]; var msg = messages[i];
if (msg.Content == null) continue; if (msg.Content == null) continue;
// tool_result 메시지의 대용량 출력 축약 if (AgentMessageInvariantHelper.TryGetToolResultId(msg, out _))
if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars)
{ {
// JSON 구조를 유지하되 output 부분만 축약 continue;
messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content));
truncated = true;
} }
// assistant의 도구 호출 블록 내 긴 텍스트도 축약
else if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 && if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 &&
msg.Content.StartsWith("{\"_tool_use_blocks\"")) msg.Content.StartsWith("{\"_tool_use_blocks\""))
{ {
// 도구 호출 구조는 유지, 텍스트 블록만 축약
if (msg.Content.Length > MaxToolResultChars * 3) if (msg.Content.Length > MaxToolResultChars * 3)
{ {
messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}"); messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}");
truncated = true; truncated = true;
} }
} }
// 일반 assistant/user 메시지 중 비정상적으로 긴 것 (예: 파일 내용 전체 붙여넣기)
else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system") else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system")
{ {
messages[i] = CloneWithContent( messages[i] = CloneWithContent(
@@ -276,13 +398,10 @@ public static class ContextCondenser
/// </summary> /// </summary>
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages) private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
{ {
var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var window = SplitCompactionWindow(messages, extraTrailingProtection: 2);
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList(); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0);
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0); var oldMessages = window.OldMessages;
var recentMessages = window.RecentMessages;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
var rewritten = new List<ChatMessage>(oldMessages.Count); var rewritten = new List<ChatMessage>(oldMessages.Count);
bool changed = false; bool changed = false;
@@ -337,7 +456,7 @@ public static class ContextCondenser
if (!changed) return (0, 0); if (!changed) return (0, 0);
messages.Clear(); messages.Clear();
if (systemMsg != null) messages.Add(systemMsg); if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten); messages.AddRange(rewritten);
messages.AddRange(recentMessages); messages.AddRange(recentMessages);
return (boundaryCount, singleCount); return (boundaryCount, singleCount);
@@ -345,13 +464,10 @@ public static class ContextCondenser
private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages) private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages)
{ {
var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var window = SplitCompactionWindow(messages, extraTrailingProtection: 3);
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList(); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return 0;
if (nonSystemMessages.Count <= RecentKeepCount + 3) return 0; var oldMessages = window.OldMessages;
var recentMessages = window.RecentMessages;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
var candidates = oldMessages var candidates = oldMessages
.Select((message, index) => new { message, index }) .Select((message, index) => new { message, index })
@@ -405,7 +521,7 @@ public static class ContextCondenser
} }
messages.Clear(); messages.Clear();
if (systemMsg != null) messages.Add(systemMsg); if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten); messages.AddRange(rewritten);
messages.AddRange(recentMessages); messages.AddRange(recentMessages);
return candidates.Count; return candidates.Count;
@@ -427,13 +543,10 @@ public static class ContextCondenser
private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages) private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages)
{ {
var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var window = SplitCompactionWindow(messages, extraTrailingProtection: 2);
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList(); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0);
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0); var oldMessages = window.OldMessages;
var recentMessages = window.RecentMessages;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
var rewritten = new List<ChatMessage>(oldMessages.Count); var rewritten = new List<ChatMessage>(oldMessages.Count);
int snippedCount = 0; int snippedCount = 0;
@@ -484,7 +597,7 @@ public static class ContextCondenser
if (snippedCount == 0 && collapsedCount == 0) return (0, 0); if (snippedCount == 0 && collapsedCount == 0) return (0, 0);
messages.Clear(); messages.Clear();
if (systemMsg != null) messages.Add(systemMsg); if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten); messages.AddRange(rewritten);
messages.AddRange(recentMessages); messages.AddRange(recentMessages);
return (snippedCount, collapsedCount); return (snippedCount, collapsedCount);
@@ -596,17 +709,7 @@ public static class ContextCondenser
!(m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) && !(m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) &&
m.MetaKind == null); m.MetaKind == null);
var attachedFiles = group var attachmentSummary = CollectCompactionAttachmentSummary(group, 4);
.SelectMany(m => m.AttachedFiles ?? Enumerable.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
try { return System.IO.Path.GetFileName(path); }
catch { return path; }
})
.Where(name => !string.IsNullOrWhiteSpace(name))
.Take(4)
.ToList();
var lines = new List<string> var lines = new List<string>
{ {
@@ -617,7 +720,8 @@ public static class ContextCondenser
if (toolCalls > 0) lines.Add($"- 도구 호출 {toolCalls}건 정리"); if (toolCalls > 0) lines.Add($"- 도구 호출 {toolCalls}건 정리");
if (metaEvents > 0) lines.Add($"- 실행 메타/로그 {metaEvents}건 정리"); if (metaEvents > 0) lines.Add($"- 실행 메타/로그 {metaEvents}건 정리");
if (longTexts > 0) lines.Add($"- 긴 설명/출력 {longTexts}건 축약"); if (longTexts > 0) lines.Add($"- 긴 설명/출력 {longTexts}건 축약");
if (attachedFiles.Count > 0) lines.Add($"- 관련 파일: {string.Join(", ", attachedFiles)}"); if (attachmentSummary.AttachedFiles.Count > 0) lines.Add($"- 관련 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}");
if (attachmentSummary.ImageCount > 0) lines.Add($"- 관련 이미지: {attachmentSummary.ImageCount}개");
return new ChatMessage return new ChatMessage
{ {
@@ -626,57 +730,22 @@ public static class ContextCondenser
Timestamp = group.Last().Timestamp, Timestamp = group.Last().Timestamp,
MetaKind = "microcompact_boundary", MetaKind = "microcompact_boundary",
MetaRunId = group.Last().MetaRunId, MetaRunId = group.Last().MetaRunId,
AttachedFiles = attachmentSummary.AttachedFiles.Count > 0 ? attachmentSummary.AttachedFiles : null,
}; };
} }
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
private static string TruncateToolResultJson(string json)
{
// 간단한 문자열 처리로 output 부분만 축약 (JSON 파서 없이)
const string marker = "\"output\":\"";
var idx = json.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0) return json[..Math.Min(json.Length, MaxToolResultChars)] + "...[축약됨]}";
var outputStart = idx + marker.Length;
// output 끝 찾기 (이스케이프된 따옴표 고려)
var outputEnd = outputStart;
while (outputEnd < json.Length)
{
if (json[outputEnd] == '\\') { outputEnd += 2; continue; }
if (json[outputEnd] == '"') break;
outputEnd++;
}
var outputLen = outputEnd - outputStart;
if (outputLen <= MaxToolResultChars) return json; // 이미 짧음
// 앞부분 + "...[축약됨]" + 뒷부분
var keepLen = MaxToolResultChars / 2;
var prefix = json[..outputStart];
var outputText = json[outputStart..outputEnd];
var suffix = json[outputEnd..];
return prefix +
outputText[..keepLen] +
"\\n...[축약됨: " + $"{outputLen:N0}" + "자 중 " + $"{MaxToolResultChars:N0}" + "자 유지]\\n" +
outputText[^keepLen..] +
suffix;
}
/// <summary> /// <summary>
/// 2단계: 오래된 메시지를 LLM으로 요약합니다. /// 2단계: 오래된 메시지를 LLM으로 요약합니다.
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다. /// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
/// </summary> /// </summary>
private static async Task<bool> SummarizeOldMessagesAsync( private static async Task<bool> SummarizeOldMessagesAsync(
List<ChatMessage> messages, LlmService llm, CancellationToken ct) List<ChatMessage> messages, ILlmService llm, CancellationToken ct)
{ {
var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var window = SplitCompactionWindow(messages, extraTrailingProtection: 0);
var systemCount = systemMsg != null ? 1 : 0; var systemMsg = window.SystemMessage;
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList(); var oldMessages = window.OldMessages;
var recentMessages = window.RecentMessages;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count); var attachmentSummary = CollectCompactionAttachmentSummary(oldMessages, 6);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
if (oldMessages.Count < 3) return false; if (oldMessages.Count < 3) return false;
@@ -717,8 +786,9 @@ public static class ContextCondenser
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
Role = "user", Role = "user",
Content = $"[이전 대화 요약 — {oldMessages.Count}개 메시지 압축]\n{summary}", Content = BuildSummaryMessageContent(oldMessages.Count, summary, attachmentSummary),
Timestamp = DateTime.Now, Timestamp = DateTime.Now,
AttachedFiles = attachmentSummary.AttachedFiles.Count > 0 ? attachmentSummary.AttachedFiles : null,
}); });
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
@@ -736,4 +806,42 @@ public static class ContextCondenser
return false; return false;
} }
} }
private static CompactionAttachmentSummary CollectCompactionAttachmentSummary(IEnumerable<ChatMessage> messages, int maxFiles)
{
var attachedFiles = messages
.SelectMany(m => m.AttachedFiles ?? Enumerable.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
try { return System.IO.Path.GetFileName(path); }
catch { return path; }
})
.Where(name => !string.IsNullOrWhiteSpace(name))
.Take(Math.Max(1, maxFiles))
.ToList();
var imageCount = messages.Sum(m => m.Images?.Count ?? 0);
return new CompactionAttachmentSummary
{
AttachedFiles = attachedFiles,
ImageCount = imageCount,
};
}
private static string BuildSummaryMessageContent(int oldMessageCount, string summary, CompactionAttachmentSummary attachmentSummary)
{
var lines = new List<string>
{
$"[이전 대화 요약 — {oldMessageCount}개 메시지 압축]",
summary.Trim()
};
if (attachmentSummary.AttachedFiles.Count > 0)
lines.Add($"참고 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}");
if (attachmentSummary.ImageCount > 0)
lines.Add($"참고 이미지: {attachmentSummary.ImageCount}개");
return string.Join("\n", lines.Where(x => !string.IsNullOrWhiteSpace(x)));
}
} }
@@ -135,7 +135,7 @@ public class DocumentAssemblerTool : IAgentTool
var pageEstimate = Math.Max(1, totalWords / 500); var pageEstimate = Math.Max(1, totalWords / 500);
return ToolResult.Ok( return ToolResult.Ok(
$"✅ 문서 조립 완료: {Path.GetFileName(fullPath)}\n" + $"✅ 문서 조립 완료: {fullPath}\n" +
$" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" + $" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" +
$"{resultMsg}", fullPath); $"{resultMsg}", fullPath);
} }
@@ -241,14 +241,30 @@ public class DocumentAssemblerTool : IAgentTool
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart(); var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(); mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body()); var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 한글 호환 글꼴 설정 헬퍼
static DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new()
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
};
// 제목 // 제목
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{ {
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(), Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" } FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" }
}); });
@@ -267,6 +283,7 @@ public class DocumentAssemblerTool : IAgentTool
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{ {
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(), Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" }, FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" },
Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" }, Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" },
@@ -281,6 +298,11 @@ public class DocumentAssemblerTool : IAgentTool
{ {
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run(); var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }
});
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim()) run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim())
{ {
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
@@ -293,9 +315,59 @@ public class DocumentAssemblerTool : IAgentTool
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
} }
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
// 첫 번째에 넣으면 Word가 무시하거나 문서가 깨짐
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
mainPart.Document.Save();
return " ✓ DOCX 조립 완료"; return " ✓ DOCX 조립 완료";
} }
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles()
{
var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles();
// 문서 기본 글꼴 설정
var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" },
new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
// Normal 스타일
var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style
{
Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph,
StyleId = "Normal",
Default = true
};
normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections) private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -316,7 +316,7 @@ public class DocumentPlannerTool : IAgentTool
folder_data_usage = folderDataUsage, folder_data_usage = folderDataUsage,
step1 = hasRefData step1 = hasRefData
? "Reference data is already provided above. Skip to step 3." ? "Reference data is already provided above. Skip to step 3."
: "Use folder_map to scan the work folder, then use document_read to read RELEVANT files only.", : "Use glob or grep to narrow relevant files first, then use document_read or file_read on the best candidates only. Use folder_map only when the user explicitly asks for folder structure or existing materials in a folder.",
step2 = hasRefData step2 = hasRefData
? "(skipped)" ? "(skipped)"
: "Summarize the key findings from the folder documents relevant to the topic.", : "Summarize the key findings from the folder documents relevant to the topic.",
@@ -420,6 +420,12 @@ public class DocumentPlannerTool : IAgentTool
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart(); var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(); mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body()); var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
@@ -438,6 +444,15 @@ public class DocumentPlannerTool : IAgentTool
} }
AddDocxParagraph(body, ""); // 섹션 간 빈 줄 AddDocxParagraph(body, ""); // 섹션 간 빈 줄
} }
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
mainPart.Document.Save();
} }
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body, private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
@@ -447,6 +462,13 @@ public class DocumentPlannerTool : IAgentTool
var run = new DocumentFormat.OpenXml.Wordprocessing.Run(); var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{ {
RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize } FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
}; };
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(); if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
@@ -460,6 +482,45 @@ public class DocumentPlannerTool : IAgentTool
body.AppendChild(para); body.AppendChild(para);
} }
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles()
{
var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles();
var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" },
new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style
{
Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph,
StyleId = "Normal",
Default = true
};
normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
// ─── Markdown 생성 ────────────────────────────────────────────────────── // ─── Markdown 생성 ──────────────────────────────────────────────────────
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections) private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
@@ -631,7 +692,7 @@ public class DocumentPlannerTool : IAgentTool
return normalized; return normalized;
var intent = $"{docType} {topic}".ToLowerInvariant(); var intent = $"{docType} {topic}".ToLowerInvariant();
if (ContainsAny(intent, "대시보드", "dashboard", "분석", "analysis", "지표", "통계")) if (ContainsAny(intent, "대시보드", "dashboard", "지표"))
return "dashboard"; return "dashboard";
if (ContainsAny(intent, "기획", "아이디어", "creative", "브레인스토밍")) if (ContainsAny(intent, "기획", "아이디어", "creative", "브레인스토밍"))
return "creative"; return "creative";
@@ -639,11 +700,15 @@ public class DocumentPlannerTool : IAgentTool
return "corporate"; return "corporate";
if (ContainsAny(intent, "가이드", "manual", "guide", "매뉴얼")) if (ContainsAny(intent, "가이드", "manual", "guide", "매뉴얼"))
return "minimal"; return "minimal";
// 보고서/분석: corporate 무드 (배경색 포함)
if (ContainsAny(intent, "보고서", "report", "분석", "analysis", "통계"))
return "corporate";
return docType switch return docType switch
{ {
"proposal" => "corporate", "proposal" => "corporate",
"analysis" => "dashboard", "analysis" => "corporate",
"report" => "corporate",
"manual" or "guide" => "minimal", "manual" or "guide" => "minimal",
"presentation" => "creative", "presentation" => "creative",
"minutes" => "professional", "minutes" => "professional",
@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet; using DocumentFormat.OpenXml.Spreadsheet;
using A = DocumentFormat.OpenXml.Drawing;
using UglyToad.PdfPig; using UglyToad.PdfPig;
namespace AxCopilot.Services.Agent; namespace AxCopilot.Services.Agent;
@@ -17,7 +18,7 @@ public class DocumentReaderTool : IAgentTool
public string Name => "document_read"; public string Name => "document_read";
public string Description => public string Description =>
"Read a document file and extract its text content. " + "Read a document file and extract its text content. " +
"Supports: PDF (.pdf), Word (.docx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " + "Supports: PDF (.pdf), Word (.docx), PowerPoint (.pptx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " +
"BibTeX (.bib), RIS (.ris). " + "BibTeX (.bib), RIS (.ris). " +
"For large files, use 'offset' to read from a specific character position (chunked reading). " + "For large files, use 'offset' to read from a specific character position (chunked reading). " +
"For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " + "For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " +
@@ -79,6 +80,7 @@ public class DocumentReaderTool : IAgentTool
{ {
".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct), ".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct),
".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct), ".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct),
".pptx" => await Task.Run(() => ReadPptx(fullPath, extractMax), ct),
".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct), ".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct),
".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct), ".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct),
".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct), ".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct),
@@ -478,6 +480,71 @@ public class DocumentReaderTool : IAgentTool
return Truncate(sb.ToString(), maxChars); return Truncate(sb.ToString(), maxChars);
} }
// ─── PPTX ───────────────────────────────────────────────────────────────
private static string ReadPptx(string path, int maxChars)
{
var sb = new StringBuilder();
using var doc = PresentationDocument.Open(path, false);
var presentation = doc.PresentationPart;
if (presentation?.Presentation?.SlideIdList == null)
return "(빈 프레젠테이션)";
var slideIds = presentation.Presentation.SlideIdList.Elements<DocumentFormat.OpenXml.Presentation.SlideId>().ToList();
sb.AppendLine($"PowerPoint: {slideIds.Count}개 슬라이드");
sb.AppendLine();
int slideNum = 0;
foreach (var slideId in slideIds)
{
if (sb.Length >= maxChars) break;
slideNum++;
var relId = slideId.RelationshipId?.Value;
if (relId == null) continue;
var slidePart = (SlidePart)presentation.GetPartById(relId);
var slide = slidePart.Slide;
if (slide == null) continue;
sb.AppendLine($"--- Slide {slideNum} ---");
// 슬라이드 내 모든 텍스트 추출 (도형, 텍스트 박스, 테이블 등)
var texts = slide.Descendants<A.Text>()
.Select(t => t.Text)
.Where(t => !string.IsNullOrWhiteSpace(t));
// 단락(Paragraph) 단위로 그룹핑하여 줄바꿈 유지
var paragraphs = slide.Descendants<A.Paragraph>();
foreach (var para in paragraphs)
{
var paraText = string.Join("", para.Descendants<A.Text>().Select(t => t.Text));
if (!string.IsNullOrWhiteSpace(paraText))
sb.AppendLine(paraText);
}
// 슬라이드 노트가 있으면 포함
var notesPart = slidePart.NotesSlidePart;
if (notesPart != null)
{
var noteTexts = notesPart.NotesSlide.Descendants<A.Paragraph>()
.Select(p => string.Join("", p.Descendants<A.Text>().Select(t => t.Text)))
.Where(t => !string.IsNullOrWhiteSpace(t))
.ToList();
if (noteTexts.Count > 0)
{
sb.AppendLine(" [노트]");
foreach (var note in noteTexts)
sb.AppendLine($" {note}");
}
}
sb.AppendLine();
}
return Truncate(sb.ToString(), maxChars);
}
// ─── XLSX ─────────────────────────────────────────────────────────────── // ─── XLSX ───────────────────────────────────────────────────────────────
private static string ReadXlsx(string path, string sheetParam, int maxChars) private static string ReadXlsx(string path, string sheetParam, int maxChars)
+123 -15
View File
@@ -38,7 +38,9 @@ public class DocxSkill : IAgentTool
"• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \" - sub-item\"]}\n" + "• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \" - sub-item\"]}\n" +
"• Callout: {\"type\": \"callout\", \"style\": \"info|warning|tip|danger\", \"title\": \"...\", \"body\": \"...\"}\n" + "• Callout: {\"type\": \"callout\", \"style\": \"info|warning|tip|danger\", \"title\": \"...\", \"body\": \"...\"}\n" +
"• HighlightBox: {\"type\": \"highlight_box\", \"text\": \"...\", \"color\": \"blue|green|orange|red\"}\n" + "• HighlightBox: {\"type\": \"highlight_box\", \"text\": \"...\", \"color\": \"blue|green|orange|red\"}\n" +
"Body text supports inline formatting: **bold**, *italic*, `code`.", "• Icon: {\"type\": \"icon\", \"name\": \"checkmark|warning|rocket|star|...\", \"text\": \"optional label text\", \"size\": 28}\n" +
"Body text supports inline formatting: **bold**, *italic*, `code`. " +
"Inline icons in body text: use {icon:name} syntax e.g. '{icon:checkmark} 완료됨'.",
Items = new() { Type = "object" } Items = new() { Type = "object" }
}, },
["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." }, ["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." },
@@ -136,6 +138,12 @@ public class DocxSkill : IAgentTool
using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document); using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart(); var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml)
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new Document(); mainPart.Document = new Document();
var body = mainPart.Document.AppendChild(new Body()); var body = mainPart.Document.AppendChild(new Body());
@@ -194,6 +202,12 @@ public class DocxSkill : IAgentTool
continue; continue;
} }
if (blockType == "icon")
{
body.Append(CreateIconParagraph(section));
continue;
}
// 일반 섹션 (heading + body) // 일반 섹션 (heading + body)
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : ""; var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : ""; var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
@@ -212,6 +226,8 @@ public class DocxSkill : IAgentTool
sectionCount++; sectionCount++;
} }
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
EnsureSectionProperties(body);
mainPart.Document.Save(); mainPart.Document.Save();
var parts = new List<string>(); var parts = new List<string>();
@@ -250,7 +266,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(), Bold = new Bold(),
FontSize = new FontSize { Val = "44" }, // 22pt FontSize = new FontSize { Val = "44" }, // 22pt
Color = new Color { Val = theme.Title }, Color = new Color { Val = theme.Title },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
para.Append(run); para.Append(run);
return para; return para;
@@ -280,7 +296,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(), Bold = new Bold(),
FontSize = new FontSize { Val = fontSize }, FontSize = new FontSize { Val = fontSize },
Color = new Color { Val = color }, Color = new Color { Val = color },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
para.Append(run); para.Append(run);
return para; return para;
@@ -299,9 +315,16 @@ public class DocxSkill : IAgentTool
return para; return para;
} }
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary> /// <summary>**bold**, *italic*, `code`, {icon:name} 인라인 서식을 Run으로 변환</summary>
private static void AppendFormattedRuns(Paragraph para, string text) private static void AppendFormattedRuns(Paragraph para, string text)
{ {
// 먼저 {icon:name} 인라인 아이콘을 심볼로 치환
text = System.Text.RegularExpressions.Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
return IconLibrary.Contains(name) ? IconLibrary.Resolve(name) : m.Value;
});
// 패턴: **bold** | *italic* | `code` | 일반텍스트 // 패턴: **bold** | *italic* | `code` | 일반텍스트
var regex = new System.Text.RegularExpressions.Regex( var regex = new System.Text.RegularExpressions.Regex(
@"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`"); @"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`");
@@ -331,7 +354,7 @@ public class DocxSkill : IAgentTool
{ {
var run = CreateRun(match.Groups[3].Value); var run = CreateRun(match.Groups[3].Value);
run.RunProperties ??= new RunProperties(); run.RunProperties ??= new RunProperties();
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "맑은 고딕" }; run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "Noto Sans KR" };
run.RunProperties.FontSize = new FontSize { Val = "20" }; run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading run.RunProperties.Shading = new Shading
{ {
@@ -360,7 +383,7 @@ public class DocxSkill : IAgentTool
run.RunProperties = new RunProperties run.RunProperties = new RunProperties
{ {
FontSize = new FontSize { Val = "22" }, // 11pt FontSize = new FontSize { Val = "22" }, // 11pt
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
return run; return run;
} }
@@ -410,7 +433,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(), Bold = new Bold(),
FontSize = new FontSize { Val = "20" }, FontSize = new FontSize { Val = "20" },
Color = new Color { Val = "FFFFFF" }, Color = new Color { Val = "FFFFFF" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
} }
}); });
para.ParagraphProperties = new ParagraphProperties para.ParagraphProperties = new ParagraphProperties
@@ -448,7 +471,7 @@ public class DocxSkill : IAgentTool
RunProperties = new RunProperties RunProperties = new RunProperties
{ {
FontSize = new FontSize { Val = "20" }, FontSize = new FontSize { Val = "20" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
} }
}); });
para.ParagraphProperties = new ParagraphProperties para.ParagraphProperties = new ParagraphProperties
@@ -503,7 +526,7 @@ public class DocxSkill : IAgentTool
{ {
FontSize = new FontSize { Val = "22" }, FontSize = new FontSize { Val = "22" },
Bold = (listStyle == "number" && !isSub) ? new Bold() : null, Bold = (listStyle == "number" && !isSub) ? new Bold() : null,
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
para.Append(prefixRun); para.Append(prefixRun);
@@ -511,7 +534,7 @@ public class DocxSkill : IAgentTool
textRun.RunProperties = new RunProperties textRun.RunProperties = new RunProperties
{ {
FontSize = new FontSize { Val = "22" }, FontSize = new FontSize { Val = "22" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
para.Append(textRun); para.Append(textRun);
@@ -552,7 +575,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(), Bold = new Bold(),
FontSize = new FontSize { Val = "22" }, FontSize = new FontSize { Val = "22" },
Color = new Color { Val = borderColor }, Color = new Color { Val = borderColor },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
titlePara.Append(titleRun); titlePara.Append(titleRun);
body.Append(titlePara); body.Append(titlePara);
@@ -642,7 +665,7 @@ public class DocxSkill : IAgentTool
{ {
var run = CreateRun(match.Groups[3].Value); var run = CreateRun(match.Groups[3].Value);
run.RunProperties ??= new RunProperties(); run.RunProperties ??= new RunProperties();
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "맑은 고딕" }; run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "Noto Sans KR" };
run.RunProperties.FontSize = new FontSize { Val = "20" }; run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading run.RunProperties.Shading = new Shading
{ {
@@ -679,6 +702,57 @@ public class DocxSkill : IAgentTool
// 머리글/바닥글 // 머리글/바닥글
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
/// <summary>Body의 마지막에 SectionProperties를 추가합니다 (OOXML 규격: 반드시 마지막 자식).</summary>
private static void EnsureSectionProperties(Body body)
{
// 기존 sectPr이 마지막이 아닌 위치에 있으면 제거 후 재추가
var existing = body.GetFirstChild<SectionProperties>();
if (existing != null)
{
// 이미 마지막 자식이면 유지
if (existing == body.LastChild) return;
existing.Remove();
}
body.AppendChild(new SectionProperties(
new PageSize { Width = 11906, Height = 16838 }, // A4
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static Styles CreateDefaultDocxStyles()
{
var styles = new Styles();
var docDefaults = new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new FontSize { Val = "22" },
new Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines { After = "160", Line = "259", LineRule = LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
var normalStyle = new Style { Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
normalStyle.AppendChild(new StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
private static void AddHeaderFooter(MainDocumentPart mainPart, Body body, private static void AddHeaderFooter(MainDocumentPart mainPart, Body body,
string? headerText, string? footerText, bool showPageNumbers) string? headerText, string? footerText, bool showPageNumbers)
{ {
@@ -693,7 +767,7 @@ public class DocxSkill : IAgentTool
{ {
FontSize = new FontSize { Val = "18" }, // 9pt FontSize = new FontSize { Val = "18" }, // 9pt
Color = new Color { Val = "808080" }, Color = new Color { Val = "808080" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
} }
}); });
para.ParagraphProperties = new ParagraphProperties para.ParagraphProperties = new ParagraphProperties
@@ -766,10 +840,44 @@ public class DocxSkill : IAgentTool
{ {
FontSize = new FontSize { Val = "16" }, FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" }, Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
} }
}; };
/// <summary>아이콘 블록: 큰 심볼 + 선택적 라벨 텍스트.</summary>
private static Paragraph CreateIconParagraph(System.Text.Json.JsonElement section)
{
var iconName = section.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var label = section.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var fontSize = section.SafeTryGetProperty("size", out var sz) ? sz.GetInt32() : 28;
var symbol = IconLibrary.Resolve(iconName);
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Line = "360" },
};
// 아이콘 심볼 (큰 폰트)
var iconRun = new Run(new Text(symbol + " ") { Space = SpaceProcessingModeValues.Preserve });
iconRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = (fontSize * 2).ToString() }, // half-pt 단위
RunFonts = new RunFonts { Ascii = "Segoe UI Emoji", HighAnsi = "Segoe UI Emoji", EastAsia = "Segoe UI Emoji" },
};
para.Append(iconRun);
// 라벨 텍스트 (일반 폰트)
if (!string.IsNullOrWhiteSpace(label))
{
var labelRun = CreateRun(label);
labelRun.RunProperties!.FontSize = new FontSize { Val = "24" }; // 12pt
para.Append(labelRun);
}
return para;
}
private static Run CreatePageNumberRun() private static Run CreatePageNumberRun()
{ {
var run = new Run(); var run = new Run();
@@ -777,7 +885,7 @@ public class DocxSkill : IAgentTool
{ {
FontSize = new FontSize { Val = "16" }, FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" }, Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" }, RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}; };
run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin }); run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin });
run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }); run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve });
@@ -172,7 +172,7 @@ internal static class DocxToHtmlConverter
<meta charset='UTF-8'> <meta charset='UTF-8'>
<style> <style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a; body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }} line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }} h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }}
h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }} h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }}
+19 -5
View File
@@ -17,7 +17,9 @@ public class ExcelSkill : IAgentTool
"Supports: header styling (bold white text on colored background), " + "Supports: header styling (bold white text on colored background), " +
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " + "striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
"cell merge, freeze panes (freeze header row), number formatting, " + "cell merge, freeze panes (freeze header row), number formatting, " +
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks."; "themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks. " +
"Inline icons: use {icon:name} in cell text (e.g. '{icon:checkmark} 완료', '{icon:warning} 주의'). " +
"170+ built-in icons: checkmark, warning, star, rocket, chart_up, chart_down, etc.";
public ToolParameterSchema Parameters => new() public ToolParameterSchema Parameters => new()
{ {
@@ -340,12 +342,16 @@ public class ExcelSkill : IAgentTool
var strVal = cellVal.ToString(); var strVal = cellVal.ToString();
// {icon:name} 인라인 아이콘 → 유니코드 심볼로 치환
if (strVal.Contains("{icon:"))
strVal = ResolveInlineIcons(strVal);
if (strVal.StartsWith('=')) if (strVal.StartsWith('='))
{ {
cell.CellFormula = new CellFormula(strVal); cell.CellFormula = new CellFormula(strVal);
cell.DataType = null; cell.DataType = null;
} }
else if (cellVal.ValueKind == JsonValueKind.Number) else if (cellVal.ValueKind == JsonValueKind.Number && !strVal.Contains("{icon:"))
{ {
cell.DataType = CellValues.Number; cell.DataType = CellValues.Number;
cell.CellValue = new CellValue(cellVal.GetDouble().ToString()); cell.CellValue = new CellValue(cellVal.GetDouble().ToString());
@@ -500,18 +506,18 @@ public class ExcelSkill : IAgentTool
var fonts = new Fonts( var fonts = new Fonts(
new Font( // 0: default new Font( // 0: default
new FontSize { Val = 11 }, new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" } new FontName { Val = "Noto Sans KR" }
), ),
new Font( // 1: bold white (header) new Font( // 1: bold white (header)
new Bold(), new Bold(),
new FontSize { Val = 11 }, new FontSize { Val = 11 },
new Color { Rgb = theme.HeaderFg }, new Color { Rgb = theme.HeaderFg },
new FontName { Val = "맑은 고딕" } new FontName { Val = "Noto Sans KR" }
), ),
new Font( // 2: bold (summary row) new Font( // 2: bold (summary row)
new Bold(), new Bold(),
new FontSize { Val = 11 }, new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" } new FontName { Val = "Noto Sans KR" }
) )
); );
stylesheet.Append(fonts); stylesheet.Append(fonts);
@@ -799,4 +805,12 @@ public class ExcelSkill : IAgentTool
private static string GetCellReference(int colIndex, int rowIndex) private static string GetCellReference(int colIndex, int rowIndex)
=> $"{GetColumnLetter(colIndex)}{rowIndex + 1}"; => $"{GetColumnLetter(colIndex)}{rowIndex + 1}";
/// <summary>{icon:name} 패턴을 유니코드 심볼로 치환합니다.</summary>
private static string ResolveInlineIcons(string text)
=> System.Text.RegularExpressions.Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
return IconLibrary.Contains(name) ? IconLibrary.Resolve(name) : m.Value;
});
} }
@@ -13,7 +13,8 @@ public class FolderMapTool : IAgentTool
public string Name => "folder_map"; public string Name => "folder_map";
public string Description => public string Description =>
"Generate a directory tree map of the work folder or a specified subfolder. " + "Generate a directory tree map of the work folder or a specified subfolder. " +
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files. " + "Shows folders and files in a tree structure. Use this only when the user explicitly asks for folder contents, directory structure, or workspace layout. " +
"Do not use it to locate a specific file when glob/grep or a targeted read would work. " +
"Supports sorting, size filtering, date filtering, and multi-extension filtering."; "Supports sorting, size filtering, date filtering, and multi-extension filtering.";
public ToolParameterSchema Parameters => new() public ToolParameterSchema Parameters => new()
@@ -22,7 +23,7 @@ public class FolderMapTool : IAgentTool
{ {
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." }, ["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." }, ["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." }, ["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." }, ["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new() ["extensions"] = new()
{ {
@@ -65,7 +66,7 @@ public class FolderMapTool : IAgentTool
var maxDepth = Math.Min(depth, 10); var maxDepth = Math.Min(depth, 10);
// ── include_files ───────────────────────────────────────────────── // ── include_files ─────────────────────────────────────────────────
var includeFiles = false; var includeFiles = true;
if (args.SafeTryGetProperty("include_files", out var inc)) if (args.SafeTryGetProperty("include_files", out var inc))
{ {
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False) if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
+41 -3
View File
@@ -46,7 +46,10 @@ public class HtmlSkill : IAgentTool
"'list' {style:'bullet|number', items:['item', ' - sub-item']}, " + "'list' {style:'bullet|number', items:['item', ' - sub-item']}, " +
"'quote' {text, author}, " + "'quote' {text, author}, " +
"'divider', " + "'divider', " +
"'kpi' {items:[{label, value, change, positive:bool}]}. " + "'kpi' {items:[{label, value, change, positive:bool}]}, " +
"'icon' {name:'checkmark|warning|rocket|...', text:'optional label', size:48}. " +
"Inline icons in any text: use {icon:name} syntax (e.g. '{icon:checkmark} Done'). " +
"170+ built-in icons available. " +
"When both body and sections are provided, sections are appended after body." "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. Default: modern" },
@@ -178,11 +181,16 @@ public class HtmlSkill : IAgentTool
} }
else else
{ {
// 커버가 없으면 기존 방식의 제목+메타 // 커버가 없으면 header-bar로 제목 표시
sb.AppendLine("<div class=\"header-bar\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>"); sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">생성: {DateTime.Now:yyyy-MM-dd HH:mm} | AX Copilot{moodLabel}</div>"); sb.AppendLine($"<div class=\"meta\">생성: {DateTime.Now:yyyy-MM-dd HH:mm} | AX Copilot{moodLabel}</div>");
sb.AppendLine("</div>");
} }
// 본문을 body-content로 감싸서 좌우 여백 확보
sb.AppendLine("<div class=\"body-content\">");
// TOC // TOC
if (!string.IsNullOrEmpty(tocHtml)) if (!string.IsNullOrEmpty(tocHtml))
sb.AppendLine(tocHtml); sb.AppendLine(tocHtml);
@@ -198,7 +206,8 @@ public class HtmlSkill : IAgentTool
"$1</div>"); "$1</div>");
sb.AppendLine(wrappedBody); sb.AppendLine(wrappedBody);
sb.AppendLine("</div>"); sb.AppendLine("</div>"); // body-content
sb.AppendLine("</div>"); // container
sb.AppendLine("</body>"); sb.AppendLine("</body>");
sb.AppendLine("</html>"); sb.AppendLine("</html>");
@@ -268,6 +277,9 @@ public class HtmlSkill : IAgentTool
case "kpi": case "kpi":
sb.AppendLine(RenderKpi(section)); sb.AppendLine(RenderKpi(section));
break; break;
case "icon":
sb.AppendLine(RenderIcon(section));
break;
} }
} }
return sb.ToString(); return sb.ToString();
@@ -549,6 +561,15 @@ public class HtmlSkill : IAgentTool
// 7. 보존된 <br> 플레이스홀더를 복원 // 7. 보존된 <br> 플레이스홀더를 복원
text = text.Replace("\x00BR\x00", "<br>"); text = text.Replace("\x00BR\x00", "<br>");
// 8. {icon:name} 인라인 아이콘 → 유니코드 심볼 (span으로 감싸서 크기 조절)
text = Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
if (!IconLibrary.Contains(name)) return m.Value;
var sym = IconLibrary.Resolve(name);
return $"<span class=\"inline-icon\" title=\"{name}\">{sym}</span>";
});
return text; return text;
} }
@@ -732,4 +753,21 @@ public class HtmlSkill : IAgentTool
private static string Escape(string s) => private static string Escape(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;"); s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>아이콘 블록: 큰 심볼 + 선택적 라벨 텍스트.</summary>
private static string RenderIcon(JsonElement s)
{
var name = s.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var label = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var size = s.SafeTryGetProperty("size", out var sz) ? sz.GetInt32() : 48;
var symbol = IconLibrary.Resolve(name);
var sb = new StringBuilder();
sb.Append($"<div class=\"icon-block\" style=\"display:flex;align-items:center;gap:12px;margin:16px 0\">");
sb.Append($"<span style=\"font-size:{size}px;line-height:1\">{symbol}</span>");
if (!string.IsNullOrWhiteSpace(label))
sb.Append($"<span style=\"font-size:1rem;color:#374151\">{MarkdownToHtml(label)}</span>");
sb.Append("</div>");
return sb.ToString();
}
} }
@@ -20,6 +20,14 @@ public interface IAgentTool
/// <summary>LLM function calling용 파라미터 JSON Schema.</summary> /// <summary>LLM function calling용 파라미터 JSON Schema.</summary>
ToolParameterSchema Parameters { get; } ToolParameterSchema Parameters { get; }
/// <summary>
/// 도구가 활성화되는 탭 카테고리.
/// null이면 모든 탭에서 사용 가능.
/// "Cowork" = Cowork 전용, "Code" = Code 전용, "Chat" = Chat 전용.
/// 쉼표 구분으로 복수 탭 지정 가능: "Cowork,Code".
/// </summary>
string? TabCategory => null;
/// <summary>도구를 실행하고 결과를 반환합니다.</summary> /// <summary>도구를 실행하고 결과를 반환합니다.</summary>
/// <param name="args">LLM이 생성한 JSON 파라미터</param> /// <param name="args">LLM이 생성한 JSON 파라미터</param>
/// <param name="context">실행 컨텍스트 (작업 폴더, 권한 등)</param> /// <param name="context">실행 컨텍스트 (작업 폴더, 권한 등)</param>
@@ -171,16 +179,35 @@ public class AgentContext
} }
// 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용 // 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용
// 사내 모드에서는 외부 경로도 사용자 승인 후 접근 가능 (CheckToolPermissionAsync에서 강제 승인 처리)
if (!string.IsNullOrEmpty(WorkFolder)) if (!string.IsNullOrEmpty(WorkFolder))
{ {
var workFull = Path.GetFullPath(WorkFolder); var workFull = Path.GetFullPath(WorkFolder);
if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase)) if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase))
{
// 사내 모드: 외부 경로는 IsPathAllowed에서 차단하지 않고 권한 검증 단계로 위임
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode))
return true; // CheckToolPermissionAsync에서 강제 승인 요청됨
return false; return false;
}
} }
return true; return true;
} }
/// <summary>경로가 워크스페이스 외부인지 확인합니다.</summary>
public bool IsOutsideWorkspace(string path)
{
if (string.IsNullOrEmpty(WorkFolder)) return false;
try
{
var fullPath = Path.GetFullPath(path);
var workFull = Path.GetFullPath(WorkFolder);
return !fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase);
}
catch { return true; }
}
/// <summary> /// <summary>
/// 파일 경로에 타임스탬프를 추가합니다. /// 파일 경로에 타임스탬프를 추가합니다.
/// 예: report.html → report_20260328_1430.html /// 예: report.html → report_20260328_1430.html
@@ -241,6 +268,32 @@ public class AgentContext
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(GetEffectiveToolPermission(toolName, target)); var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(GetEffectiveToolPermission(toolName, target));
if (PermissionModeCatalog.IsDeny(effectivePerm)) return false; if (PermissionModeCatalog.IsDeny(effectivePerm)) return false;
// ── 사내 모드 보안 강화: 워크스페이스 외부 경로 접근 시 무조건 사용자 승인 ──
// BypassPermissions / AcceptEdits 모드여도 외부 경로는 강제 승인 필요
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode)
&& !string.IsNullOrWhiteSpace(target)
&& IsOutsideWorkspace(target))
{
if (AskPermission == null) return false;
var extTarget = target.Trim();
var extCacheKey = $"ext_ws|{toolName}|{extTarget}";
lock (_permissionLock)
{
if (_approvedPermissionCache.Contains(extCacheKey))
return true;
}
var extAllowed = await AskPermission(toolName, extTarget);
if (extAllowed)
{
lock (_permissionLock)
_approvedPermissionCache.Add(extCacheKey);
}
return extAllowed;
}
if (PermissionModeCatalog.IsAuto(effectivePerm)) return true; if (PermissionModeCatalog.IsAuto(effectivePerm)) return true;
if (AskPermission == null) return false; if (AskPermission == null) return false;
@@ -482,4 +535,5 @@ public enum AgentEventType
StopRequested, // 중단 요청 StopRequested, // 중단 요청
Paused, // 에이전트 일시정지 Paused, // 에이전트 일시정지
Resumed, // 에이전트 재개 Resumed, // 에이전트 재개
UserMessage, // 실행 중 사용자 메시지 주입
} }
@@ -4,19 +4,19 @@ namespace AxCopilot.Services.Agent;
internal interface IToolExecutionCoordinator internal interface IToolExecutionCoordinator
{ {
Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync( Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block, ContentBlock block,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
AgentContext context, AgentContext context,
CancellationToken ct); CancellationToken ct);
Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync( Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct, CancellationToken ct,
string phaseLabel, string phaseLabel,
AgentLoopService.RunState? runState = null, AgentLoopService.RunState? runState = null,
bool forceToolCall = false, bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null, Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null); Func<ToolStreamEvent, Task>? onStreamEventAsync = null);
} }
+240
View File
@@ -0,0 +1,240 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 모든 문서 스킬(PPTX, DOCX, XLSX, HTML)에서 공유하는 내장 아이콘/심볼 라이브러리.
/// 유니코드 심볼 기반이므로 외부 파일 0바이트 — 폰트가 지원하는 한 어디서든 렌더링됩니다.
///
/// 사용 예:
/// var symbol = IconLibrary.Resolve("checkmark"); // "✔"
/// var symbol = IconLibrary.Resolve("rocket"); // "🚀"
/// var symbol = IconLibrary.Resolve("없는이름"); // "❓" (폴백)
/// </summary>
internal static class IconLibrary
{
/// <summary>아이콘 이름으로 유니코드 심볼 문자열을 반환합니다. 없으면 ❓.</summary>
public static string Resolve(string? iconName)
{
if (string.IsNullOrWhiteSpace(iconName)) return "";
return Symbols.TryGetValue(iconName.Trim(), out var symbol) ? symbol : "❓";
}
/// <summary>아이콘 이름이 라이브러리에 존재하는지 확인합니다.</summary>
public static bool Contains(string? iconName)
=> !string.IsNullOrWhiteSpace(iconName) && Symbols.ContainsKey(iconName.Trim());
/// <summary>전체 아이콘 목록 (읽기 전용).</summary>
public static IReadOnlyDictionary<string, string> All => Symbols;
// ══════════════════════════════════════════════════════════════════════════
// 유니코드 심볼 매핑 (170종+)
// ══════════════════════════════════════════════════════════════════════════
private static readonly Dictionary<string, string> Symbols = new(StringComparer.OrdinalIgnoreCase)
{
// ── 상태/알림 ──
["checkmark"] = "✔",
["check"] = "✔",
["check_box"] = "☑",
["warning"] = "⚠",
["info"] = "",
["error"] = "✖",
["question"] = "❓",
["exclamation"] = "❗",
["prohibited"] = "🚫",
["sos"] = "🆘",
["new"] = "🆕",
["free"] = "🆓",
["ok"] = "🆗",
["cool"] = "🆒",
["up"] = "🆙",
["vs"] = "🆚",
["100"] = "💯",
// ── 사람/소통 ──
["people"] = "👥",
["person"] = "👤",
["thumbs_up"] = "👍",
["thumbs_down"] = "👎",
["clap"] = "👏",
["handshake"] = "🤝",
["wave_hand"] = "👋",
["pray"] = "🙏",
["muscle"] = "💪",
["brain"] = "🧠",
["eye"] = "👁",
["speech"] = "💬",
["thought"] = "💭",
// ── 사무/업무 ──
["phone"] = "📞",
["email"] = "📧",
["inbox"] = "📥",
["outbox"] = "📤",
["document"] = "📄",
["documents"] = "📑",
["folder"] = "📁",
["folder_open"] = "📂",
["calendar"] = "📅",
["clipboard"] = "📋",
["pushpin"] = "📌",
["pin"] = "📌",
["paperclip"] = "📎",
["ruler"] = "📏",
["scissors"] = "✂",
["pen"] = "🖊",
["pencil"] = "✏",
["memo"] = "📝",
["book"] = "📖",
["books"] = "📚",
["notebook"] = "📓",
["newspaper"] = "📰",
["label"] = "🏷",
["tag"] = "🏷",
["package"] = "📦",
["mailbox"] = "📬",
["fax"] = "📠",
["printer"] = "🖨",
["computer"] = "💻",
["desktop"] = "🖥",
["keyboard"] = "⌨",
["mouse"] = "🖱",
// ── 시간/시계 ──
["clock"] = "🕐",
["hourglass"] = "⏳",
["stopwatch"] = "⏱",
["alarm"] = "⏰",
["timer"] = "⏲",
// ── 보안/잠금 ──
["lock"] = "🔒",
["unlock"] = "🔓",
["key"] = "🔑",
["shield"] = "🛡",
// ── 검색/탐색 ──
["search"] = "🔍",
["magnify_right"] = "🔎",
["binoculars"] = "🔭",
["compass"] = "🧭",
["map"] = "🗺",
["globe"] = "🌐",
["globe_asia"] = "🌏",
["globe_americas"] = "🌎",
["globe_europe"] = "🌍",
// ── 도구/설정 ──
["wrench"] = "🔧",
["hammer"] = "🔨",
["nut_bolt"] = "🔩",
["link"] = "🔗",
["chain"] = "⛓",
["refresh"] = "🔄",
["recycle"] = "♻",
["gear"] = "⚙",
// ── 성취/보상 ──
["trophy"] = "🏆",
["medal"] = "🏅",
["medal_gold"] = "🥇",
["medal_silver"] = "🥈",
["medal_bronze"] = "🥉",
["crown"] = "👑",
["gem"] = "💎",
// ── 감정/기호 ──
["lightbulb"] = "💡",
["target"] = "🎯",
["rocket"] = "🚀",
["fire"] = "🔥",
["bell"] = "🔔",
["sparkle"] = "✨",
["sparkles"] = "✨",
["rainbow"] = "🌈",
["confetti"] = "🎉",
["party"] = "🎊",
["balloon"] = "🎈",
["gift"] = "🎁",
["heart"] = "❤",
["star"] = "⭐",
["thumbs"] = "👍",
// ── 화살표/방향 ──
["arrow_right"] = "➡",
["arrow_left"] = "⬅",
["arrow_up"] = "⬆",
["arrow_down"] = "⬇",
["arrow_up_right"] = "↗",
["arrow_down_right"]= "↘",
["arrow_curved"] = "↩",
// ── 금융/비즈니스 ──
["money"] = "💰",
["money_bag"] = "💰",
["dollar"] = "💵",
["credit_card"] = "💳",
["receipt"] = "🧾",
["chart_up"] = "📈",
["chart_down"] = "📉",
["bar_chart"] = "📊",
// ── 교통/이동 ──
["car"] = "🚗",
["bus"] = "🚌",
["train"] = "🚆",
["airplane"] = "✈",
["ship"] = "🚢",
["bicycle"] = "🚲",
// ── 건물/장소 ──
["building"] = "🏢",
["hospital"] = "🏥",
["school"] = "🏫",
["factory"] = "🏭",
["house"] = "🏠",
["store"] = "🏪",
["bank"] = "🏦",
// ── 자연/과학 ──
["tree"] = "🌳",
["leaf"] = "🍃",
["flower"] = "🌸",
["seedling"] = "🌱",
["earth"] = "🌍",
["water"] = "💧",
["snowflake"] = "❄",
["atom"] = "⚛",
["dna"] = "🧬",
["microscope"] = "🔬",
["telescope"] = "🔭",
["test_tube"] = "🧪",
["petri_dish"] = "🧫",
// ── 음식 ──
["coffee"] = "☕",
["pizza"] = "🍕",
// ── 수학/기호 ──
["plus"] = "",
["minus"] = "",
["multiply"] = "✖",
["divide"] = "➗",
["infinity"] = "♾",
["copyright"] = "©",
["trademark"] = "™",
["registered"] = "®",
// ── 기타 유용 ──
["battery"] = "🔋",
["plug"] = "🔌",
["magnet"] = "🧲",
["crystal_ball"] = "🔮",
["palette"] = "🎨",
["movie"] = "🎬",
["music"] = "🎵",
["headphone"] = "🎧",
["camera"] = "📷",
["video"] = "📹",
["satellite"] = "📡",
["antenna"] = "📡",
["megaphone"] = "📢",
["loudspeaker"] = "📢",
["mute"] = "🔇",
["speaker"] = "🔊",
["zzz"] = "💤",
["smiley"] = "😊",
["cloud"] = "☁",
["sun"] = "☀",
["moon"] = "🌙",
["lightning"] = "⚡",
["flag"] = "🚩",
["home"] = "🏠",
["cross"] = "✝",
["diamond"] = "💠",
["no_symbol"] = "🚫",
};
}
+163 -16
View File
@@ -5,18 +5,24 @@ namespace AxCopilot.Services.Agent;
/// <summary> /// <summary>
/// LSP 기반 코드 인텔리전스 도구. /// LSP 기반 코드 인텔리전스 도구.
/// 정의 이동(goto_definition), 참조 검색(find_references), 심볼 목록(symbols) 3가지 액션을 제공합니다. /// 정의 이동, 참조 검색, hover, 구현 위치, 심볼 검색, 호출 계층 등 구조적 코드 탐색을 제공합니다.
/// </summary> /// </summary>
public class LspTool : IAgentTool, IDisposable public class LspTool : IAgentTool, IDisposable
{ {
public string Name => "lsp_code_intel"; public string Name => "lsp_code_intel";
public string Description => public string Description =>
"코드 인텔리전스 도구. 정의 이동, 참조 검색, 심볼 목록을 제공합니다.\n" + "코드 인텔리전스 도구. 정의, 참조, hover, 구현 위치, 문서/워크스페이스 심볼, 호출 계층을 제공합니다.\n" +
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다 (파일, 라인, 컬럼)\n" + "- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다\n" +
"- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" + "- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" +
"- action=\"symbols\": 파일 내 모든 심볼(클래스, 메서드, 필드 등)을 나열합니다\n" + "- action=\"hover\": 심볼의 타입/문서 정보를 가져옵니다\n" +
"file_path, line, character 파라미터가 필요합니다 (line과 character는 0-based)."; "- action=\"goto_implementation\": 인터페이스/추상 멤버의 구현 위치를 찾습니다\n" +
"- action=\"symbols\": 파일 내 모든 심볼을 나열합니다\n" +
"- action=\"workspace_symbols\": 워크스페이스 전체 심볼을 검색합니다 (query 권장)\n" +
"- action=\"prepare_call_hierarchy\": 현재 위치의 호출 계층 기준 심볼을 확인합니다\n" +
"- action=\"incoming_calls\": 현재 심볼을 호출하는 상위 호출자를 찾습니다\n" +
"- action=\"outgoing_calls\": 현재 심볼이 호출하는 하위 호출 대상을 찾습니다\n" +
"line/character는 기본적으로 1-based 입력을 기대하며, 0-based 값도 호환 처리합니다.";
public ToolParameterSchema Parameters => new() public ToolParameterSchema Parameters => new()
{ {
@@ -25,23 +31,39 @@ public class LspTool : IAgentTool, IDisposable
["action"] = new ToolProperty ["action"] = new ToolProperty
{ {
Type = "string", Type = "string",
Description = "수행할 작업: goto_definition | find_references | symbols", Description = "수행할 작업",
Enum = new() { "goto_definition", "find_references", "symbols" } Enum = new()
{
"goto_definition",
"find_references",
"hover",
"goto_implementation",
"symbols",
"workspace_symbols",
"prepare_call_hierarchy",
"incoming_calls",
"outgoing_calls"
}
}, },
["file_path"] = new ToolProperty ["file_path"] = new ToolProperty
{ {
Type = "string", Type = "string",
Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로)" Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로). workspace_symbols에서는 작업 폴더 기준 힌트로만 사용 가능."
}, },
["line"] = new ToolProperty ["line"] = new ToolProperty
{ {
Type = "integer", Type = "integer",
Description = "대상 라인 번호 (0-based). symbols 액션에서는 불필요." Description = "대상 라인 번호. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
}, },
["character"] = new ToolProperty ["character"] = new ToolProperty
{ {
Type = "integer", Type = "integer",
Description = "라인 내 문자 위치 (0-based). symbols 액션에서는 불필요." Description = "라인 내 문자 위치. 기본 1-based 입력을 기대하며 내부에서 0-based로 변환합니다. symbols/workspace_symbols에서는 불필요."
},
["query"] = new ToolProperty
{
Type = "string",
Description = "workspace_symbols에서 사용할 심볼 검색어. 비어 있으면 file_path의 파일명/심볼 힌트를 사용합니다."
}, },
}, },
Required = new() { "action", "file_path" } Required = new() { "action", "file_path" }
@@ -59,8 +81,9 @@ public class LspTool : IAgentTool, IDisposable
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : ""; var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : ""; var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
var line = args.SafeTryGetProperty("line", out var l) ? l.GetInt32() : 0; var line = args.SafeTryGetProperty("line", out var l) ? NormalizePosition(l.SafeGetInt32()) : 0;
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0; var character = args.SafeTryGetProperty("character", out var ch) ? NormalizePosition(ch.SafeGetInt32()) : 0;
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(filePath)) if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다."); return ToolResult.Fail("file_path가 필요합니다.");
@@ -88,8 +111,14 @@ public class LspTool : IAgentTool, IDisposable
{ {
"goto_definition" => await GotoDefinitionAsync(client, filePath, line, character, ct), "goto_definition" => await GotoDefinitionAsync(client, filePath, line, character, ct),
"find_references" => await FindReferencesAsync(client, filePath, line, character, ct), "find_references" => await FindReferencesAsync(client, filePath, line, character, ct),
"hover" => await HoverAsync(client, filePath, line, character, ct),
"goto_implementation" => await GotoImplementationAsync(client, filePath, line, character, ct),
"symbols" => await GetSymbolsAsync(client, filePath, ct), "symbols" => await GetSymbolsAsync(client, filePath, ct),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. goto_definition | find_references | symbols 중 선택하세요.") "workspace_symbols" => await GetWorkspaceSymbolsAsync(client, filePath, query, ct),
"prepare_call_hierarchy" => await PrepareCallHierarchyAsync(client, filePath, line, character, ct),
"incoming_calls" => await GetIncomingCallsAsync(client, filePath, line, character, ct),
"outgoing_calls" => await GetOutgoingCallsAsync(client, filePath, line, character, ct),
_ => ToolResult.Fail("알 수 없는 action입니다. goto_definition | find_references | hover | goto_implementation | symbols | workspace_symbols | prepare_call_hierarchy | incoming_calls | outgoing_calls 중 선택하세요.")
}; };
} }
catch (Exception ex) catch (Exception ex)
@@ -118,13 +147,44 @@ public class LspTool : IAgentTool, IDisposable
return ToolResult.Ok("참조를 찾을 수 없습니다."); return ToolResult.Ok("참조를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 참조:"); sb.AppendLine($"총 {locations.Count}개 참조");
sb.AppendLine($"파일 수: {locations.Select(l => l.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
sb.AppendLine($"첫 참조: {locations[0]}");
sb.AppendLine();
foreach (var loc in locations.Take(30)) foreach (var loc in locations.Take(30))
sb.AppendLine($" {loc}"); sb.AppendLine($" {loc}");
if (locations.Count > 30) if (locations.Count > 30)
sb.AppendLine($" ... 외 {locations.Count - 30}개"); sb.AppendLine($" ... 외 {locations.Count - 30}개");
return ToolResult.Ok(sb.ToString()); return ToolResult.Ok(sb.ToString(), locations[0].FilePath);
}
private async Task<ToolResult> HoverAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var hover = await client.HoverAsync(filePath, line, character, ct);
if (string.IsNullOrWhiteSpace(hover))
return ToolResult.Ok("hover 정보를 찾을 수 없습니다.");
return ToolResult.Ok($"Hover 정보\n위치: {Path.GetFileName(filePath)}:{line + 1}:{character + 1}\n\n{hover}", filePath);
}
private async Task<ToolResult> GotoImplementationAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var locations = await client.GotoImplementationAsync(filePath, line, character, ct);
if (locations.Count == 0)
return ToolResult.Ok("구현 위치를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 구현 위치");
sb.AppendLine($"파일 수: {locations.Select(l => l.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
sb.AppendLine($"첫 구현: {locations[0]}");
sb.AppendLine();
foreach (var loc in locations.Take(20))
sb.AppendLine($" {loc}");
if (locations.Count > 20)
sb.AppendLine($" ... 외 {locations.Count - 20}개");
return ToolResult.Ok(sb.ToString(), locations[0].FilePath);
} }
private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct) private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct)
@@ -134,13 +194,92 @@ public class LspTool : IAgentTool, IDisposable
return ToolResult.Ok("심볼을 찾을 수 없습니다."); return ToolResult.Ok("심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {symbols.Count}개 심볼:"); sb.AppendLine($"총 {symbols.Count}개 심볼");
sb.AppendLine($"파일: {Path.GetFileName(filePath)}");
sb.AppendLine();
foreach (var sym in symbols) foreach (var sym in symbols)
sb.AppendLine($" {sym}"); sb.AppendLine($" {sym}");
return ToolResult.Ok(sb.ToString()); return ToolResult.Ok(sb.ToString());
} }
private async Task<ToolResult> GetWorkspaceSymbolsAsync(LspClientService client, string filePath, string query, CancellationToken ct)
{
var fallbackQuery = !string.IsNullOrWhiteSpace(query)
? query
: Path.GetFileNameWithoutExtension(filePath);
var symbols = await client.SearchWorkspaceSymbolsAsync(fallbackQuery, ct);
if (symbols.Count == 0)
return ToolResult.Ok($"워크스페이스 심볼을 찾을 수 없습니다. query=\"{fallbackQuery}\"");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"query=\"{fallbackQuery}\" 결과 {symbols.Count}개");
sb.AppendLine($"파일 수: {symbols.Select(s => s.Location?.FilePath).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase).Count()}");
if (symbols.FirstOrDefault() is { } firstSymbol)
sb.AppendLine($"첫 결과: {firstSymbol}");
sb.AppendLine();
foreach (var sym in symbols.Take(30))
sb.AppendLine($" {sym}");
if (symbols.Count > 30)
sb.AppendLine($" ... 외 {symbols.Count - 30}개");
return ToolResult.Ok(sb.ToString(), symbols.FirstOrDefault(s => s.Location != null)?.Location?.FilePath);
}
private async Task<ToolResult> PrepareCallHierarchyAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var items = await client.PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return ToolResult.Ok("호출 계층 기준 심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"호출 계층 기준 {items.Count}개");
sb.AppendLine($"대표 심볼: {items[0]}");
sb.AppendLine();
foreach (var item in items.Take(10))
sb.AppendLine($" {item}");
if (items.Count > 10)
sb.AppendLine($" ... 외 {items.Count - 10}개");
return ToolResult.Ok(sb.ToString(), items[0].Location.FilePath);
}
private async Task<ToolResult> GetIncomingCallsAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var calls = await client.GetIncomingCallsAsync(filePath, line, character, ct);
if (calls.Count == 0)
return ToolResult.Ok("incoming call을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"상위 호출자 {calls.Count}개");
sb.AppendLine($"대표 호출자: {calls[0]}");
sb.AppendLine();
foreach (var call in calls.Take(20))
sb.AppendLine($" {call}");
if (calls.Count > 20)
sb.AppendLine($" ... 외 {calls.Count - 20}개");
return ToolResult.Ok(sb.ToString(), calls[0].Location.FilePath);
}
private async Task<ToolResult> GetOutgoingCallsAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var calls = await client.GetOutgoingCallsAsync(filePath, line, character, ct);
if (calls.Count == 0)
return ToolResult.Ok("outgoing call을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"하위 호출 대상 {calls.Count}개");
sb.AppendLine($"첫 호출 대상: {calls[0]}");
sb.AppendLine();
foreach (var call in calls.Take(20))
sb.AppendLine($" {call}");
if (calls.Count > 20)
sb.AppendLine($" ... 외 {calls.Count - 20}개");
return ToolResult.Ok(sb.ToString(), calls[0].Location.FilePath);
}
private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct) private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct)
{ {
if (_clients.TryGetValue(language, out var existing) && existing.IsConnected) if (_clients.TryGetValue(language, out var existing) && existing.IsConnected)
@@ -173,6 +312,14 @@ public class LspTool : IAgentTool, IDisposable
}; };
} }
private static int NormalizePosition(int value)
{
if (value <= 0)
return 0;
return value - 1;
}
public void Dispose() public void Dispose()
{ {
foreach (var client in _clients.Values) foreach (var client in _clients.Values)
@@ -58,8 +58,8 @@ public static class ModelExecutionProfileCatalog
NoToolResponseThreshold: 1, NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 4, // IBM/Qwen 등 chatty 모델: 재시도 횟수 늘려 도구 호출 강제 NoToolRecoveryMaxRetries: 4, // IBM/Qwen 등 chatty 모델: 재시도 횟수 늘려 도구 호출 강제
PlanExecutionRetryMax: 2, PlanExecutionRetryMax: 2,
DocumentPlanRetryMax: 2, DocumentPlanRetryMax: 0,
PreferAggressiveDocumentFallback: true, PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: true, ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false, EnablePostToolVerification: false,
EnableCodeQualityGates: true, EnableCodeQualityGates: true,
@@ -87,18 +87,18 @@ public static class ModelExecutionProfileCatalog
DocumentPlanRetryMax: 2, DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: false, PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false, ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true, EnablePostToolVerification: false,
EnableCodeQualityGates: true, EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true, EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true, EnableParallelReadBatch: true,
MaxParallelReadBatch: 6, MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2, CodeVerificationGateMaxRetries: 1,
HighImpactBuildTestGateMaxRetries: 1, HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1, FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 1, CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 1, RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 1, ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 1, DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1), TerminalEvidenceGateMaxRetries: 1),
"fast_readonly" => new ExecutionPolicy( "fast_readonly" => new ExecutionPolicy(
"fast_readonly", "fast_readonly",
@@ -128,8 +128,8 @@ public static class ModelExecutionProfileCatalog
"document_heavy" => new ExecutionPolicy( "document_heavy" => new ExecutionPolicy(
"document_heavy", "document_heavy",
"문서 생성 우선", "문서 생성 우선",
ForceInitialToolCall: true, ForceInitialToolCall: false,
ForceToolCallAfterPlan: true, ForceToolCallAfterPlan: false,
ToolTemperatureCap: 0.35, ToolTemperatureCap: 0.35,
NoToolResponseThreshold: 1, NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1, NoToolRecoveryMaxRetries: 1,
@@ -154,26 +154,26 @@ public static class ModelExecutionProfileCatalog
"balanced", "balanced",
"균형", "균형",
ForceInitialToolCall: true, ForceInitialToolCall: true,
ForceToolCallAfterPlan: true, ForceToolCallAfterPlan: false,
ToolTemperatureCap: 0.35, ToolTemperatureCap: 0.35,
NoToolResponseThreshold: 2, NoToolResponseThreshold: 2,
NoToolRecoveryMaxRetries: 2, NoToolRecoveryMaxRetries: 2,
PlanExecutionRetryMax: 2, PlanExecutionRetryMax: 2,
DocumentPlanRetryMax: 2, DocumentPlanRetryMax: 0,
PreferAggressiveDocumentFallback: false, PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false, ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true, EnablePostToolVerification: false,
EnableCodeQualityGates: true, EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true, EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true, EnableParallelReadBatch: true,
MaxParallelReadBatch: 6, MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2, CodeVerificationGateMaxRetries: 1,
HighImpactBuildTestGateMaxRetries: 1, HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1, FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 1, CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 1, RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 1, ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 1, DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1), TerminalEvidenceGateMaxRetries: 1),
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -165,7 +165,7 @@ internal static class PptxToHtmlConverter
<meta charset='UTF-8'> <meta charset='UTF-8'>
<style> <style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a; body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a;
line-height: 1.6; padding: 20px; font-size: 13px; }} line-height: 1.6; padding: 20px; font-size: 13px; }}
.slide-info {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; text-align: center; }} .slide-info {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; text-align: center; }}
.slide-card {{ background: #fff; border-radius: 12px; padding: 28px 32px; margin-bottom: 16px; .slide-card {{ background: #fff; border-radius: 12px; padding: 28px 32px; margin-bottom: 16px;
+3 -2
View File
@@ -282,8 +282,9 @@ public static class SkillService
. .
: :
1. file_read 1. glob/grep
2. folder_map () 2. file_read
3. folder_map
: :
- -
@@ -11,13 +11,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
{ {
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{ {
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read", "file_read", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool", "dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
"hash_tool", "image_analyze", "multi_read" "hash_tool", "image_analyze"
}; };
private readonly LlmService _llm; private readonly ILlmService _llm;
private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName; private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName;
private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync; private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync;
private readonly Action<AgentEventType, string, string> _emitEvent; private readonly Action<AgentEventType, string, string> _emitEvent;
@@ -27,7 +27,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs; private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
public StreamingToolExecutionCoordinator( public StreamingToolExecutionCoordinator(
LlmService llm, ILlmService llm,
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName, Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync, Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
Action<AgentEventType, string, string> emitEvent, Action<AgentEventType, string, string> emitEvent,
@@ -46,8 +46,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs; _computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
} }
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync( public async Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block, ContentBlock block,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
AgentContext context, AgentContext context,
CancellationToken ct) CancellationToken ct)
@@ -70,57 +70,60 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement; var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await _executeToolAsync(resolvedToolName, input, context, null, ct); var result = await _executeToolAsync(resolvedToolName, input, context, null, ct);
sw.Stop(); sw.Stop();
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
} }
catch (Exception ex) catch (Exception ex)
{ {
sw.Stop(); sw.Stop();
return new LlmService.ToolPrefetchResult( return new ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"), ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
sw.ElapsedMilliseconds, sw.ElapsedMilliseconds,
resolvedToolName); resolvedToolName);
} }
} }
public async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync( public async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools, IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct, CancellationToken ct,
string phaseLabel, string phaseLabel,
AgentLoopService.RunState? runState = null, AgentLoopService.RunState? runState = null,
bool forceToolCall = false, bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null, Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null) Func<ToolStreamEvent, Task>? onStreamEventAsync = null)
{ {
var transientRetries = runState?.TransientLlmErrorRetries ?? 0; var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0; var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
while (true) while (true)
{ {
var streamedAnyPartialState = false;
try try
{ {
if (onStreamEventAsync == null) if (onStreamEventAsync == null)
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync); return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
var blocks = new List<LlmService.ContentBlock>(); var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder(); var textBuilder = new StringBuilder();
await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct)) await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct))
{ {
await onStreamEventAsync(evt); await onStreamEventAsync(evt);
if (evt.Kind == LlmService.ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
{ {
streamedAnyPartialState = true;
textBuilder.Append(evt.Text); textBuilder.Append(evt.Text);
} }
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null) else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{ {
streamedAnyPartialState = true;
blocks.Add(evt.ToolCall); blocks.Add(evt.ToolCall);
} }
} }
var result = new List<LlmService.ContentBlock>(); var result = new List<ContentBlock>();
var text = textBuilder.ToString().Trim(); var text = textBuilder.ToString().Trim();
if (!string.IsNullOrWhiteSpace(text)) if (!string.IsNullOrWhiteSpace(text))
result.Add(new LlmService.ContentBlock { Type = "text", Text = text }); result.Add(new ContentBlock { Type = "text", Text = text });
result.AddRange(blocks); result.AddRange(blocks);
return result; return result;
} }
@@ -130,6 +133,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
&& contextRecoveryRetries < 2 && contextRecoveryRetries < 2
&& _forceContextRecovery(messages)) && _forceContextRecovery(messages))
{ {
if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
contextRecoveryRetries++; contextRecoveryRetries++;
if (runState != null) if (runState != null)
runState.ContextRecoveryAttempts = contextRecoveryRetries; runState.ContextRecoveryAttempts = contextRecoveryRetries;
@@ -146,6 +151,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
if (_isTransientLlmError(ex) && transientRetries < 3) if (_isTransientLlmError(ex) && transientRetries < 3)
{ {
if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"));
transientRetries++; transientRetries++;
if (runState != null) if (runState != null)
runState.TransientLlmErrorRetries = transientRetries; runState.TransientLlmErrorRetries = transientRetries;
+22 -17
View File
@@ -22,21 +22,22 @@ internal sealed class TaskTypePolicy
TaskType = "bugfix", TaskType = "bugfix",
GuidanceMessage = GuidanceMessage =
"[System:TaskType] This is a bug-fix task. Prioritize reproduction evidence, root cause linkage, smallest safe fix, and regression verification. " + "[System:TaskType] This is a bug-fix task. Prioritize reproduction evidence, root cause linkage, smallest safe fix, and regression verification. " +
"Preferred tool order: file_read -> grep/glob -> build_run/test_loop -> file_edit -> build_run/test_loop.", "Preferred tool order: targeted file_read or grep/glob/lsp -> file_edit -> build_run/test_loop as needed -> git_tool(diff) when it helps confirm the final change.",
FailurePatternFocus = "재현 조건과 원인 연결을 먼저 확인하세요. Check reproduction conditions and root-cause linkage first.", FailurePatternFocus = "Check reproduction conditions and root-cause linkage first.",
FollowUpTaskLine = "작업 유형: bugfix. Task type: bugfix. Verify the fix is directly linked to the symptom and confirm non-regression.\n", FollowUpTaskLine = "Task type: bugfix. Verify the fix is directly linked to the symptom and confirm non-regression.\n",
FailureInvestigationTaskLine = "추가 점검: 재현 조건 기준으로 증상이 재현되지 않는지와 원인 연결이 타당한지 확인하세요. Extra check: confirm the symptom is no longer reproducible and root-cause linkage is valid.\n", FailureInvestigationTaskLine = "Extra check: confirm the symptom is no longer reproducible and root-cause linkage is valid.\n",
FinalReportTaskLine = "버그 수정은 원인, 수정 내용, 재현/회귀 검증 근거를 포함하세요. For bug fixes, include root cause, change summary, and reproduction/regression evidence.\n", FinalReportTaskLine = "For bug fixes, include root cause, change summary, and reproduction/regression evidence.\n",
}, },
"feature" => new TaskTypePolicy "feature" => new TaskTypePolicy
{ {
TaskType = "feature", TaskType = "feature",
GuidanceMessage = GuidanceMessage =
"[System:TaskType] This is a feature task. Prioritize affected interfaces/callers, data flow, validation paths, and test/documentation needs. " + "[System:TaskType] This is a feature task. Prioritize affected interfaces/callers, data flow, validation paths, and test/documentation needs. " +
"Preferred tool order: folder_map -> file_read -> grep/glob -> file_edit -> build_run/test_loop.", "Preferred tool order: targeted file_read or grep/glob/lsp -> file_edit/file_write -> build_run/test_loop as needed -> git_tool(diff). " +
"Use folder_map only when the user explicitly needs folder structure or file listing.",
FailurePatternFocus = "Check new behavior flow and caller linkage first.", FailurePatternFocus = "Check new behavior flow and caller linkage first.",
FollowUpTaskLine = "작업 유형: feature. Task type: feature. Verify behavior flow, input/output path, caller impact, and test additions.\n", FollowUpTaskLine = "Task type: feature. Verify behavior flow, input/output path, caller impact, and test additions.\n",
FailureInvestigationTaskLine = "추가 점검: 새 기능 경로와 호출부 연결이 의도대로 동작하는지 확인하세요. Extra check: confirm feature path and caller linkage behave as intended.\n", FailureInvestigationTaskLine = "Extra check: confirm feature path and caller linkage behave as intended.\n",
FinalReportTaskLine = "For features, include behavior flow, impacted files/callers, and verification evidence.\n", FinalReportTaskLine = "For features, include behavior flow, impacted files/callers, and verification evidence.\n",
}, },
"refactor" => new TaskTypePolicy "refactor" => new TaskTypePolicy
@@ -44,10 +45,10 @@ internal sealed class TaskTypePolicy
TaskType = "refactor", TaskType = "refactor",
GuidanceMessage = GuidanceMessage =
"[System:TaskType] This is a refactor task. Prioritize behavior preservation, reference impact, diff review, and non-regression evidence. " + "[System:TaskType] This is a refactor task. Prioritize behavior preservation, reference impact, diff review, and non-regression evidence. " +
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop.", "Preferred tool order: targeted file_read or grep/glob/lsp -> file_edit -> build_run/test_loop as needed -> git_tool(diff).",
FailurePatternFocus = "Check behavior preservation and impact scope first.", FailurePatternFocus = "Check behavior preservation and impact scope first.",
FollowUpTaskLine = "작업 유형: refactor. Task type: refactor. Prioritize behavior-preservation evidence over cosmetic cleanup.\n", FollowUpTaskLine = "Task type: refactor. Prioritize behavior-preservation evidence over cosmetic cleanup.\n",
FailureInvestigationTaskLine = "추가 점검: 동작 보존 관점에서 기존 호출 흐름이 동일하게 유지되는지 확인하세요. Extra check: validate existing call flow remains behavior-compatible.\n", FailureInvestigationTaskLine = "Extra check: validate existing call flow remains behavior-compatible.\n",
FinalReportTaskLine = "For refactors, include behavior-preservation evidence and impact scope.\n", FinalReportTaskLine = "For refactors, include behavior-preservation evidence and impact scope.\n",
}, },
"review" => new TaskTypePolicy "review" => new TaskTypePolicy
@@ -56,10 +57,10 @@ internal sealed class TaskTypePolicy
GuidanceMessage = GuidanceMessage =
"[System:TaskType] This is a review task. Prioritize concrete defects, regressions, risky assumptions, and missing tests before summaries. " + "[System:TaskType] This is a review task. Prioritize concrete defects, regressions, risky assumptions, and missing tests before summaries. " +
"Report findings with P0-P3 severity and file evidence, then separate Fixed vs Unfixed status. " + "Report findings with P0-P3 severity and file evidence, then separate Fixed vs Unfixed status. " +
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> evidence-first findings.", "Preferred tool order: targeted file_read or grep/glob/lsp -> git_tool(diff) when available -> evidence-first findings.",
FailurePatternFocus = "Review focus: severity accuracy (P0-P3), file-grounded evidence, and unresolved-risk clarity.", FailurePatternFocus = "Review focus: severity accuracy (P0-P3), file-grounded evidence, and unresolved-risk clarity.",
FollowUpTaskLine = "작업 유형: review-follow-up. Task type: review-follow-up. For each finding, state status as Fixed or Unfixed with verification evidence.\n", FollowUpTaskLine = "Task type: review-follow-up. For each finding, state status as Fixed or Unfixed with verification evidence.\n",
FailureInvestigationTaskLine = "추가 점검: 리뷰에서 지적된 위험은 반드시 수정 근거나 미해결 사유/영향을 남기세요. Extra check: every risk must have either a concrete fix or an explicit unresolved rationale and impact.\n", FailureInvestigationTaskLine = "Extra check: every risk must have either a concrete fix or an explicit unresolved rationale and impact.\n",
FinalReportTaskLine = "For review, list P0-P3 findings with file evidence and split into Fixed vs Unfixed with residual risk.\n", FinalReportTaskLine = "For review, list P0-P3 findings with file evidence and split into Fixed vs Unfixed with residual risk.\n",
IsReviewTask = true IsReviewTask = true
}, },
@@ -67,8 +68,12 @@ internal sealed class TaskTypePolicy
{ {
TaskType = "docs", TaskType = "docs",
GuidanceMessage = GuidanceMessage =
"[System:TaskType] This is a document/content task. Prioritize source evidence, completeness, consistency, and self-review. " + "[System:TaskType] This is a document/content task. " +
"Preferred tool order: folder_map -> document_read/file_read -> drafting tool -> self-review.", "If the user asks you to create or write a new document, skip file exploration entirely and go directly to the creation tool (docx_create, html_create, excel_create, etc.). " +
"Use document_plan only when it materially helps structure a multi-section document. " +
"Prefer producing a real file on disk when the request is clearly for a deliverable, but do not force file creation for analysis-only or advisory requests. " +
"If the user asks you to read or analyze existing documents, use: glob/grep -> document_read/file_read -> analysis. " +
"Use folder_map only when the user explicitly asks for folder contents or directory structure.",
FailurePatternFocus = "Check source evidence and document completeness first.", FailurePatternFocus = "Check source evidence and document completeness first.",
FollowUpTaskLine = "", FollowUpTaskLine = "",
FailureInvestigationTaskLine = "", FailureInvestigationTaskLine = "",
@@ -77,7 +82,7 @@ internal sealed class TaskTypePolicy
_ => new TaskTypePolicy _ => new TaskTypePolicy
{ {
TaskType = "general", TaskType = "general",
GuidanceMessage = "[System:TaskType] Use a cautious analyze -> implement -> verify workflow and do not finish without concrete evidence.", GuidanceMessage = "[System:TaskType] Use a cautious analyze -> selective discovery -> implement -> verify workflow. Prefer targeted file_read, grep/glob, or lsp_code_intel before broad scans, and do not finish without concrete evidence.",
FailurePatternFocus = "Check recent failure patterns first.", FailurePatternFocus = "Check recent failure patterns first.",
FollowUpTaskLine = "", FollowUpTaskLine = "",
FailureInvestigationTaskLine = "", FailureInvestigationTaskLine = "",
@@ -90,7 +90,7 @@ public class TemplateRenderTool : IAgentTool
await TextFileCodec.WriteAllTextAsync(outputPath, rendered, TextFileCodec.Utf8NoBom, ct); await TextFileCodec.WriteAllTextAsync(outputPath, rendered, TextFileCodec.Utf8NoBom, ct);
return ToolResult.Ok( return ToolResult.Ok(
$"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)", $"✅ 템플릿 렌더링 완료: {outputPath} ({rendered.Length:N0}자)",
outputPath); outputPath);
} }
@@ -182,7 +182,7 @@ public static class TemplateService
private const string CssModern = """ private const string CssModern = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; } background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; .container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 16px; padding: 56px 52px; border-radius: 16px; padding: 56px 52px;
@@ -218,7 +218,7 @@ public static class TemplateService
#region Professional #region Professional
private const string CssProfessional = """ private const string CssProfessional = """
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; body { font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; } background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; .container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 8px; padding: 48px; border-radius: 8px; padding: 48px;
@@ -257,7 +257,7 @@ public static class TemplateService
private const string CssCreative = """ private const string CssCreative = """
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif; body { font-family: 'Poppins', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; } min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95); .container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95);
@@ -336,7 +336,7 @@ public static class TemplateService
private const string CssElegant = """ private const string CssElegant = """
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif; body { font-family: 'Source Sans 3', 'Noto Sans KR', sans-serif;
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; } background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; .container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 4px; padding: 56px 52px; border-radius: 4px; padding: 56px 52px;
@@ -375,7 +375,7 @@ public static class TemplateService
private const string CssDark = """ private const string CssDark = """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; } background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #161b22; .container { max-width: 1080px; margin: 0 auto; background: #161b22;
border-radius: 12px; padding: 52px; border-radius: 12px; padding: 52px;
@@ -415,7 +415,7 @@ public static class TemplateService
private const string CssColorful = """ private const string CssColorful = """
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif; body { font-family: 'Nunito', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%); background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; } min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; .container { max-width: 1080px; margin: 0 auto; background: #fff;
@@ -455,7 +455,7 @@ public static class TemplateService
#region Corporate #region Corporate
private const string CssCorporate = """ private const string CssCorporate = """
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif; body { font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; } background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0; .container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); } box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
@@ -464,6 +464,9 @@ public static class TemplateService
.header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; } .header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; }
.header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; } .header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; }
.body-content { padding: 36px 40px 40px; } .body-content { padding: 36px 40px 40px; }
.cover-page { margin: -36px -40px 40px -40px !important; border-radius: 0 !important;
width: auto !important; box-sizing: border-box !important;
left: 0; right: 0; }
h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; } h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; }
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366; h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366;
border-left: 4px solid #ff6600; padding-left: 12px; } border-left: 4px solid #ff6600; padding-left: 12px; }
@@ -497,7 +500,7 @@ public static class TemplateService
private const string CssMagazine = """ private const string CssMagazine = """
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif; body { font-family: 'Open Sans', 'Noto Sans KR', sans-serif;
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; } background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; .container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 2px; padding: 0; overflow: hidden; border-radius: 2px; padding: 0; overflow: hidden;
@@ -548,7 +551,7 @@ public static class TemplateService
private const string CssDashboard = """ private const string CssDashboard = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif; body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; } background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; }
.container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; } .container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; }
h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; } h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; }
+168 -1
View File
@@ -62,7 +62,174 @@ public class ToolRegistry : IDisposable
if (disabledNames == null) return All; if (disabledNames == null) return All;
var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase); var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
if (disabled.Count == 0) return All; if (disabled.Count == 0) return All;
return _tools.Values.Where(t => !disabled.Contains(t.Name)).ToList().AsReadOnly(); return OrderToolsForExposure(_tools.Values.Where(t => !disabled.Contains(t.Name)))
.ToList()
.AsReadOnly();
}
/// <summary>비활성 도구를 제외하고 현재 탭에 해당하는 도구만 반환합니다.</summary>
public IReadOnlyCollection<IAgentTool> GetActiveToolsForTab(string activeTab, IEnumerable<string>? disabledNames = null)
{
var disabled = disabledNames != null
? new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase)
: null;
return OrderToolsForExposure(_tools.Values.Where(t =>
{
if (disabled != null && disabled.Contains(t.Name)) return false;
return IsToolAvailableForTab(t, activeTab);
})).ToList().AsReadOnly();
}
private static IEnumerable<IAgentTool> OrderToolsForExposure(IEnumerable<IAgentTool> tools)
{
return tools
.OrderBy(GetToolExposureBucket)
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase);
}
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
};
}
/// <summary>도구가 해당 탭에서 사용 가능한지 확인합니다.</summary>
private static bool IsToolAvailableForTab(IAgentTool tool, string activeTab)
{
var category = ResolveTabCategory(tool);
if (string.IsNullOrEmpty(category)) return true; // null = 모든 탭
// 쉼표 구분 복수 탭: "Cowork,Code"
foreach (var part in category.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (string.Equals(part, activeTab, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// 도구별 탭 카테고리 오버라이드.
/// IAgentTool.TabCategory가 null인 도구는 이 맵을 참조합니다.
/// 키: 도구 이름, 값: 허용 탭 (쉼표 구분). 맵에 없으면 = 모든 탭.
/// </summary>
private static readonly Dictionary<string, string> 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",
};
/// <summary>도구의 실질 탭 카테고리를 결정합니다 (IAgentTool.TabCategory → 오버라이드 맵 순).</summary>
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;
} }
/// <summary>IDisposable 도구를 모두 해제합니다.</summary> /// <summary>IDisposable 도구를 모두 해제합니다.</summary>
@@ -202,7 +202,7 @@ internal static class XlsxToHtmlConverter
<meta charset='UTF-8'> <meta charset='UTF-8'>
<style> <style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a; body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.5; padding: 16px; font-size: 12.5px; }} line-height: 1.5; padding: 16px; font-size: 12.5px; }}
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }} .sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer; .sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;
+2 -2
View File
@@ -9,7 +9,7 @@ namespace AxCopilot.Services;
/// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아 /// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아
/// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다. /// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다.
/// </summary> /// </summary>
public sealed class AppStateService public sealed class AppStateService : IAppStateService
{ {
public sealed class SkillCatalogState public sealed class SkillCatalogState
{ {
@@ -212,7 +212,7 @@ public sealed class AppStateService
NotifyStateChanged(); NotifyStateChanged();
} }
public void LoadFromSettings(SettingsService settings) public void LoadFromSettings(ISettingsService settings)
{ {
var llm = settings.Settings.Llm; var llm = settings.Settings.Llm;
Skills.Enabled = llm.EnableSkillSystem; Skills.Enabled = llm.EnableSkillSystem;
@@ -51,7 +51,7 @@ public sealed class ChatSessionStateService
return CurrentConversation; return CurrentConversation;
} }
public void Load(SettingsService settings) public void Load(ISettingsService settings)
{ {
var llm = settings.Settings.Llm; var llm = settings.Settings.Llm;
ActiveTab = NormalizeTab(llm.LastActiveTab); ActiveTab = NormalizeTab(llm.LastActiveTab);
@@ -67,7 +67,7 @@ public sealed class ChatSessionStateService
} }
} }
public void Save(SettingsService settings) public void Save(ISettingsService settings)
{ {
var llm = settings.Settings.Llm; var llm = settings.Settings.Llm;
llm.LastActiveTab = NormalizeTab(ActiveTab); llm.LastActiveTab = NormalizeTab(ActiveTab);
@@ -87,7 +87,7 @@ public sealed class ChatSessionStateService
TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId; TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId;
} }
public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings) public ChatConversation LoadOrCreateConversation(string tab, IChatStorageService storage, ISettingsService settings)
{ {
var normalizedTab = NormalizeTab(tab); var normalizedTab = NormalizeTab(tab);
var rememberedId = GetConversationId(normalizedTab); var rememberedId = GetConversationId(normalizedTab);
@@ -153,13 +153,19 @@ public sealed class ChatSessionStateService
return CreateFreshConversation(normalizedTab, settings); return CreateFreshConversation(normalizedTab, settings);
} }
public ChatConversation CreateFreshConversation(string tab, SettingsService settings) public ChatConversation CreateFreshConversation(string tab, ISettingsService settings)
{ {
var normalizedTab = NormalizeTab(tab); var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab }; var created = new ChatConversation { Tab = normalizedTab };
var workFolder = settings.Settings.Llm.WorkFolder;
if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat") // Code/Cowork 탭: 매 대화마다 폴더를 새로 선택하도록 빈 상태로 시작
created.WorkFolder = workFolder; if (string.Equals(normalizedTab, "Code", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTab, "Cowork", StringComparison.OrdinalIgnoreCase))
{
created.WorkFolder = "";
CurrentConversation = created;
return created;
}
CurrentConversation = created; CurrentConversation = created;
return created; return created;
@@ -239,7 +245,7 @@ public sealed class ChatSessionStateService
return fork; return fork;
} }
public void SaveCurrentConversation(ChatStorageService storage, string tab) public void SaveCurrentConversation(IChatStorageService storage, string tab)
{ {
var conv = CurrentConversation; var conv = CurrentConversation;
if (conv == null) return; if (conv == null) return;
@@ -261,7 +267,7 @@ public sealed class ChatSessionStateService
RememberConversation(tab, null); RememberConversation(tab, null);
} }
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true) public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, IChatStorageService? storage = null, bool remember = true)
{ {
var normalizedTab = NormalizeTab(tab); var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab; conversation.Tab = normalizedTab;
@@ -275,7 +281,7 @@ public sealed class ChatSessionStateService
return conversation; return conversation;
} }
public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false) public ChatMessage AppendMessage(string tab, ChatMessage message, IChatStorageService? storage = null, bool useForTitle = false)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.Messages.Add(message); conv.Messages.Add(message);
@@ -294,7 +300,7 @@ public sealed class ChatSessionStateService
public ChatConversation UpdateConversationMetadata( public ChatConversation UpdateConversationMetadata(
string tab, string tab,
Action<ChatConversation> apply, Action<ChatConversation> apply,
ChatStorageService? storage = null, IChatStorageService? storage = null,
bool ensureConversation = true) bool ensureConversation = true)
{ {
var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) }); var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) });
@@ -311,7 +317,7 @@ public sealed class ChatSessionStateService
string? dataUsage, string? dataUsage,
string? outputFormat, string? outputFormat,
string? mood, string? mood,
ChatStorageService? storage = null) IChatStorageService? storage = null)
{ {
return UpdateConversationMetadata(tab, conv => return UpdateConversationMetadata(tab, conv =>
{ {
@@ -322,7 +328,7 @@ public sealed class ChatSessionStateService
}, storage); }, storage);
} }
public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null) public bool RemoveLastAssistantMessage(string tab, IChatStorageService? storage = null)
{ {
var conv = CurrentConversation; var conv = CurrentConversation;
if (conv == null || conv.Messages.Count == 0) if (conv == null || conv.Messages.Count == 0)
@@ -336,7 +342,7 @@ public sealed class ChatSessionStateService
return true; return true;
} }
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null) public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, IChatStorageService? storage = null)
{ {
var conv = CurrentConversation; var conv = CurrentConversation;
if (conv == null) if (conv == null)
@@ -353,7 +359,7 @@ public sealed class ChatSessionStateService
return true; return true;
} }
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null) public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, IChatStorageService? storage = null)
{ {
var conv = CurrentConversation; var conv = CurrentConversation;
if (conv == null) if (conv == null)
@@ -368,7 +374,7 @@ public sealed class ChatSessionStateService
return true; return true;
} }
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null) public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, IChatStorageService? storage = null)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.ExecutionEvents ??= new List<ChatExecutionEvent>(); conv.ExecutionEvents ??= new List<ChatExecutionEvent>();
@@ -417,7 +423,7 @@ public sealed class ChatSessionStateService
return conv; return conv;
} }
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null) public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, IChatStorageService? storage = null)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.AgentRunHistory ??= new List<ChatAgentRunRecord>(); conv.AgentRunHistory ??= new List<ChatAgentRunRecord>();
@@ -448,7 +454,7 @@ public sealed class ChatSessionStateService
return conv; return conv;
} }
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null, string kind = "message") public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", IChatStorageService? storage = null, string kind = "message")
{ {
var trimmed = text?.Trim() ?? ""; var trimmed = text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(trimmed)) if (string.IsNullOrWhiteSpace(trimmed))
@@ -480,16 +486,16 @@ public sealed class ChatSessionStateService
return (conv.DraftQueueItems ?? []).ToList(); return (conv.DraftQueueItems ?? []).ToList();
} }
public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null) public bool MarkDraftRunning(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage); => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage);
public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null) public bool MarkDraftCompleted(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage); => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage);
public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null) public bool MarkDraftFailed(string tab, string draftId, string? error, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage); => UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage);
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null) public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, IChatStorageService? storage = null)
{ {
return UpdateDraftItem(tab, draftId, item => return UpdateDraftItem(tab, draftId, item =>
{ {
@@ -503,10 +509,10 @@ public sealed class ChatSessionStateService
}, storage); }, storage);
} }
public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null) public bool ResetDraftToQueued(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage); => UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage);
public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null) public bool RemoveDraft(string tab, string draftId, IChatStorageService? storage = null)
{ {
if (string.IsNullOrWhiteSpace(draftId)) if (string.IsNullOrWhiteSpace(draftId))
return false; return false;
@@ -520,7 +526,7 @@ public sealed class ChatSessionStateService
return true; return true;
} }
public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null) public bool ToggleExecutionHistory(string tab, IChatStorageService? storage = null)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.ShowExecutionHistory = !conv.ShowExecutionHistory; conv.ShowExecutionHistory = !conv.ShowExecutionHistory;
@@ -528,7 +534,7 @@ public sealed class ChatSessionStateService
return conv.ShowExecutionHistory; return conv.ShowExecutionHistory;
} }
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null) public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, IChatStorageService? storage = null)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.ConversationFailedOnlyFilter = failedOnly; conv.ConversationFailedOnlyFilter = failedOnly;
@@ -758,7 +764,7 @@ public sealed class ChatSessionStateService
return result; return result;
} }
private void TouchConversation(ChatStorageService? storage, string tab) private void TouchConversation(IChatStorageService? storage, string tab)
{ {
var conv = EnsureCurrentConversation(tab); var conv = EnsureCurrentConversation(tab);
conv.UpdatedAt = DateTime.Now; conv.UpdatedAt = DateTime.Now;
@@ -768,7 +774,7 @@ public sealed class ChatSessionStateService
try { storage?.Save(conv); } catch { } try { storage?.Save(conv); } catch { }
} }
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, ChatStorageService? storage) private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, IChatStorageService? storage)
{ {
if (string.IsNullOrWhiteSpace(draftId)) if (string.IsNullOrWhiteSpace(draftId))
return false; return false;
+32 -4
View File
@@ -11,7 +11,7 @@ namespace AxCopilot.Services;
/// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호. /// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호.
/// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지. /// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지.
/// </summary> /// </summary>
public class ChatStorageService public class ChatStorageService : IChatStorageService
{ {
private static readonly string ConversationsDir = private static readonly string ConversationsDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -53,7 +53,7 @@ public class ChatStorageService
// 원자적 교체: 기존 파일이 있으면 덮어쓰기 // 원자적 교체: 기존 파일이 있으면 덮어쓰기
if (File.Exists(path)) File.Delete(path); if (File.Exists(path)) File.Delete(path);
File.Move(tempPath, path); File.Move(tempPath, path);
UpdateMetaCache(conversation); UpdateMetaCacheUnsafe(conversation);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -124,6 +124,20 @@ public class ChatStorageService
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary> /// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
public void UpdateMetaCache(ChatConversation conv) public void UpdateMetaCache(ChatConversation conv)
{
Lock.EnterWriteLock();
try
{
UpdateMetaCacheUnsafe(conv);
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>WriteLock이 이미 확보된 상태에서 호출. 내부 전용.</summary>
private void UpdateMetaCacheUnsafe(ChatConversation conv)
{ {
if (_metaCache == null) return; if (_metaCache == null) return;
var existing = _metaCache.FindIndex(c => c.Id == conv.Id); var existing = _metaCache.FindIndex(c => c.Id == conv.Id);
@@ -146,6 +160,20 @@ public class ChatStorageService
/// <summary>메타 캐시에서 항목을 제거합니다.</summary> /// <summary>메타 캐시에서 항목을 제거합니다.</summary>
public void RemoveFromMetaCache(string id) public void RemoveFromMetaCache(string id)
{
Lock.EnterWriteLock();
try
{
RemoveFromMetaCacheUnsafe(id);
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>WriteLock이 이미 확보된 상태에서 호출. 내부 전용.</summary>
private void RemoveFromMetaCacheUnsafe(string id)
{ {
_metaCache?.RemoveAll(c => c.Id == id); _metaCache?.RemoveAll(c => c.Id == id);
InvalidateMetaOrderCache(); InvalidateMetaOrderCache();
@@ -220,7 +248,7 @@ public class ChatStorageService
try try
{ {
if (File.Exists(path)) File.Delete(path); if (File.Exists(path)) File.Delete(path);
RemoveFromMetaCache(id); RemoveFromMetaCacheUnsafe(id);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -293,7 +321,7 @@ public class ChatStorageService
if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff) if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff)
{ {
File.Delete(file); File.Delete(file);
RemoveFromMetaCache(conv.Id); RemoveFromMetaCacheUnsafe(conv.Id);
count++; count++;
} }
} }
+4 -4
View File
@@ -56,9 +56,9 @@ internal sealed class Cp4dTokenService
Content = JsonContent.Create(body) Content = JsonContent.Create(body)
}; };
using var resp = await _http.SendAsync(req, ct); using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
statusCode = resp.StatusCode; statusCode = resp.StatusCode;
var rawBody = await resp.Content.ReadAsStringAsync(ct); var rawBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
lastErrorBody = rawBody; lastErrorBody = rawBody;
@@ -71,9 +71,9 @@ internal sealed class Cp4dTokenService
return true; return true;
} }
var authorized = await TryAuthorizeAsync(new { username, password }, "username+password"); var authorized = await TryAuthorizeAsync(new { username, password }, "username+password").ConfigureAwait(false);
if (!authorized && !string.IsNullOrWhiteSpace(password)) if (!authorized && !string.IsNullOrWhiteSpace(password))
authorized = await TryAuthorizeAsync(new { username, api_key = password }, "username+api_key"); authorized = await TryAuthorizeAsync(new { username, api_key = password }, "username+api_key").ConfigureAwait(false);
if (!authorized || string.IsNullOrWhiteSpace(json)) if (!authorized || string.IsNullOrWhiteSpace(json))
{ {
@@ -11,7 +11,7 @@ public sealed class DraftQueueProcessorService
public bool CanStartNext(ChatSessionStateService? session, string tab) public bool CanStartNext(ChatSessionStateService? session, string tab)
=> session?.GetNextQueuedDraft(tab) != null; => session?.GetNextQueuedDraft(tab) != null;
public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, ChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null) public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, IChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null)
{ {
if (session == null) if (session == null)
return null; return null;
@@ -46,7 +46,7 @@ public sealed class DraftQueueProcessorService
?? next; ?? next;
} }
public bool Complete(ChatSessionStateService? session, string tab, string draftId, ChatStorageService? storage = null, TaskRunService? taskRuns = null) public bool Complete(ChatSessionStateService? session, string tab, string draftId, IChatStorageService? storage = null, TaskRunService? taskRuns = null)
{ {
var completed = session?.MarkDraftCompleted(tab, draftId, storage) ?? false; var completed = session?.MarkDraftCompleted(tab, draftId, storage) ?? false;
if (completed) if (completed)
@@ -54,7 +54,7 @@ public sealed class DraftQueueProcessorService
return completed; return completed;
} }
public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, ChatStorageService? storage = null, TaskRunService? taskRuns = null) public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, IChatStorageService? storage = null, TaskRunService? taskRuns = null)
{ {
if (session == null) if (session == null)
return false; return false;
@@ -83,7 +83,7 @@ public sealed class DraftQueueProcessorService
return handled; return handled;
} }
public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
{ {
if (session == null) if (session == null)
return 0; return 0;
@@ -102,18 +102,18 @@ public sealed class DraftQueueProcessorService
return promoted; return promoted;
} }
public int ClearCompleted(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int ClearCompleted(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "completed", storage); => ClearByState(session, tab, "completed", storage);
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int ClearFailed(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "failed", storage); => ClearByState(session, tab, "failed", storage);
/// <summary>대기 중인 항목을 모두 제거합니다 (중지 시 사용).</summary> /// <summary>대기 중인 항목을 모두 제거합니다 (중지 시 사용).</summary>
public int ClearQueued(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int ClearQueued(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "queued", storage); => ClearByState(session, tab, "queued", storage);
/// <summary>실행 중인 항목을 실패로 전환합니다 (중지 시 사용).</summary> /// <summary>실행 중인 항목을 실패로 전환합니다 (중지 시 사용).</summary>
public int CancelRunning(ChatSessionStateService? session, string tab, ChatStorageService? storage = null) public int CancelRunning(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
{ {
if (session == null) return 0; if (session == null) return 0;
int count = 0; int count = 0;
@@ -127,7 +127,7 @@ public sealed class DraftQueueProcessorService
return count; return count;
} }
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage) private static int ClearByState(ChatSessionStateService? session, string tab, string state, IChatStorageService? storage)
{ {
if (session == null) if (session == null)
return 0; return 0;
+3 -3
View File
@@ -42,15 +42,15 @@ internal sealed class IbmIamTokenService
"application/x-www-form-urlencoded"); "application/x-www-form-urlencoded");
req.Headers.Accept.ParseAdd("application/json"); req.Headers.Accept.ParseAdd("application/json");
using var resp = await _http.SendAsync(req, ct); using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var errBody = await resp.Content.ReadAsStringAsync(ct); var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}"); LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}");
return null; return null;
} }
var json = await resp.Content.ReadAsStringAsync(ct); var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp)) if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp))
{ {
+2 -2
View File
@@ -124,7 +124,7 @@ public class IndexService : IDisposable
/// </summary> /// </summary>
public async Task BuildAsync(CancellationToken ct = default) public async Task BuildAsync(CancellationToken ct = default)
{ {
await _rebuildLock.WaitAsync(ct); await _rebuildLock.WaitAsync(ct).ConfigureAwait(false);
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
{ {
@@ -146,7 +146,7 @@ public class IndexService : IDisposable
$"스캔 중: {Path.GetFileName(dir)}", $"스캔 중: {Path.GetFileName(dir)}",
EstimateRemainingText(progressBase, sw.Elapsed))); EstimateRemainingText(progressBase, sw.Elapsed)));
await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct); await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct).ConfigureAwait(false);
var completedProgress = paths.Count == 0 ? 1 : (double)(index + 1) / paths.Count; var completedProgress = paths.Count == 0 ? 1 : (double)(index + 1) / paths.Count;
ReportIndexProgress(LauncherIndexProgressInfo.Running( ReportIndexProgress(LauncherIndexProgressInfo.Running(
@@ -0,0 +1,25 @@
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>앱 전역 상태 요약 저장소 인터페이스.</summary>
public interface IAppStateService
{
ChatSessionStateService? ChatSession { get; }
void AttachChatSession(ChatSessionStateService session);
void LoadFromSettings(ISettingsService settings);
AppStateService.SkillCatalogState Skills { get; }
AppStateService.McpCatalogState Mcp { get; }
AppStateService.PermissionPolicyState Permissions { get; }
AppStateService.AgentCatalogState AgentCatalog { get; }
AppStateService.AgentRunState AgentRun { get; }
void UpsertTask(string id, string kind, string title, string summary, string status = "running", string? filePath = null);
void CompleteTask(string id, string? summary = null, string status = "completed");
void ApplyAgentEvent(AgentEvent evt);
AppStateService.BackgroundJobSummaryState GetBackgroundJobSummary();
AppStateService.PermissionSummaryState GetPermissionSummary(ChatConversation? conversation = null);
}
@@ -0,0 +1,19 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>대화 내역 저장/로드/삭제 인터페이스.</summary>
public interface IChatStorageService
{
void Save(ChatConversation conversation);
ChatConversation? Load(string id);
List<ChatConversation> LoadAllMeta();
void InvalidateMetaCache();
void UpdateMetaCache(ChatConversation conv);
void RemoveFromMetaCache(string id);
void Delete(string id);
int DeleteAll();
int DeleteAllByTab(string tab);
int PurgeExpired(int retentionDays);
int PurgeForDiskSpace(double threshold = 0.98);
}
@@ -0,0 +1,75 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>런타임 연결 정보 스냅샷.</summary>
public record RuntimeConnectionSnapshot(
string Service,
string Model,
string Endpoint,
bool AllowInsecureTls,
bool HasApiKey);
/// <summary>LLM API 호출 인터페이스. 스트리밍/비스트리밍 모두 지원.</summary>
public interface ILlmService : IDisposable
{
/// <summary>현재 서비스/모델 정보 조회.</summary>
(string service, string model) GetCurrentModelInfo();
/// <summary>비스트리밍 전체 응답 요청.</summary>
Task<string> SendAsync(List<ChatMessage> messages, CancellationToken ct = default);
/// <summary>스트리밍 응답 요청 (SSE).</summary>
IAsyncEnumerable<string> StreamAsync(
List<ChatMessage> messages,
CancellationToken ct = default);
/// <summary>연결 테스트.</summary>
Task<(bool ok, string message)> TestConnectionAsync();
/// <summary>자동 라우팅용 서비스/모델 오버라이드 설정.</summary>
void PushRouteOverride(string service, string model);
/// <summary>서비스/모델 오버라이드 해제.</summary>
void ClearRouteOverride();
/// <summary>모델/추론 파라미터 오버라이드 Push.</summary>
void PushInferenceOverride(
string? service = null,
string? model = null,
double? temperature = null,
string? reasoningEffort = null);
/// <summary>가장 최근 Push 상태 복원.</summary>
void PopInferenceOverride();
/// <summary>가장 최근 요청의 토큰 사용량.</summary>
TokenUsage? LastTokenUsage { get; }
/// <summary>런타임 연결 정보 스냅샷 조회.</summary>
RuntimeConnectionSnapshot GetRuntimeConnectionSnapshot();
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
Task<List<ContentBlock>> SendWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct = default,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null);
/// <summary>도구 정의를 포함하여 스트리밍 요청.</summary>
IAsyncEnumerable<ToolStreamEvent> StreamWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
CancellationToken ct = default);
/// <summary>현재 활성 실행 정책 조회.</summary>
ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy();
/// <summary>현재 시스템 프롬프트 (컨텍스트 토큰 추정에 사용).</summary>
string? SystemPrompt { get; }
}
@@ -0,0 +1,9 @@
namespace AxCopilot.Services;
/// <summary>사용자 메시지의 인텐트를 분석하여 최적 모델을 선택하는 라우터 인터페이스.</summary>
public interface IModelRouterService
{
/// <summary>사용자 메시지를 분석하여 최적 모델을 선택합니다.</summary>
/// <returns>라우팅 결과. null이면 기본 모델 유지.</returns>
ModelRouteResult? Route(string userMessage);
}
@@ -0,0 +1,15 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>애플리케이션 설정 읽기/쓰기 인터페이스.</summary>
public interface ISettingsService
{
AppSettings Settings { get; }
string? MigrationSummary { get; }
event EventHandler? SettingsChanged;
void Load();
void Save();
Task SaveAsync();
}
+518 -107
View File
@@ -8,6 +8,36 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock
{
public string Type { get; init; } = "text"; // "text" | "tool_use"
public string Text { get; init; } = ""; // text 타입일 때
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
public enum ToolStreamEventKind
{
TextDelta,
ToolCallReady,
RetryReset,
Completed
}
public sealed record ToolStreamEvent(
ToolStreamEventKind Kind,
string Text = "",
ContentBlock? ToolCall = null);
/// <summary> /// <summary>
/// LlmService의 Function Calling (tool_use) 확장. /// LlmService의 Function Calling (tool_use) 확장.
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다. /// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
@@ -15,35 +45,6 @@ namespace AxCopilot.Services;
/// </summary> /// </summary>
public partial class LlmService public partial class LlmService
{ {
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock
{
public string Type { get; init; } = "text"; // "text" | "tool_use"
public string Text { get; init; } = ""; // text 타입일 때
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
public enum ToolStreamEventKind
{
TextDelta,
ToolCallReady,
Completed
}
public sealed record ToolStreamEvent(
ToolStreamEventKind Kind,
string Text = "",
ContentBlock? ToolCall = null);
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary> /// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall"> /// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. /// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -61,9 +62,9 @@ public partial class LlmService
EnsureOperationModeAllowsLlmService(activeService); EnsureOperationModeAllowsLlmService(activeService);
return NormalizeServiceName(activeService) switch return NormalizeServiceName(activeService) switch
{ {
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct), "sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct), "gemini" => await SendGeminiWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync), "ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).ConfigureAwait(false),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.") _ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
}; };
} }
@@ -150,14 +151,14 @@ public partial class LlmService
req.Headers.Add("x-api-key", apiKey); req.Headers.Add("x-api-key", apiKey);
req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion); req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion);
using var resp = await _http.SendAsync(req, ct); using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var errBody = await resp.Content.ReadAsStringAsync(ct); var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
throw new HttpRequestException(ClassifyHttpError(resp, errBody)); throw new HttpRequestException(ClassifyHttpError(resp, errBody));
} }
var respJson = await resp.Content.ReadAsStringAsync(ct); var respJson = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(respJson); using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement; var root = doc.RootElement;
@@ -544,10 +545,14 @@ public partial class LlmService
var detail = ExtractErrorDetail(errBody); var detail = ExtractErrorDetail(errBody);
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) if (forceToolCall && (int)resp.StatusCode == 400)
{ {
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."); LogService.Warn(isIbmDeployment
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); ? "[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
: "[ToolUse] OpenAI 호환 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
var fallbackBody = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
: BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody); var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
@@ -557,7 +562,7 @@ public partial class LlmService
await ApplyAuthHeaderAsync(retryReq, ct); await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (retryResp.IsSuccessStatusCode) if (retryResp.IsSuccessStatusCode)
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct); return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, isIbmDeployment, prefetchToolCallAsync, ct);
} }
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
@@ -591,7 +596,7 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}", @"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled); System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<ContentBlock> TryExtractToolCallsFromText(string text) internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{ {
var results = new List<ContentBlock>(); var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results; if (string.IsNullOrWhiteSpace(text)) return results;
@@ -685,13 +690,65 @@ public partial class LlmService
} }
} }
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false) private sealed record OpenAiToolCompatibilityProfile(
int StructuredHistoryRecentWindow,
bool AllowParallelToolCalls,
bool IncludeReasoningEffort,
bool AddToolOnlyDirectiveOnFallback);
private OpenAiToolCompatibilityProfile GetOpenAiToolCompatibilityProfile(string service, string model)
{
var normalizedService = NormalizeServiceName(service);
var normalizedModel = (model ?? "").Trim().ToLowerInvariant();
var isFragileVllmFamily =
normalizedService == "vllm" &&
(normalizedModel.Contains("qwen", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("llama", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("deepseek", StringComparison.OrdinalIgnoreCase)
|| normalizedModel.Contains("mistral", StringComparison.OrdinalIgnoreCase));
if (isFragileVllmFamily)
{
return new OpenAiToolCompatibilityProfile(
StructuredHistoryRecentWindow: 4,
AllowParallelToolCalls: false,
IncludeReasoningEffort: false,
AddToolOnlyDirectiveOnFallback: true);
}
return new OpenAiToolCompatibilityProfile(
StructuredHistoryRecentWindow: 8,
AllowParallelToolCalls: true,
IncludeReasoningEffort: true,
AddToolOnlyDirectiveOnFallback: true);
}
private static string BuildOpenAiToolOnlyDirective(IReadOnlyCollection<IAgentTool> tools)
{
var toolNames = string.Join(", ", tools.Select(t => t.Name).Take(12));
return "[TOOL_ONLY] 텍스트로 설명하지 말고 지금 바로 도구를 호출하세요. " +
$"사용 가능한 도구: {toolNames}. " +
"plain text 대신 function/tool call을 사용하세요.";
}
private object BuildOpenAiToolBody(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
bool useToolChoice = true)
{ {
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var activeService = ResolveService();
var activeModel = ResolveModel();
var compatibilityProfile = GetOpenAiToolCompatibilityProfile(activeService, activeModel);
var msgs = new List<object>(); var msgs = new List<object>();
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages, compatibilityProfile.StructuredHistoryRecentWindow);
foreach (var m in messages) for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{ {
var m = messages[messageIndex];
var keepStructuredHistory = messageIndex >= structuredHistoryStart;
// tool_result 메시지 → OpenAI tool 응답 형식 // tool_result 메시지 → OpenAI tool 응답 형식
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{ {
@@ -699,6 +756,16 @@ public partial class LlmService
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement; var root = doc.RootElement;
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "user",
content = BuildOpenAiToolResultTranscript(root),
});
continue;
}
msgs.Add(new msgs.Add(new
{ {
role = "tool", role = "tool",
@@ -717,6 +784,16 @@ public partial class LlmService
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "assistant",
content = BuildOpenAiAssistantTranscript(blocksArr),
});
continue;
}
var textContent = ""; var textContent = "";
var toolCallsList = new List<object>(); var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray()) foreach (var b in blocksArr.EnumerateArray())
@@ -765,6 +842,21 @@ public partial class LlmService
} }
} }
if (forceToolCall && !useToolChoice && compatibilityProfile.AddToolOnlyDirectiveOnFallback)
{
msgs.Add(new
{
role = "user",
content = BuildOpenAiToolOnlyDirective(tools),
});
}
// ── tool_calls ↔ tool 메시지 쌍 검증 ──
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
// 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지.
SanitizeToolCallPairs(msgs);
// OpenAI 도구 정의 // OpenAI 도구 정의
var toolDefs = tools.Select(t => var toolDefs = tools.Select(t =>
{ {
@@ -791,20 +883,24 @@ public partial class LlmService
}; };
}).ToArray(); }).ToArray();
var activeService = ResolveService();
var activeModel = ResolveModel();
var executionPolicy = GetActiveExecutionPolicy(); var executionPolicy = GetActiveExecutionPolicy();
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama) if (isOllama)
{ {
return new // Ollama /api/chat 전용 바디 — stream:false로 비스트리밍 응답
// Ollama 0.5.x+ 에서 tool_choice 파라미터 지원 (미지원 버전은 무시됨)
var ollamaBody = new Dictionary<string, object?>
{ {
model = activeModel, ["model"] = activeModel,
messages = msgs, ["messages"] = msgs,
tools = toolDefs, ["tools"] = toolDefs,
stream = false, ["stream"] = false,
options = new { temperature = ResolveToolTemperature() } ["options"] = new { temperature = ResolveToolTemperature() }
}; };
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
if (forceToolCall && useToolChoice)
ollamaBody["tool_choice"] = "required";
return ollamaBody;
} }
var body = new Dictionary<string, object?> var body = new Dictionary<string, object?>
@@ -815,20 +911,39 @@ public partial class LlmService
["stream"] = true, ["stream"] = true,
["temperature"] = ResolveToolTemperature(), ["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch, ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls,
}; };
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환) // 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true }; body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제 // tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응) // 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall) if (forceToolCall && useToolChoice)
body["tool_choice"] = "required"; body["tool_choice"] = "required";
var effort = ResolveReasoningEffort(); var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort)) if (compatibilityProfile.IncludeReasoningEffort && !string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort; body["reasoning_effort"] = effort;
return body; return body;
} }
private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages, int protectedRecentNonSystemMessages)
{
var nonSystemMessages = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
.ToList();
if (nonSystemMessages.Count <= protectedRecentNonSystemMessages)
return 0;
var tentativeStart = Math.Max(0, nonSystemMessages.Count - protectedRecentNonSystemMessages);
var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
nonSystemMessages.Select(x => x.message).ToList(),
tentativeStart,
out _);
return nonSystemMessages[Math.Max(0, adjustedStart)].index;
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary> /// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody( private object BuildIbmToolBody(
List<ChatMessage> messages, List<ChatMessage> messages,
@@ -869,59 +984,28 @@ public partial class LlmService
{ {
if (m.Role == "system") continue; if (m.Role == "system") continue;
// tool_result → OpenAI role:"tool" 형식 (watsonx /text/chat 지원) // IBM/Qwen 배포형은 과거 tool_calls/tool 메시지 이력을 엄격하게 검사하는 경우가 있어
// 이전 tool-use 대화는 평탄한 transcript로 재구성한다.
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{ {
try try
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement; var root = doc.RootElement;
msgs.Add(new msgs.Add(new { role = "user", content = BuildIbmToolResultTranscript(root) });
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").SafeGetString(),
});
continue; continue;
} }
catch { } catch { }
} }
// _tool_use_blocks → OpenAI tool_calls 형식 // _tool_use_blocks도 assistant + tool_calls로 다시 보내지 않고 plain assistant transcript로 평탄화
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{ {
try try
{ {
using var doc = JsonDocument.Parse(m.Content); using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
var textContent = ""; msgs.Add(new { role = "assistant", content = BuildIbmAssistantTranscript(blocksArr) });
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").SafeGetString();
if (bType == "text")
textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").SafeGetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson,
}
});
}
}
msgs.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
tool_calls = toolCallsList,
});
continue; continue;
} }
catch { } catch { }
@@ -998,6 +1082,105 @@ public partial class LlmService
}; };
} }
private static string BuildIbmAssistantTranscript(JsonElement blocksArr)
{
var textSegments = new List<string>();
var toolSegments = new List<string>();
foreach (var block in blocksArr.EnumerateArray())
{
var blockType = block.GetProperty("type").SafeGetString();
if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase))
{
var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
textSegments.Add(text.Trim());
continue;
}
if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase))
continue;
var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var args = block.SafeTryGetProperty("input", out var inputEl) ? inputEl.GetRawText() : "{}";
if (string.IsNullOrWhiteSpace(name))
continue;
toolSegments.Add($"<tool_call>\n{{\"name\":\"{name}\",\"arguments\":{args}}}\n</tool_call>");
}
var parts = new List<string>();
if (textSegments.Count > 0)
parts.Add(string.Join("\n\n", textSegments));
if (toolSegments.Count > 0)
parts.Add(string.Join("\n", toolSegments));
return parts.Count == 0
? "<tool_call>\n{\"name\":\"unknown_tool\",\"arguments\":{}}\n</tool_call>"
: string.Join("\n\n", parts);
}
private static string BuildOpenAiAssistantTranscript(JsonElement blocksArr)
{
var textSegments = new List<string>();
var toolNames = new List<string>();
foreach (var block in blocksArr.EnumerateArray())
{
var blockType = block.GetProperty("type").SafeGetString();
if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase))
{
var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
textSegments.Add(text.Trim());
continue;
}
if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase))
continue;
var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(name))
toolNames.Add(name.Trim());
}
var parts = new List<string>();
if (textSegments.Count > 0)
parts.Add(string.Join("\n\n", textSegments));
if (toolNames.Count > 0)
parts.Add($"[이전 도구 호출: {string.Join(", ", toolNames.Distinct(StringComparer.OrdinalIgnoreCase))}]");
return parts.Count == 0 ? "[이전 도구 호출]" : string.Join("\n\n", parts);
}
private static string BuildOpenAiToolResultTranscript(JsonElement root)
{
var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : "";
var header = string.IsNullOrWhiteSpace(toolName)
? "[이전 도구 결과]"
: $"[이전 도구 결과: {toolName}]";
return string.IsNullOrWhiteSpace(content)
? $"{header}\n(no output)"
: $"{header}\n{content}";
}
private static string BuildIbmToolResultTranscript(JsonElement root)
{
var toolCallId = root.SafeTryGetProperty("tool_use_id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : "";
var header = string.IsNullOrWhiteSpace(toolName)
? "[Tool Result]"
: $"[Tool Result: {toolName}]";
if (!string.IsNullOrWhiteSpace(toolCallId))
header += $" (id={toolCallId})";
return string.IsNullOrWhiteSpace(content)
? $"{header}\n(no output)"
: $"{header}\n{content}";
}
private sealed class ToolCallAccumulator private sealed class ToolCallAccumulator
{ {
public int Index { get; init; } public int Index { get; init; }
@@ -1104,9 +1287,14 @@ public partial class LlmService
{ {
var errBody = await resp.Content.ReadAsStringAsync(ct); var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody); var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) if (forceToolCall && (int)resp.StatusCode == 400)
{ {
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); LogService.Warn(isIbmDeployment
? "[ToolUse] IBM 배포형 스트리밍 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
: "[ToolUse] OpenAI 호환 스트리밍 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
var fallbackBody = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
: BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody); var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
@@ -1230,11 +1418,22 @@ public partial class LlmService
resultsEl.GetArrayLength() > 0) resultsEl.GetArrayLength() > 0)
{ {
var first = resultsEl[0]; var first = resultsEl[0];
var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl) string? generatedText = null;
? generatedTextEl.SafeGetString() if (first.SafeTryGetProperty("generated_text", out var generatedTextEl))
: first.SafeTryGetProperty("output_text", out var outputTextEl) generatedText = TryExtractTextContent(generatedTextEl, out var extractedGeneratedText) ? extractedGeneratedText : generatedTextEl.SafeGetString();
? outputTextEl.SafeGetString() else if (first.SafeTryGetProperty("output_text", out var outputTextEl))
: null; generatedText = TryExtractTextContent(outputTextEl, out var extractedOutputText) ? extractedOutputText : outputTextEl.SafeGetString();
else if (first.SafeTryGetProperty("message", out var ibmMessageEl) &&
TryExtractMessageToolBlocks(ibmMessageEl, out var ibmMessageText, out var ibmToolBlocks))
{
generatedText = ibmMessageText;
foreach (var toolBlock in ibmToolBlocks)
{
if (prefetchToolCallAsync != null)
toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock);
}
}
if (!string.IsNullOrEmpty(generatedText)) if (!string.IsNullOrEmpty(generatedText))
{ {
if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal)) if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal))
@@ -1364,27 +1563,33 @@ public partial class LlmService
message = nestedMessage; message = nestedMessage;
var consumed = false; var consumed = false;
if (message.SafeTryGetProperty("content", out var contentEl) && if (message.SafeTryGetProperty("content", out var contentEl))
contentEl.ValueKind == JsonValueKind.String)
{ {
var parsedText = contentEl.SafeGetString(); if (TryExtractTextContent(contentEl, out var parsedText))
if (!string.IsNullOrWhiteSpace(parsedText))
{ {
text = parsedText; text = parsedText;
consumed = true; consumed = true;
} }
if (contentEl.ValueKind == JsonValueKind.Array)
{
foreach (var block in contentEl.EnumerateArray())
{
if (!TryParseContentArrayToolBlock(block, out var toolBlock))
continue;
toolBlocks.Add(toolBlock);
consumed = true;
}
}
} }
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용 // Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed && if (!consumed &&
message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) && message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String) TryExtractTextContent(reasoningContentEl, out var reasoningText))
{ {
var reasoningText = reasoningContentEl.SafeGetString(); text = reasoningText;
if (!string.IsNullOrWhiteSpace(reasoningText)) consumed = true;
{
text = reasoningText;
consumed = true;
}
} }
if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) && if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
@@ -1428,6 +1633,125 @@ public partial class LlmService
return consumed; return consumed;
} }
private static bool TryExtractTextContent(JsonElement element, out string text)
{
text = "";
if (element.ValueKind == JsonValueKind.String)
{
var parsed = element.SafeGetString();
if (!string.IsNullOrWhiteSpace(parsed))
{
text = parsed;
return true;
}
return false;
}
if (element.ValueKind != JsonValueKind.Array)
return false;
var segments = new List<string>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var str = item.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
continue;
}
if (item.ValueKind != JsonValueKind.Object)
continue;
if (item.SafeTryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String)
{
var str = textEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
continue;
}
if (item.SafeTryGetProperty("content", out var nestedContentEl) && TryExtractTextContent(nestedContentEl, out var nestedText))
{
segments.Add(nestedText);
continue;
}
if (item.SafeTryGetProperty("reasoning_content", out var reasoningEl) && TryExtractTextContent(reasoningEl, out var reasoningText))
{
segments.Add(reasoningText);
continue;
}
if (item.SafeTryGetProperty("output_text", out var outputTextEl) && outputTextEl.ValueKind == JsonValueKind.String)
{
var str = outputTextEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(str))
segments.Add(str);
}
}
if (segments.Count == 0)
return false;
text = string.Join("\n", segments.Where(s => !string.IsNullOrWhiteSpace(s)));
return !string.IsNullOrWhiteSpace(text);
}
private static bool TryParseContentArrayToolBlock(JsonElement block, out ContentBlock toolBlock)
{
toolBlock = default!;
if (block.ValueKind != JsonValueKind.Object)
return false;
var type = block.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() ?? "" : "";
if (!string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, "tool_call", StringComparison.OrdinalIgnoreCase))
return false;
JsonElement? parsedArgs = null;
if (block.SafeTryGetProperty("input", out var inputEl))
parsedArgs = inputEl.Clone();
else if (block.SafeTryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
try
{
using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch
{
parsedArgs = null;
}
}
else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
parsedArgs = argsEl.Clone();
}
}
var toolName = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(toolName))
return false;
toolBlock = new ContentBlock
{
Type = "tool_use",
ToolName = toolName,
ToolId = block.SafeTryGetProperty("id", out var idEl)
? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12]
: Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
};
return true;
}
private static bool LooksLikeCompleteJson(string json) private static bool LooksLikeCompleteJson(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -1514,6 +1838,93 @@ public partial class LlmService
// ─── 공통 헬퍼 ───────────────────────────────────────────────────── // ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다.
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
/// </summary>
private static void SanitizeToolCallPairs(List<object> msgs)
{
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ──
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용
var pairedToolIndices = new HashSet<int>();
for (int i = 0; i < msgs.Count; i++)
{
var msgType = msgs[i].GetType();
var toolCallsProp = msgType.GetProperty("tool_calls");
var roleProp = msgType.GetProperty("role");
if (toolCallsProp == null || roleProp == null) continue;
var role = roleProp.GetValue(msgs[i]) as string;
if (role != "assistant") continue;
var toolCalls = toolCallsProp.GetValue(msgs[i]);
if (toolCalls == null) continue;
int callCount = 0;
if (toolCalls is System.Collections.ICollection col) callCount = col.Count;
else if (toolCalls is System.Collections.IEnumerable en)
{
foreach (var _ in en) callCount++;
}
if (callCount == 0) continue;
// 바로 다음에 tool 역할 메시지가 callCount개 있는지 확인
int foundTools = 0;
for (int j = i + 1; j < msgs.Count && foundTools < callCount; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole == "tool")
{
foundTools++;
pairedToolIndices.Add(j);
}
else
break;
}
if (foundTools < callCount)
{
// 쌍이 불완전 → assistant를 일반 텍스트로 교체
var contentProp = msgType.GetProperty("content");
var contentText = contentProp?.GetValue(msgs[i]) as string ?? "";
if (string.IsNullOrWhiteSpace(contentText))
contentText = "[이전 도구 호출 — 결과 누락으로 생략됨]";
msgs[i] = new { role = "assistant", content = contentText };
// 이 assistant에 딸린 불완전 tool 메시지도 user로 변환
for (int j = i + 1; j < msgs.Count; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole != "tool") break;
var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? "";
msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" };
pairedToolIndices.Remove(j);
}
LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})");
}
}
// ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ──
for (int i = 0; i < msgs.Count; i++)
{
if (pairedToolIndices.Contains(i)) continue;
var msgType = msgs[i].GetType();
var roleProp = msgType.GetProperty("role");
var role = roleProp?.GetValue(msgs[i]) as string;
if (role != "tool") continue;
var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? "";
msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" };
LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})");
}
}
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary> /// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType) private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType)
{ {
+6 -13
View File
@@ -18,7 +18,7 @@ public record TokenUsage(int PromptTokens, int CompletionTokens)
/// LLM API 호출 서비스. Ollama / vLLM / Gemini / Claude 백엔드를 지원합니다. /// LLM API 호출 서비스. Ollama / vLLM / Gemini / Claude 백엔드를 지원합니다.
/// 스트리밍(SSE) 및 비스트리밍 모두 지원합니다. /// 스트리밍(SSE) 및 비스트리밍 모두 지원합니다.
/// </summary> /// </summary>
public partial class LlmService : IDisposable public partial class LlmService : ILlmService
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly HttpClient _httpInsecure; private readonly HttpClient _httpInsecure;
@@ -148,7 +148,7 @@ public partial class LlmService : IDisposable
internal string GetActiveExecutionProfileKey() internal string GetActiveExecutionProfileKey()
=> Agent.ModelExecutionProfileCatalog.Normalize(GetActiveRegisteredModel()?.ExecutionProfile); => Agent.ModelExecutionProfileCatalog.Normalize(GetActiveRegisteredModel()?.ExecutionProfile);
internal Agent.ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy() public Agent.ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy()
=> Agent.ModelExecutionProfileCatalog.Get(GetActiveExecutionProfileKey()); => Agent.ModelExecutionProfileCatalog.Get(GetActiveExecutionProfileKey());
internal double ResolveToolTemperature() internal double ResolveToolTemperature()
@@ -220,13 +220,6 @@ public partial class LlmService : IDisposable
/// <summary>가장 최근 요청의 토큰 사용량. 스트리밍/비스트리밍 완료 후 갱신됩니다.</summary> /// <summary>가장 최근 요청의 토큰 사용량. 스트리밍/비스트리밍 완료 후 갱신됩니다.</summary>
public TokenUsage? LastTokenUsage { get; private set; } public TokenUsage? LastTokenUsage { get; private set; }
public record RuntimeConnectionSnapshot(
string Service,
string Model,
string Endpoint,
bool AllowInsecureTls,
bool HasApiKey);
public LlmService(SettingsService settings) public LlmService(SettingsService settings)
{ {
_settings = settings; _settings = settings;
@@ -342,8 +335,8 @@ public partial class LlmService : IDisposable
{ {
return llm.RegisteredModels.FirstOrDefault(m => return llm.RegisteredModels.FirstOrDefault(m =>
m.Service.Equals(service, StringComparison.OrdinalIgnoreCase) && m.Service.Equals(service, StringComparison.OrdinalIgnoreCase) &&
(CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled) == modelName || (string.Equals(CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled), modelName, StringComparison.OrdinalIgnoreCase) ||
m.Alias == modelName)); string.Equals(m.Alias, modelName, StringComparison.OrdinalIgnoreCase)));
} }
private Models.RegisteredModel? GetActiveRegisteredModel() private Models.RegisteredModel? GetActiveRegisteredModel()
@@ -572,7 +565,7 @@ public partial class LlmService : IDisposable
EnsureOperationModeAllowsLlmService(activeService); EnsureOperationModeAllowsLlmService(activeService);
try try
{ {
return await SendWithServiceAsync(activeService, messages, ct); return await SendWithServiceAsync(activeService, messages, ct).ConfigureAwait(false);
} }
catch (Exception ex) when (llm.FallbackModels.Count > 0) catch (Exception ex) when (llm.FallbackModels.Count > 0)
{ {
@@ -587,7 +580,7 @@ public partial class LlmService : IDisposable
EnsureOperationModeAllowsLlmService(fbService); EnsureOperationModeAllowsLlmService(fbService);
LogService.Warn($"모델 폴백: {activeService} → {fbService} ({ex.Message})"); LogService.Warn($"모델 폴백: {activeService} → {fbService} ({ex.Message})");
LastFallbackInfo = $"{activeService} → {fbService}"; LastFallbackInfo = $"{activeService} → {fbService}";
return await SendWithServiceAsync(fbService, messages, ct); return await SendWithServiceAsync(fbService, messages, ct).ConfigureAwait(false);
} }
catch { continue; } catch { continue; }
} }
+341 -16
View File
@@ -2,6 +2,7 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
@@ -68,8 +69,15 @@ public class LspClientService : IDisposable
textDocument = new textDocument = new
{ {
definition = new { dynamicRegistration = false }, definition = new { dynamicRegistration = false },
implementation = new { dynamicRegistration = false },
references = new { dynamicRegistration = false }, references = new { dynamicRegistration = false },
documentSymbol = new { dynamicRegistration = false }, documentSymbol = new { dynamicRegistration = false },
hover = new { dynamicRegistration = false },
callHierarchy = new { dynamicRegistration = false },
},
workspace = new
{
symbol = new { dynamicRegistration = false },
} }
} }
}, ct); }, ct);
@@ -126,6 +134,79 @@ public class LspClientService : IDisposable
return ParseSymbols(result); return ParseSymbols(result);
} }
/// <summary>심볼의 hover 정보를 가져옵니다.</summary>
public async Task<string?> HoverAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/hover", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseHover(result);
}
/// <summary>인터페이스/추상 메서드 등의 구현 위치를 찾습니다.</summary>
public async Task<List<LspLocation>> GotoImplementationAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/implementation", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseLocations(result);
}
/// <summary>워크스페이스 전체 심볼을 검색합니다.</summary>
public async Task<List<LspWorkspaceSymbol>> SearchWorkspaceSymbolsAsync(string query, CancellationToken ct = default)
{
var result = await SendRequestAsync("workspace/symbol", new { query }, ct);
return ParseWorkspaceSymbols(result);
}
/// <summary>호출 계층의 기준 아이템을 준비합니다.</summary>
public async Task<List<LspCallHierarchyItem>> PrepareCallHierarchyAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/prepareCallHierarchy", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseCallHierarchyItems(result);
}
/// <summary>해당 심볼을 호출하는 상위 호출자를 찾습니다.</summary>
public async Task<List<LspCallHierarchyEntry>> GetIncomingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return new List<LspCallHierarchyEntry>();
var result = await SendRequestAsync("callHierarchy/incomingCalls", new
{
item = items[0].RawItem
}, ct);
return ParseIncomingCalls(result);
}
/// <summary>해당 심볼이 호출하는 하위 호출 대상을 찾습니다.</summary>
public async Task<List<LspCallHierarchyEntry>> GetOutgoingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return new List<LspCallHierarchyEntry>();
var result = await SendRequestAsync("callHierarchy/outgoingCalls", new
{
item = items[0].RawItem
}, ct);
return ParseOutgoingCalls(result);
}
// ─── JSON-RPC 통신 ────────────────────────────────────────────────────── // ─── JSON-RPC 통신 ──────────────────────────────────────────────────────
private static readonly JsonSerializerOptions JsonOpts = new() private static readonly JsonSerializerOptions JsonOpts = new()
@@ -249,16 +330,7 @@ public class LspClientService : IDisposable
var elem = result.Value; var elem = result.Value;
if (elem.ValueKind == JsonValueKind.Array && elem.GetArrayLength() > 0) if (elem.ValueKind == JsonValueKind.Array && elem.GetArrayLength() > 0)
elem = elem[0]; elem = elem[0];
if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range)) return ParseLocationElement(elem);
{
var start = range.GetProperty("start");
return new LspLocation
{
FilePath = UriToFile(uri.GetString() ?? ""),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
};
}
} }
catch { } catch { }
return null; return null;
@@ -267,21 +339,56 @@ public class LspClientService : IDisposable
private static List<LspLocation> ParseLocations(JsonElement? result) private static List<LspLocation> ParseLocations(JsonElement? result)
{ {
var list = new List<LspLocation>(); var list = new List<LspLocation>();
if (result?.ValueKind != JsonValueKind.Array) return list; if (result == null)
foreach (var elem in result.Value.EnumerateArray()) return list;
if (result.Value.ValueKind == JsonValueKind.Array)
{ {
foreach (var elem in result.Value.EnumerateArray())
{
var parsed = ParseLocationElement(elem);
if (parsed != null)
list.Add(parsed);
}
}
else
{
var parsed = ParseLocationElement(result.Value);
if (parsed != null)
list.Add(parsed);
}
return list;
}
private static LspLocation? ParseLocationElement(JsonElement elem)
{
try
{
if (elem.TryGetProperty("targetUri", out var targetUri) && elem.TryGetProperty("targetSelectionRange", out var targetSelectionRange))
{
var start = targetSelectionRange.GetProperty("start");
return new LspLocation
{
FilePath = UriToFile(targetUri.SafeGetString() ?? ""),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
};
}
if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range)) if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range))
{ {
var start = range.GetProperty("start"); var start = range.GetProperty("start");
list.Add(new LspLocation return new LspLocation
{ {
FilePath = UriToFile(uri.GetString() ?? ""), FilePath = UriToFile(uri.SafeGetString() ?? ""),
Line = start.GetProperty("line").GetInt32(), Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(), Character = start.GetProperty("character").GetInt32(),
}); };
} }
} }
return list; catch { }
return null;
} }
private static List<LspSymbol> ParseSymbols(JsonElement? result) private static List<LspSymbol> ParseSymbols(JsonElement? result)
@@ -311,6 +418,193 @@ public class LspClientService : IDisposable
return list; return list;
} }
private static string? ParseHover(JsonElement? result)
{
if (result == null || result.Value.ValueKind != JsonValueKind.Object)
return null;
if (!result.Value.TryGetProperty("contents", out var contents))
return null;
try
{
return contents.ValueKind switch
{
JsonValueKind.String => contents.SafeGetString(),
JsonValueKind.Object => contents.TryGetProperty("value", out var value)
? value.SafeGetString()
: contents.GetRawText(),
JsonValueKind.Array => string.Join(
"\n\n",
contents.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("value", out var v)
? v.SafeGetString()
: item.SafeGetString())
.Where(text => !string.IsNullOrWhiteSpace(text))),
_ => contents.SafeGetString()
};
}
catch
{
return null;
}
}
private static List<LspWorkspaceSymbol> ParseWorkspaceSymbols(JsonElement? result)
{
var list = new List<LspWorkspaceSymbol>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
var location = elem.TryGetProperty("location", out var locationEl)
? ParseLocationElement(locationEl)
: null;
list.Add(new LspWorkspaceSymbol
{
Name = name,
Kind = kind,
Location = location,
});
}
catch { }
}
return list;
}
private static List<LspCallHierarchyItem> ParseCallHierarchyItems(JsonElement? result)
{
var list = new List<LspCallHierarchyItem>();
if (result == null)
return list;
if (result.Value.ValueKind == JsonValueKind.Array)
{
foreach (var elem in result.Value.EnumerateArray())
{
var item = ParseCallHierarchyItem(elem);
if (item != null)
list.Add(item);
}
}
else if (result.Value.ValueKind == JsonValueKind.Object)
{
var item = ParseCallHierarchyItem(result.Value);
if (item != null)
list.Add(item);
}
return list;
}
private static LspCallHierarchyItem? ParseCallHierarchyItem(JsonElement elem)
{
try
{
var uri = elem.TryGetProperty("uri", out var uriEl) ? uriEl.SafeGetString() ?? "" : "";
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
var selectionRange = elem.TryGetProperty("selectionRange", out var selectionRangeEl)
? selectionRangeEl
: elem.GetProperty("range");
var start = selectionRange.GetProperty("start");
return new LspCallHierarchyItem
{
Name = name,
Kind = kind,
Location = new LspLocation
{
FilePath = UriToFile(uri),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
},
RawItem = elem.Clone(),
};
}
catch
{
return null;
}
}
private static List<LspCallHierarchyEntry> ParseIncomingCalls(JsonElement? result)
{
var list = new List<LspCallHierarchyEntry>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
if (!elem.TryGetProperty("from", out var fromEl))
continue;
var item = ParseCallHierarchyItem(fromEl);
if (item == null)
continue;
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
? ranges.GetArrayLength()
: 0;
list.Add(new LspCallHierarchyEntry
{
Name = item.Name,
Kind = item.Kind,
Location = item.Location,
RangeCount = count,
});
}
catch { }
}
return list;
}
private static List<LspCallHierarchyEntry> ParseOutgoingCalls(JsonElement? result)
{
var list = new List<LspCallHierarchyEntry>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
if (!elem.TryGetProperty("to", out var toEl))
continue;
var item = ParseCallHierarchyItem(toEl);
if (item == null)
continue;
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
? ranges.GetArrayLength()
: 0;
list.Add(new LspCallHierarchyEntry
{
Name = item.Name,
Kind = item.Kind,
Location = item.Location,
RangeCount = count,
});
}
catch { }
}
return list;
}
private static string SymbolKindName(int kind) => kind switch private static string SymbolKindName(int kind) => kind switch
{ {
1 => "file", 2 => "module", 3 => "namespace", 4 => "package", 1 => "file", 2 => "module", 3 => "namespace", 4 => "package",
@@ -408,3 +702,34 @@ public class LspSymbol
public int Line { get; init; } public int Line { get; init; }
public override string ToString() => $"[{Kind}] {Name} (line {Line + 1})"; public override string ToString() => $"[{Kind}] {Name} (line {Line + 1})";
} }
/// <summary>워크스페이스 심볼 검색 결과.</summary>
public class LspWorkspaceSymbol
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation? Location { get; init; }
public override string ToString() => Location == null
? $"[{Kind}] {Name}"
: $"[{Kind}] {Name} @ {Location}";
}
/// <summary>호출 계층 기준 아이템.</summary>
public class LspCallHierarchyItem
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation Location { get; init; } = new();
public JsonElement RawItem { get; init; }
public override string ToString() => $"[{Kind}] {Name} @ {Location}";
}
/// <summary>호출 계층의 incoming/outgoing 엔트리.</summary>
public class LspCallHierarchyEntry
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation Location { get; init; } = new();
public int RangeCount { get; init; }
public override string ToString() => $"[{Kind}] {Name} @ {Location} (matches: {RangeCount})";
}
+1 -1
View File
@@ -21,7 +21,7 @@ public record ModelRouteResult(
/// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가 /// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가
/// - 확신도 조정: Route() 메서드의 confidence 체크 로직 /// - 확신도 조정: Route() 메서드의 confidence 체크 로직
/// </summary> /// </summary>
public class ModelRouterService public class ModelRouterService : IModelRouterService
{ {
private readonly SettingsService _settings; private readonly SettingsService _settings;
+1 -1
View File
@@ -66,7 +66,7 @@ public static class PdfExportService
private static string GetPrintStyles() => @" private static string GetPrintStyles() => @"
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; font-size: 13px; color: #222; background: #fff; padding: 20px; } body { font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; font-size: 13px; color: #222; background: #fff; padding: 20px; }
.header { border-bottom: 2px solid #4B5EFC; padding-bottom: 12px; margin-bottom: 20px; } .header { border-bottom: 2px solid #4B5EFC; padding-bottom: 12px; margin-bottom: 20px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1b2e; } .header h1 { font-size: 18px; font-weight: 700; color: #1a1b2e; }
.header .meta { font-size: 11px; color: #888; margin-top: 4px; } .header .meta { font-size: 11px; color: #888; margin-top: 4px; }
+49
View File
@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
namespace AxCopilot.Services;
/// <summary>
/// 전역 DI 서비스 로케이터.
/// 기존 수동 new 패턴에서 DI 컨테이너로의 점진적 전환을 지원합니다.
/// App.OnStartup()에서 Configure()를 호출하여 초기화합니다.
///
/// 사용법:
/// var settings = ServiceLocator.Get&lt;ISettingsService&gt;();
/// var storage = ServiceLocator.Get&lt;IChatStorageService&gt;();
/// </summary>
public static class ServiceLocator
{
private static IServiceProvider? _provider;
/// <summary>DI 컨테이너를 구성합니다. 앱 시작 시 1회만 호출합니다.</summary>
public static IServiceProvider Configure(Action<IServiceCollection> configureServices)
{
var services = new ServiceCollection();
configureServices(services);
_provider = services.BuildServiceProvider(validateScopes: true);
return _provider;
}
/// <summary>이미 생성된 ServiceProvider를 직접 설정합니다.</summary>
public static void SetProvider(IServiceProvider provider)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>등록된 서비스를 가져옵니다.</summary>
public static T Get<T>() where T : notnull
{
if (_provider == null)
throw new InvalidOperationException("ServiceLocator가 초기화되지 않았습니다. App.OnStartup()에서 Configure()를 호출하세요.");
return _provider.GetRequiredService<T>();
}
/// <summary>등록된 서비스를 가져옵니다. 미등록 시 null을 반환합니다.</summary>
public static T? GetOptional<T>() where T : class
{
return _provider?.GetService<T>();
}
/// <summary>내부 ServiceProvider에 직접 접근합니다. (테스트/마이그레이션용)</summary>
public static IServiceProvider? Provider => _provider;
}
+40 -3
View File
@@ -5,7 +5,7 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Services; namespace AxCopilot.Services;
public class SettingsService public class SettingsService : ISettingsService
{ {
private static readonly string AppDataDir = InitAppDataDir(); private static readonly string AppDataDir = InitAppDataDir();
@@ -186,12 +186,13 @@ public class SettingsService
} }
catch (IOException) when (attempt < 2) catch (IOException) when (attempt < 2)
{ {
Thread.Sleep(50 * (attempt + 1)); // SpinWait으로 짧은 지연 (Thread.Sleep보다 UI 블로킹 최소화)
Thread.SpinWait(50_000 * (attempt + 1));
} }
catch (Exception ex) when (attempt < 2) catch (Exception ex) when (attempt < 2)
{ {
LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}"); LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1)); Thread.SpinWait(50_000 * (attempt + 1));
} }
finally finally
{ {
@@ -203,6 +204,42 @@ public class SettingsService
SettingsChanged?.Invoke(this, EventArgs.Empty); SettingsChanged?.Invoke(this, EventArgs.Empty);
} }
/// <summary>비동기 설정 저장. UI 스레드에서 호출 시 블로킹 없이 저장합니다.</summary>
public async Task SaveAsync()
{
EnsureDirectories();
NormalizeRuntimeSettings();
var json = JsonSerializer.Serialize(_settings, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json);
await Task.Run(() =>
{
lock (_saveLock)
{
var tmpPath = SettingsPath + ".tmp";
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
File.WriteAllText(tmpPath, encrypted);
File.Move(tmpPath, SettingsPath, overwrite: true);
break;
}
catch (Exception ex) when (attempt < 2)
{
LogService.Warn($"settings.dat 비동기 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1));
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
}
}
}
}).ConfigureAwait(false);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private void NormalizeRuntimeSettings() private void NormalizeRuntimeSettings()
{ {
var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant(); var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant();
+25 -1
View File
@@ -30,10 +30,34 @@ public static class TokenEstimator
{ {
int total = 0; int total = 0;
foreach (var m in messages) foreach (var m in messages)
total += Estimate(m.Content) + 4; // 메시지 오버헤드 {
var content = m.Content;
// _tool_use_blocks JSON은 API 전송 시 구조화된 형식으로 변환되므로
// 원시 JSON 길이 대비 약 40% 할인 적용 (JSON 래퍼 오버헤드 제거)
if (m.Role == "assistant" && content != null && content.StartsWith("{\"_tool_use_blocks\""))
total += (int)(Estimate(content) * 0.6) + 4;
// tool_result JSON도 구조화 변환됨
else if (m.Role == "user" && content != null && content.StartsWith("{\"type\":\"tool_result\""))
total += (int)(Estimate(content) * 0.7) + 4;
else
total += Estimate(content ?? "") + 4;
}
return total; return total;
} }
/// <summary>시스템 프롬프트 + 도구 정의에 의한 기본 오버헤드 토큰을 추정합니다.</summary>
/// <param name="systemPromptLength">시스템 프롬프트 문자 수</param>
/// <param name="toolCount">등록된 도구 수</param>
/// <returns>추정 토큰 수</returns>
public static int EstimateBaseOverhead(int systemPromptLength, int toolCount)
{
// 시스템 프롬프트 토큰
var sysTokens = systemPromptLength > 0 ? (int)(systemPromptLength / 3.5) : 0;
// 도구 정의: 이름 + 설명 + 파라미터 스키마 ≈ 평균 180토큰/도구
var toolTokens = toolCount * 180;
return sysTokens + toolTokens;
}
/// <summary>비용을 추정합니다 (USD 기준).</summary> /// <summary>비용을 추정합니다 (USD 기준).</summary>
public static (double InputCost, double OutputCost) EstimateCost( public static (double InputCost, double OutputCost) EstimateCost(
int promptTokens, int completionTokens, string service, string model) int promptTokens, int completionTokens, string service, string model)
+2 -2
View File
@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F3F4F6"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FCFCFD"/> <SolidColorBrush x:Key="ItemBackground" Color="#F3F4F6"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/>
<SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/> <SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/>
+2 -2
View File
@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#FAF6EF"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFDFC"/> <SolidColorBrush x:Key="ItemBackground" Color="#FAF6EF"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/> <SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>
+2 -2
View File
@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#F8FAFC"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EAF2FF"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#EAF2FF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/> <SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
+2 -2
View File
@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#ECEFF4"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#ECEFF4"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E9F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E9F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/> <SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/>
+2 -2
View File
@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#F0F2F8"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/>
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/> <SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
+22
View File
@@ -107,6 +107,28 @@ public class HexToColorConverter : IValueConverter
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <summary>"#RRGGBB" 헥스 문자열 → SolidColorBrush. DataTemplate 아이콘 색상에 사용.</summary>
public class HexToBrushConverter : IValueConverter
{
public object Convert(object value, Type t, object p, CultureInfo c)
{
if (value is string hex && !string.IsNullOrEmpty(hex))
{
try
{
var color = (Color)ColorConverter.ConvertFromString(hex);
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
catch { /* 잘못된 값이면 기본 반환 */ }
}
return new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80));
}
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotImplementedException();
}
/// <summary>null 또는 빈 문자열이면 Collapsed, 값이 있으면 Visible.</summary> /// <summary>null 또는 빈 문자열이면 Collapsed, 값이 있으면 Visible.</summary>
public class NullToCollapsedConverter : IValueConverter public class NullToCollapsedConverter : IValueConverter
{ {
@@ -0,0 +1,276 @@
using System.Collections.ObjectModel;
using AxCopilot.Models;
namespace AxCopilot.ViewModels;
/// <summary>
/// ChatWindow의 MVVM ViewModel.
/// XAML 바인딩 대상 속성을 노출합니다.
/// 기존 코드비하인드와 공존하며 점진적으로 확장합니다.
/// </summary>
public class ChatWindowViewModel : ViewModelBase
{
// ── 탭 상태 ──────────────────────────────────────────────
private string _activeTab = "Chat";
public string ActiveTab
{
get => _activeTab;
set
{
if (SetProperty(ref _activeTab, value))
{
OnPropertyChanged(nameof(IsChat));
OnPropertyChanged(nameof(IsCowork));
OnPropertyChanged(nameof(IsCode));
OnPropertyChanged(nameof(TabDisplayName));
}
}
}
public bool IsChat => ActiveTab == "Chat";
public bool IsCowork => ActiveTab == "Cowork";
public bool IsCode => ActiveTab == "Code";
public string TabDisplayName => ActiveTab switch
{
"Chat" => "AX Agent",
"Cowork" => "Cowork",
"Code" => "Code",
_ => ActiveTab,
};
// ── 대화 제목 ────────────────────────────────────────────
private string _chatTitle = "";
public string ChatTitle
{
get => _chatTitle;
set => SetProperty(ref _chatTitle, value);
}
// ── 스트리밍 상태 ────────────────────────────────────────
private bool _isStreaming;
public bool IsStreaming
{
get => _isStreaming;
set
{
if (SetProperty(ref _isStreaming, value))
{
OnPropertyChanged(nameof(CanSend));
OnPropertyChanged(nameof(SendButtonIcon));
OnPropertyChanged(nameof(StreamingStatusText));
}
}
}
public bool CanSend => !IsStreaming;
public string SendButtonIcon => IsStreaming ? "\uE711" : "\uE724"; // Stop : Send
public string StreamingStatusText => IsStreaming ? "응답 생성 중..." : "";
// ── 모델 정보 ────────────────────────────────────────────
private string _modelLabel = "";
public string ModelLabel
{
get => _modelLabel;
set => SetProperty(ref _modelLabel, value);
}
private string _serviceLabel = "";
public string ServiceLabel
{
get => _serviceLabel;
set => SetProperty(ref _serviceLabel, value);
}
// ── 토큰 사용량 ─────────────────────────────────────────
private int _contextTokens;
public int ContextTokens
{
get => _contextTokens;
set
{
if (SetProperty(ref _contextTokens, value))
{
OnPropertyChanged(nameof(ContextUsagePercent));
OnPropertyChanged(nameof(ContextUsageText));
}
}
}
private int _maxContextTokens = 128000;
public int MaxContextTokens
{
get => _maxContextTokens;
set
{
if (SetProperty(ref _maxContextTokens, value))
{
OnPropertyChanged(nameof(ContextUsagePercent));
OnPropertyChanged(nameof(ContextUsageText));
}
}
}
public double ContextUsagePercent => MaxContextTokens > 0
? Math.Min(100.0, ContextTokens * 100.0 / MaxContextTokens)
: 0;
public string ContextUsageText => MaxContextTokens > 0
? $"{ContextTokens:N0} / {MaxContextTokens:N0}"
: "";
// ── 대화 목록 ────────────────────────────────────────────
public ObservableCollection<ConversationItemViewModel> Conversations { get; } = new();
private int _remainingConversationCount;
public int RemainingConversationCount
{
get => _remainingConversationCount;
set
{
if (SetProperty(ref _remainingConversationCount, value))
OnPropertyChanged(nameof(HasMoreConversations));
}
}
public bool HasMoreConversations => RemainingConversationCount > 0;
private string _emptyConversationText = "";
public string EmptyConversationText
{
get => _emptyConversationText;
set => SetProperty(ref _emptyConversationText, value);
}
private string _selectedCategory = "";
public string SelectedCategory
{
get => _selectedCategory;
set => SetProperty(ref _selectedCategory, value);
}
private bool _failedOnlyFilter;
public bool FailedOnlyFilter
{
get => _failedOnlyFilter;
set => SetProperty(ref _failedOnlyFilter, value);
}
private bool _runningOnlyFilter;
public bool RunningOnlyFilter
{
get => _runningOnlyFilter;
set => SetProperty(ref _runningOnlyFilter, value);
}
private bool _sortByRecent;
public bool SortByRecent
{
get => _sortByRecent;
set => SetProperty(ref _sortByRecent, value);
}
// ── Git 상태 ─────────────────────────────────────────────
private string _gitBranchName = "";
public string GitBranchName
{
get => _gitBranchName;
set
{
if (SetProperty(ref _gitBranchName, value))
OnPropertyChanged(nameof(HasGitBranch));
}
}
public bool HasGitBranch => !string.IsNullOrEmpty(GitBranchName);
// ── 작업 폴더 ────────────────────────────────────────────
private string _workFolder = "";
public string WorkFolder
{
get => _workFolder;
set
{
if (SetProperty(ref _workFolder, value))
{
OnPropertyChanged(nameof(HasWorkFolder));
OnPropertyChanged(nameof(WorkFolderDisplay));
}
}
}
public bool HasWorkFolder => !string.IsNullOrEmpty(WorkFolder);
public string WorkFolderDisplay => string.IsNullOrEmpty(WorkFolder) ? "폴더 선택" : WorkFolder;
}
/// <summary>대화 목록 항목 ViewModel — DataTemplate 바인딩 대상.</summary>
public class ConversationItemViewModel : ViewModelBase
{
public string Id { get; init; } = "";
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
private string _updatedAtText = "";
public string UpdatedAtText
{
get => _updatedAtText;
set => SetProperty(ref _updatedAtText, value);
}
private bool _pinned;
public bool Pinned
{
get => _pinned;
set => SetProperty(ref _pinned, value);
}
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
private bool _isRunning;
public bool IsRunning
{
get => _isRunning;
set => SetProperty(ref _isRunning, value);
}
public string Category { get; init; } = "general";
public string Symbol { get; init; } = "\uE8BD";
public string ColorHex { get; init; } = "#6B7280";
public string Tab { get; init; } = "Chat";
public string Preview { get; init; } = "";
public string? ParentId { get; init; }
public int AgentRunCount { get; init; }
public int FailedAgentRunCount { get; init; }
public string LastAgentRunSummary { get; init; } = "";
public string WorkFolder { get; init; } = "";
// ── 그룹 ──
public string Group { get; init; } = "오늘";
public int GroupOrder { get; init; }
// ── 표시용 computed ──
public bool IsBranch => !string.IsNullOrEmpty(ParentId);
public string IconText => Pinned ? "\uE718" : IsBranch ? "\uE8A5" : Symbol;
public string RunStatusText => FailedAgentRunCount > 0
? $"실패 {FailedAgentRunCount}"
: AgentRunCount > 0 ? $"실행 {AgentRunCount}" : "";
public bool HasRunStatus => AgentRunCount > 0 || FailedAgentRunCount > 0;
public bool HasFailed => FailedAgentRunCount > 0;
}

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