7 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
18 changed files with 3555 additions and 9010 deletions

View File

@@ -7,10 +7,72 @@ 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-10 09:02 (KST)
- AX Agent transcript 표시를 `claude-code`식 row 타입 기반으로 재정리했습니다. `thinking / waiting / compact / tool activity / permission / tool result / status`를 별도 row 의미로 정규화하고, process feed는 같은 활동 그룹이면 이어붙여 append 수를 줄이도록 바꿨습니다. - `claude-code` 기준으로 Cowork/Code의 남은 차이를 더 줄였습니다. Cowork/Code 프롬프트의 텍스트-only 완료 조건을 완화해, 작업이 이미 끝났거나 충분한 근거가 있을 때는 불필요한 도구 호출을 더 강제하지 않도록 정리했습니다.
- 권한 요청과 도구 결과 카탈로그를 다시 정리해 Cowork/Code 진행 중 무엇을 하는지, 어떤 결과 상태인지가 기본 transcript에서 더 읽히도록 맞췄습니다. 동시에 render 성능 로그에 row kind 분포와 process feed append/merge 수치를 남겨 이후 실검증 때 구조 개선 효과를 바로 판단할 수 있게 했습니다. - 에이전트 루프도 같이 손봤습니다. 텍스트-only 재시도는 이제 실제 산출물 생성이나 코드 수정처럼 구체적인 실행이 필요한 경우에만 다시 도구를 강제하고, 문서 생성 fallback도 같은 범위로 좁혔습니다.
- Cowork/Chat 하단 프리셋 안내 카드는 실제 메시지뿐 아니라 execution event가 생긴 뒤에도 자동으로 숨겨 결과/진행 화면을 가리지 않도록 조정했고, 입력 워터마크와 footer 기본 문구도 정상 한국어 기준으로 다시 정리했습니다. - 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 +1608,8 @@ 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`가 나오면 관련 파일만 다시 고르도록 교정합니다.
- [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)는 `%APPDATA%\\AxCopilot\\perf``exploration_breadth` 로그를 남겨 `folder_map` 호출 수, 총 읽은 파일 수, broad scan 여부를 실사용 기준으로 확인할 수 있게 했습니다.
- 업데이트: 2026-04-09 11:12 (KST)
- `claude-code``Messages.tsx`, `MessageRow.tsx`, `GroupedToolUseContent.tsx`, `UserToolResultMessage`, `PermissionRequest` 구조를 다시 대조해 AX transcript 표시 계약을 row 중심으로 정리했습니다.
- [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)에 `TranscriptRowKind``AgentTranscriptRowPresentation`을 도입해 thinking, waiting, compact, tool activity, permission, tool result, status를 한 번에 정규화하도록 바꿨습니다.
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)는 이 row 메타를 사용해 고빈도 process feed를 같은 group key 기준으로 교체 렌더하도록 바꿨고, 연속 읽기/검색/단계 이벤트를 더 적은 row 수로 보여주게 했습니다.
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)와 [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)를 정리해 실패/거부/취소/승인 필요와 권한 행위를 유형별 카드 메타로 다시 맞췄습니다.
- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs), [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 grouped process feed append/merge 수를 추적하도록 바꿔, `claude-code`식 activity grouping이 실제 렌더 수를 얼마나 줄였는지 성능 로그로 확인할 수 있게 했습니다.

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,16 @@ 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 스레드 부담이 완화됩니다.

View File

@@ -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
@@ -24,6 +26,196 @@ public partial class AgentLoopService
public bool CorrectiveHintInjected { get; set; } public bool CorrectiveHintInjected { get; set; }
} }
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
IReadOnlyCollection<IAgentTool> tools,
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
if (tools.Count == 0)
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)
{ {
if (string.IsNullOrWhiteSpace(userQuery)) if (string.IsNullOrWhiteSpace(userQuery))
@@ -32,6 +224,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 +249,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 +323,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;

View File

@@ -23,7 +23,7 @@ public partial class AgentLoopService
string Content, string Content,
string? PreviousContent = null); string? PreviousContent = null);
private readonly LlmService _llm; private readonly ILlmService _llm;
private readonly ToolRegistry _tools; private readonly ToolRegistry _tools;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator; private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
@@ -82,7 +82,7 @@ public partial class AgentLoopService
/// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary> /// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary>
public event Action<AgentEvent>? EventOccurred; public event Action<AgentEvent>? EventOccurred;
public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings) public AgentLoopService(ILlmService llm, ToolRegistry tools, SettingsService settings)
{ {
_llm = llm; _llm = llm;
_tools = tools; _tools = tools;
@@ -180,6 +180,9 @@ public partial class AgentLoopService
Scope = ClassifyExplorationScope(userQuery, ActiveTab), Scope = ClassifyExplorationScope(userQuery, ActiveTab),
SelectiveHit = true, SelectiveHit = true,
}; };
var pathAccessState = new PathAccessTrackingState();
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
// 워크플로우 상세 로그: 에이전트 루프 시작 // 워크플로우 상세 로그: 에이전트 루프 시작
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start", WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
@@ -236,17 +239,20 @@ public partial class AgentLoopService
var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType); var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType);
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType); maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
// 플랜 prelude는 현재 정책상 비활성
var shouldGeneratePlanPrelude = false;
var context = BuildContext(); var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy); InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope); InjectExplorationScopeGuidance(messages, explorationState.Scope);
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
taskPolicy,
ActiveTab,
userQuery);
EmitEvent( EmitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
"", "",
explorationState.Scope switch explorationState.Scope switch
{ {
ExplorationScope.DirectCreation => "문서 생성 모드 · 바로 문서를 만드는 중",
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중", ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중", ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중", ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
@@ -331,148 +337,6 @@ public partial class AgentLoopService
workFolder = context.WorkFolder workFolder = context.WorkFolder
})); }));
// ── 과거 plan mode 잔재. 현재 정책상 비활성 ──
if (shouldGeneratePlanPrelude)
{
iteration++;
EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중...");
// 계획 생성 전용 시스템 지시를 임시 추가
var planInstruction = new ChatMessage
{
Role = "user",
Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. " +
"각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. " +
"계획만 제시하고 실행은 하지 마세요."
};
messages.Add(planInstruction);
// 도구 없이 텍스트만 요청
string planText;
try
{
planText = await _llm.SendAsync(messages, ct);
}
catch (Exception ex)
{
EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}");
return $"⚠ LLM 오류: {ex.Message}";
}
// 계획 지시 메시지 제거 (실제 실행 시 혼란 방지)
messages.Remove(planInstruction);
// 계획 추출
planSteps = TaskDecomposer.ExtractSteps(planText);
planExtracted = true;
if (planSteps.Count > 0)
{
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계",
steps: planSteps);
// 사용자 승인 대기
if (UserDecisionCallback != null)
{
EmitEvent(
AgentEventType.Decision,
"",
$"계획 확인 대기 · {planSteps.Count}단계",
steps: planSteps);
var decision = await UserDecisionCallback(
planText,
new List<string> { "승인", "수정 요청", "취소" });
EmitPlanDecisionResultEvent(decision, planSteps);
if (decision == "취소")
{
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
return "작업이 중단되었습니다.";
}
else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps))
{
planText = approvedPlanText;
planSteps = approvedPlanSteps;
}
else if (decision != null && decision != "승인")
{
// 수정 요청 — 피드백으로 계획 재생성
messages.Add(new ChatMessage { Role = "assistant", Content = planText });
messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." });
// 재생성 루프 (최대 3회)
for (int retry = 0; retry < 3; retry++)
{
try { planText = await _llm.SendAsync(messages, ct); }
catch { break; }
planSteps = TaskDecomposer.ExtractSteps(planText);
if (planSteps.Count > 0)
{
EmitEvent(AgentEventType.Planning, "", $"수정된 계획: {planSteps.Count}단계",
steps: planSteps);
}
EmitEvent(
AgentEventType.Decision,
"",
$"수정 계획 확인 대기 · {planSteps.Count}단계",
steps: planSteps);
decision = await UserDecisionCallback(
planText,
new List<string> { "승인", "수정 요청", "취소" });
EmitPlanDecisionResultEvent(decision, planSteps);
if (decision == "취소")
{
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
return "작업이 중단되었습니다.";
}
if (TryParseApprovedPlanDecision(decision, out var revisedPlanText, out var revisedPlanSteps))
{
planText = revisedPlanText;
planSteps = revisedPlanSteps;
break;
}
if (decision == null || decision == "승인") break;
// 재수정
messages.Add(new ChatMessage { Role = "assistant", Content = planText });
messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." });
}
}
}
// 승인된 계획을 컨텍스트에 포함하여 실행 유도
// 도구 호출을 명확히 강제하여 텍스트 응답만 반환하는 경우 방지
messages.Add(new ChatMessage { Role = "assistant", Content = planText });
// 1차 계획의 단계들을 document_plan의 sections_hint로 전달하도록 지시
// → BuildSections() 하드코딩 대신 LLM이 잡은 섹션 구조가 문서에 반영됨
var planSectionsHint = planSteps.Count > 0
? string.Join(", ", planSteps)
: "";
var sectionInstruction = !string.IsNullOrEmpty(planSectionsHint)
? $"document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"{planSectionsHint}\""
: "";
messages.Add(new ChatMessage { Role = "user",
Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. " +
"텍스트로 설명하지 말고 반드시 도구를 호출하세요." +
(string.IsNullOrEmpty(sectionInstruction) ? "" : "\n" + sectionInstruction) });
}
else
{
// 계획 추출 실패 — assistant 응답으로 추가하고 일반 모드로 진행
if (!string.IsNullOrEmpty(planText))
messages.Add(new ChatMessage { Role = "assistant", Content = planText });
}
}
while (iteration < maxIterations && !ct.IsCancellationRequested) while (iteration < maxIterations && !ct.IsCancellationRequested)
{ {
iteration++; iteration++;
@@ -518,6 +382,19 @@ public partial class AgentLoopService
} }
} }
if (lastToolResultAtUtc is { } toolResultAt)
{
var waitMs = Math.Max(0, (long)(DateTime.UtcNow - toolResultAt).TotalMilliseconds);
WorkflowLogService.LogTransition(
_conversationId,
_currentRunId,
iteration,
"llm_wait_after_tool_result",
$"{lastToolResultToolName ?? "unknown"}:{waitMs}ms");
lastToolResultAtUtc = null;
lastToolResultToolName = null;
}
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"); EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
// Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) // Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
@@ -554,10 +431,15 @@ public partial class AgentLoopService
} }
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨 // P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides); var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
explorationState,
userQuery,
ActiveTab,
totalToolCalls);
// LLM에 도구 정의와 함께 요청 // LLM에 도구 정의와 함께 요청
List<LlmService.ContentBlock> blocks; List<ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew(); var llmCallSw = Stopwatch.StartNew();
try try
{ {
@@ -612,7 +494,7 @@ public partial class AgentLoopService
{ {
switch (evt.Kind) switch (evt.Kind)
{ {
case LlmService.ToolStreamEventKind.TextDelta: case ToolStreamEventKind.TextDelta:
if (!string.IsNullOrWhiteSpace(evt.Text)) if (!string.IsNullOrWhiteSpace(evt.Text))
{ {
streamedTextPreview.Append(evt.Text); streamedTextPreview.Append(evt.Text);
@@ -626,7 +508,7 @@ public partial class AgentLoopService
} }
} }
break; break;
case LlmService.ToolStreamEventKind.ToolCallReady: case ToolStreamEventKind.ToolCallReady:
if (evt.ToolCall != null) if (evt.ToolCall != null)
{ {
EmitEvent( EmitEvent(
@@ -635,9 +517,14 @@ public partial class AgentLoopService
$"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}"); $"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}");
} }
break; break;
case LlmService.ToolStreamEventKind.Completed: case ToolStreamEventKind.Completed:
await Task.CompletedTask; await Task.CompletedTask;
break; break;
case ToolStreamEventKind.RetryReset:
streamedTextPreview.Clear();
lastStreamUiUpdateAt = DateTime.UtcNow;
EmitEvent(AgentEventType.Thinking, "", "스트리밍 중간 응답을 정리하고 재시도합니다.");
break;
} }
}); });
runState.ContextRecoveryAttempts = 0; runState.ContextRecoveryAttempts = 0;
@@ -745,7 +632,7 @@ public partial class AgentLoopService
// 응답에서 텍스트와 도구 호출 분리 // 응답에서 텍스트와 도구 호출 분리
var textParts = new List<string>(); var textParts = new List<string>();
var toolCalls = new List<LlmService.ContentBlock>(); var toolCalls = new List<ContentBlock>();
foreach (var block in blocks) foreach (var block in blocks)
{ {
@@ -831,7 +718,13 @@ public partial class AgentLoopService
// 도구 호출이 없으면 루프 종료 — 단, 문서 생성 요청인데 파일이 미생성이면 자동 저장 // 도구 호출이 없으면 루프 종료 — 단, 문서 생성 요청인데 파일이 미생성이면 자동 저장
if (toolCalls.Count == 0) if (toolCalls.Count == 0)
{ {
if (totalToolCalls == 0 var requiresConcreteArtifactOrEdit =
string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)
? taskPolicy.TaskType is "bugfix" or "feature" or "refactor"
: IsDocumentCreationRequest(userQuery);
if (requiresConcreteArtifactOrEdit
&& totalToolCalls == 0
&& consecutiveNoToolResponses >= noToolResponseThreshold && consecutiveNoToolResponses >= noToolResponseThreshold
&& runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries) && runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries)
{ {
@@ -851,13 +744,15 @@ public partial class AgentLoopService
"[System:ToolCallRequired] " + "[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " + "⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " + "텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
$"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" + "지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}", $"사용 가능한 도구: {activeToolPreview}",
_ => _ =>
"[System:ToolCallRequired] " + "[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " + "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" + $"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
"반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}" $"반드시 사용해야 할 도구 목록: {activeToolPreview}"
}; };
@@ -874,7 +769,8 @@ public partial class AgentLoopService
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것 // 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회) // "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
if (executionPolicy.ForceToolCallAfterPlan if (requiresConcreteArtifactOrEdit
&& executionPolicy.ForceToolCallAfterPlan
&& planSteps.Count > 0 && planSteps.Count > 0
&& totalToolCalls == 0 && totalToolCalls == 0
&& planExecutionRetry < planExecutionRetryMax) && planExecutionRetry < planExecutionRetryMax)
@@ -891,12 +787,13 @@ public partial class AgentLoopService
{ {
1 => 1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " + "[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
$"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" + "지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}", $"사용 가능한 도구: {planToolList}",
_ => _ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " + "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" + $"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" + "<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}" $"사용 가능한 도구: {planToolList}"
}; };
@@ -906,7 +803,8 @@ public partial class AgentLoopService
} }
// 문서 생성 우선 프로파일은 재시도보다 빠른 fallback을 우선합니다. // 문서 생성 우선 프로파일은 재시도보다 빠른 fallback을 우선합니다.
if (documentPlanCalled if (requiresConcreteArtifactOrEdit
&& documentPlanCalled
&& executionPolicy.PreferAggressiveDocumentFallback && executionPolicy.PreferAggressiveDocumentFallback
&& !string.IsNullOrEmpty(documentPlanScaffold) && !string.IsNullOrEmpty(documentPlanScaffold)
&& !_docFallbackAttempted) && !_docFallbackAttempted)
@@ -915,7 +813,8 @@ public partial class AgentLoopService
} }
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 프로파일 기준 재시도 // document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 프로파일 기준 재시도
if (documentPlanCalled && postDocumentPlanRetry < documentPlanRetryMax) if (requiresConcreteArtifactOrEdit
&& documentPlanCalled && postDocumentPlanRetry < documentPlanRetryMax)
{ {
postDocumentPlanRetry++; postDocumentPlanRetry++;
if (!string.IsNullOrEmpty(textResponse)) if (!string.IsNullOrEmpty(textResponse))
@@ -930,7 +829,8 @@ public partial class AgentLoopService
} }
// 재시도도 모두 소진 → 앱이 직접 본문 생성 후 html_create 강제 실행 // 재시도도 모두 소진 → 앱이 직접 본문 생성 후 html_create 강제 실행
if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) if (requiresConcreteArtifactOrEdit
&& documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted)
{ {
_docFallbackAttempted = true; _docFallbackAttempted = true;
EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다..."); EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다...");
@@ -1248,7 +1148,7 @@ public partial class AgentLoopService
: resolvedToolName; : resolvedToolName;
var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase) var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase)
? call ? call
: new LlmService.ContentBlock : new ContentBlock
{ {
Type = call.Type, Type = call.Type,
Text = call.Text, Text = call.Text,
@@ -1306,6 +1206,18 @@ public partial class AgentLoopService
continue; continue;
} }
if (TryHandleRepeatedPathAccessTransition(
effectiveCall,
context,
pathAccessState,
messages,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy))
{
continue;
}
// Task Decomposition: 단계 진행률 추적 // Task Decomposition: 단계 진행률 추적
if (planSteps.Count > 0) if (planSteps.Count > 0)
{ {
@@ -1345,7 +1257,7 @@ public partial class AgentLoopService
Role = "system", Role = "system",
Content = Content =
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " + "Exploration correction: The current request is narrow. Stop broad workspace scanning. " +
"Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " + "Follow this order: glob/grep -> targeted file_read/document_read -> folder_map only if the user explicitly needs folder structure. " +
"Do not repeat folder_map unless repository structure is genuinely required." "Do not repeat folder_map unless repository structure is genuinely required."
}); });
EmitEvent( EmitEvent(
@@ -1405,7 +1317,7 @@ public partial class AgentLoopService
if (llm.EnableHookInputMutation && if (llm.EnableHookInputMutation &&
TryGetHookUpdatedInput(preResults, out var updatedInput)) TryGetHookUpdatedInput(preResults, out var updatedInput))
{ {
effectiveCall = new LlmService.ContentBlock effectiveCall = new ContentBlock
{ {
Type = effectiveCall.Type, Type = effectiveCall.Type,
Text = effectiveCall.Text, Text = effectiveCall.Text,
@@ -1533,6 +1445,8 @@ public partial class AgentLoopService
ref statsFailCount, ref statsFailCount,
ref statsInputTokens, ref statsInputTokens,
ref statsOutputTokens); ref statsOutputTokens);
lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName;
if (!result.Success) if (!result.Success)
{ {
@@ -1608,7 +1522,14 @@ public partial class AgentLoopService
messages.Add(LlmService.CreateToolResultMessage( messages.Add(LlmService.CreateToolResultMessage(
effectiveCall.ToolId, effectiveCall.ToolId,
effectiveCall.ToolName, effectiveCall.ToolName,
BuildLoopToolResultMessage(effectiveCall, result, runState))); BuildToolResultFeedbackMessage(
effectiveCall,
result,
runState,
context,
messages,
explorationState,
iteration)));
if (TryHandleReadOnlyStagnationTransition( if (TryHandleReadOnlyStagnationTransition(
consecutiveReadOnlySuccessTools, consecutiveReadOnlySuccessTools,
@@ -1909,7 +1830,8 @@ public partial class AgentLoopService
SkillRuntimeOverrides? runtimeOverrides) SkillRuntimeOverrides? runtimeOverrides)
{ {
var mergedDisabled = MergeDisabledTools(disabledToolNames); var mergedDisabled = MergeDisabledTools(disabledToolNames);
var active = _tools.GetActiveTools(mergedDisabled); // 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약)
var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active; return active;
@@ -2776,7 +2698,7 @@ public partial class AgentLoopService
? testReviewLine + ? testReviewLine +
"5. grep 또는 glob으로 공용 API, 호출부, 의존성 등록, 테스트 영향을 모두 다시 확인합니다.\n" + "5. grep 또는 glob으로 공용 API, 호출부, 의존성 등록, 테스트 영향을 모두 다시 확인합니다.\n" +
"6. build_run으로 build와 test를 모두 실행해 통과 여부를 확인합니다.\n" + "6. build_run으로 build와 test를 모두 실행해 통과 여부를 확인합니다.\n" +
"7. 필요하면 spawn_agent로 호출부 분석이나 관련 테스트 탐색을 병렬 조사하게 하고, wait_agents로 결과를 통합합니다.\n" + "7. 병렬 조사가 실제로 도움이 될 때만 spawn_agent로 호출부 분석이나 관련 테스트 탐색을 위임하고, wait_agents로 결과를 통합합니다.\n" +
"8. 문제가 발견되면 즉시 수정하고 다시 검증합니다.\n" + "8. 문제가 발견되면 즉시 수정하고 다시 검증합니다.\n" +
"중요: 이 변경은 영향 범위가 넓을 가능성이 큽니다. build/test와 참조 검토가 모두 끝나기 전에는 마무리하지 마세요." "중요: 이 변경은 영향 범위가 넓을 가능성이 큽니다. build/test와 참조 검토가 모두 끝나기 전에는 마무리하지 마세요."
: testReviewLine + : testReviewLine +
@@ -2927,7 +2849,7 @@ public partial class AgentLoopService
var failureHint = BuildFailureTypeRecoveryHint(ClassifyFailureRecoveryKind(toolName, toolOutput), toolName); var failureHint = BuildFailureTypeRecoveryHint(ClassifyFailureRecoveryKind(toolName, toolOutput), toolName);
var highImpactLine = highImpactChange var highImpactLine = highImpactChange
? "4. 공용 API, 인터페이스, DI 등록, 모델 계약, 호출부 전파 영향까지 반드시 확인합니다.\n" + ? "4. 공용 API, 인터페이스, DI 등록, 모델 계약, 호출부 전파 영향까지 반드시 확인합니다.\n" +
"5. 필요하면 spawn_agent로 호출부/관련 테스트 조사를 병렬 실행해 근거를 보강합니다.\n" + "5. 병렬 조사가 실제로 도움이 될 때만 spawn_agent로 호출부/관련 테스트 조사를 실행해 근거를 보강합니다.\n" +
"6. 관련 테스트가 없다면 테스트 부재 사실과 확인 근거를 남깁니다.\n" + "6. 관련 테스트가 없다면 테스트 부재 사실과 확인 근거를 남깁니다.\n" +
"7. 근본 원인을 수정한 뒤 build/test를 다시 실행합니다.\n" + "7. 근본 원인을 수정한 뒤 build/test를 다시 실행합니다.\n" +
"중요: 이 변경은 영향 범위가 넓을 수 있으므로 build와 test 둘 다 확인하기 전에는 종료하지 마세요." "중요: 이 변경은 영향 범위가 넓을 수 있으므로 build와 test 둘 다 확인하기 전에는 종료하지 마세요."
@@ -3481,8 +3403,8 @@ public partial class AgentLoopService
private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact) private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact)
{ {
var taskLine = taskPolicy.FinalReportTaskLine; var taskLine = taskPolicy.FinalReportTaskLine;
var riskLine = highImpact var riskLine = taskPolicy.IsReviewTask || highImpact
? "고영향 변경이므로 남은 리스크나 추가 확인 필요 사항도 반드시 적으세요.\n" ? "남은 리스크나 추가 확인 필요 사항이 실제로 남아 있을 때만 짧게 적으세요.\n"
: ""; : "";
return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" + return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" +
@@ -3494,7 +3416,7 @@ public partial class AgentLoopService
"6. review 작업이면 이슈별로 수정 완료/미수정 상태를 분리해 적으세요\n" + "6. review 작업이면 이슈별로 수정 완료/미수정 상태를 분리해 적으세요\n" +
taskLine + taskLine +
riskLine + riskLine +
"가능하면 짧고 명확하게 요약하세요."; "후속 권유는 실제 미해결 위험이나 추가 확인이 남았을 때만 포함하세요. 가능하면 짧고 명확하게 요약하세요.";
} }
private static string BuildFinalReportQualityPrompt(string taskType, bool highImpact) private static string BuildFinalReportQualityPrompt(string taskType, bool highImpact)
@@ -3803,7 +3725,9 @@ public partial class AgentLoopService
$"- 실패 도구: {unknownToolName}\n" + $"- 실패 도구: {unknownToolName}\n" +
aliasHint + aliasHint +
$"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" + $"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" +
"- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" + (string.IsNullOrEmpty(aliasHint)
? "- 위 후보만으로도 충분히 선택 가능하면 바로 그 도구를 호출하세요. 그래도 도구가 애매할 때만 tool_search를 사용하세요.\n"
: "- 자동 매핑 후보가 맞으면 바로 그 도구를 호출하세요. 정말 모호할 때만 tool_search를 사용하세요.\n") +
"위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요."; "위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요.";
} }
@@ -3823,7 +3747,7 @@ public partial class AgentLoopService
$"- 요청 도구: {requestedToolName}\n" + $"- 요청 도구: {requestedToolName}\n" +
policyLine + policyLine +
$"- 지금 사용 가능한 도구 예시: {activePreview}\n" + $"- 지금 사용 가능한 도구 예시: {activePreview}\n" +
"- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" + "- 위 허용 도구 예시로 바로 대체할 수 있으면 즉시 호출하세요. 그래도 도구 선택이 모호할 때만 tool_search를 사용하세요.\n" +
"허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다."; "허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다.";
} }
@@ -3837,7 +3761,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedUnknownToolCount}\n" + $"- 반복 횟수: {repeatedUnknownToolCount}\n" +
$"- 실패 도구: {unknownToolName}\n" + $"- 실패 도구: {unknownToolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요."; "- 다음 실행에서는 먼저 위 목록의 실제 도구 이름을 직접 선택하고, 그래도 애매할 때만 tool_search를 사용하세요.";
} }
private static string BuildDisallowedToolLoopAbortResponse( private static string BuildDisallowedToolLoopAbortResponse(
@@ -3850,7 +3774,7 @@ public partial class AgentLoopService
$"- 반복 횟수: {repeatedCount}\n" + $"- 반복 횟수: {repeatedCount}\n" +
$"- 비허용 도구: {toolName}\n" + $"- 비허용 도구: {toolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요."; "- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요.";
} }
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
@@ -4136,7 +4060,7 @@ public partial class AgentLoopService
// 검증 응답 처리 // 검증 응답 처리
var verifyText = new List<string>(); var verifyText = new List<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>(); var verifyToolCalls = new List<ContentBlock>();
foreach (var block in verifyBlocks) foreach (var block in verifyBlocks)
{ {
@@ -4724,7 +4648,7 @@ public partial class AgentLoopService
} }
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary> /// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) private string? CheckDecisionRequired(ContentBlock call, AgentContext context)
{ {
var app = System.Windows.Application.Current as App; var app = System.Windows.Application.Current as App;
var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal"; var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal";
@@ -4811,7 +4735,7 @@ public partial class AgentLoopService
return null; return null;
} }
private static string FormatToolCallSummary(LlmService.ContentBlock call) private static string FormatToolCallSummary(ContentBlock call)
{ {
if (call.ToolInput == null) return call.ToolName; if (call.ToolInput == null) return call.ToolName;
try try
@@ -4829,7 +4753,7 @@ public partial class AgentLoopService
catch { return call.ToolName; } catch { return call.ToolName; }
} }
private static string BuildToolCallSignature(LlmService.ContentBlock call) private static string BuildToolCallSignature(ContentBlock call)
{ {
var name = (call.ToolName ?? "").Trim().ToLowerInvariant(); var name = (call.ToolName ?? "").Trim().ToLowerInvariant();
if (call.ToolInput == null) if (call.ToolInput == null)
@@ -4929,7 +4853,7 @@ public partial class AgentLoopService
FailureRecoveryKind.Permission => FailureRecoveryKind.Permission =>
"권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행", "권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행",
FailureRecoveryKind.Path => FailureRecoveryKind.Path =>
"folder_map/glob -> 절대경로 확인 -> file_read로 존재 검증 -> 원도구 재실행", "glob -> file_read -> folder_map(필요 시만) -> 절대경로 확인 -> 원도구 재실행",
FailureRecoveryKind.Command => FailureRecoveryKind.Command =>
"도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구", "도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구",
FailureRecoveryKind.Dependency => FailureRecoveryKind.Dependency =>
@@ -4940,7 +4864,7 @@ public partial class AgentLoopService
{ {
"build_run" or "test_loop" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", "build_run" or "test_loop" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop",
"file_edit" or "file_write" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", "file_edit" or "file_write" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop",
"grep" or "glob" => "file_read -> folder_map -> grep/glob", "grep" or "glob" => "glob/grep 재구성 -> file_read -> folder_map(필요 시만)",
_ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry" _ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry"
} }
}; };

View File

@@ -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,
@@ -96,9 +96,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,
@@ -134,7 +134,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 +156,16 @@ public partial class AgentLoopService
if (!shouldVerify) if (!shouldVerify)
return false; return false;
if (string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
{
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;
} }

View File

@@ -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;
@@ -421,7 +421,7 @@ public partial class AgentLoopService
} }
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,
@@ -463,7 +463,7 @@ public partial class AgentLoopService
} }
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,
@@ -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,
@@ -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)
{ {
@@ -1193,4 +1193,3 @@ public partial class AgentLoopService
} }

View File

@@ -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)
{ {
@@ -120,7 +132,7 @@ public partial class AgentLoopService
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++;
@@ -148,6 +160,9 @@ public partial class AgentLoopService
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;
@@ -179,6 +194,9 @@ public partial class AgentLoopService
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;

View File

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

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)

View File

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

View File

@@ -22,7 +22,7 @@ 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 = "작업 유형: bugfix. 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",
@@ -33,7 +33,8 @@ internal sealed class 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 = "작업 유형: feature. 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",
@@ -44,7 +45,7 @@ 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 = "작업 유형: refactor. 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",
@@ -56,7 +57,7 @@ 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 = "작업 유형: review-follow-up. 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",
@@ -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/WRITE a new document, skip file exploration entirely — " +
"go directly to the creation tool (docx_create, html_create, excel_create, etc.) and use document_plan only when it materially helps structure a multi-section document. " +
"to produce a REAL FILE on disk. Do NOT respond with text only — the output MUST be a file. " +
"If the user asks you to READ/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 = "",

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>

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>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -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;
@@ -869,59 +870,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 +968,60 @@ 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 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; }
@@ -1230,11 +1254,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 +1399,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 +1469,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))

View File

@@ -8,12 +8,12 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Views; namespace AxCopilot.Views;
/// <summary> /// <summary>
/// 에이전트 이벤트의 무거운 작업(대화 변이·저장)을 백그라운드 스레드에서 처리합니다. /// 에이전트 이벤트의 무거운 작업(대화 반영, 저장, 실행 이력 기록)을
/// UI 스레드는 시각적 피드백만 담당하여 채팅창 멈춤을 방지합니다. /// 백그라운드 단일 리더에서 배치 처리해 UI 스레드 점유를 줄입니다.
/// </summary> /// </summary>
public partial class ChatWindow public partial class ChatWindow
{ {
/// <summary>백그라운드 처리 대상 이벤트 큐 아이템.</summary> /// <summary>백그라운드 처리 대상 에이전트 이벤트 단위입니다.</summary>
private readonly record struct AgentEventWorkItem( private readonly record struct AgentEventWorkItem(
AgentEvent Event, AgentEvent Event,
string EventTab, string EventTab,
@@ -26,50 +26,31 @@ public partial class ChatWindow
private Task? _agentEventProcessorTask; private Task? _agentEventProcessorTask;
/// <summary>백그라운드 이벤트 프로세서를 시작합니다. 창 초기화 시 한 번 호출됩니다.</summary> /// <summary>백그라운드 이벤트 프로세서를 시작합니다.</summary>
private void StartAgentEventProcessor() private void StartAgentEventProcessor()
{ {
_agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync); _agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync);
} }
/// <summary>백그라운드 이벤트 프로세서를 종료합니다. 창 닫기 시 호출됩니다.</summary> /// <summary>백그라운드 이벤트 프로세서를 종료합니다.</summary>
private void StopAgentEventProcessor() private void StopAgentEventProcessor()
{ {
_agentEventChannel.Writer.TryComplete(); _agentEventChannel.Writer.TryComplete();
// 프로세서 완료를 동기 대기하지 않음 — 데드락 방지 // 종료 대기는 생략한다. 남은 정리는 GC와 종료 루틴에 맡긴다.
// GC가 나머지를 정리합니다.
} }
/// <summary> /// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다. /// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지). /// 대화 히스토리 반영과 저장은 프로세서에서 한 번만 수행합니다.
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
/// </summary> /// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender) private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{ {
// ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ──
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var result = _chatEngine.AppendExecutionEvent(
session, null!, _currentConversation, _activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}");
}
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender)); _agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
} }
/// <summary>백그라운드 전용: 채널에서 이벤트를 배치로 읽어 대화 변이 + 저장을 수행합니다.</summary> /// <summary>
/// 백그라운드 전용: 채널에 쌓인 이벤트를 배치로 읽어 대화 반영과 저장을 수행합니다.
/// </summary>
private async Task ProcessAgentEventsAsync() private async Task ProcessAgentEventsAsync()
{ {
var reader = _agentEventChannel.Reader; var reader = _agentEventChannel.Reader;
@@ -97,7 +78,6 @@ public partial class ChatWindow
var eventTab = work.EventTab; var eventTab = work.EventTab;
var activeTab = work.ActiveTab; var activeTab = work.ActiveTab;
// ── 대화 변이: execution event 추가 ──
try try
{ {
lock (_convLock) lock (_convLock)
@@ -117,7 +97,6 @@ public partial class ChatWindow
LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}");
} }
// ── 대화 변이: agent run 추가 (Complete/Error) ──
if (evt.Type == AgentEventType.Complete) if (evt.Type == AgentEventType.Complete)
{ {
try try
@@ -139,6 +118,7 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}");
} }
hasTerminalEvent = true; hasTerminalEvent = true;
} }
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName)) else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
@@ -162,6 +142,7 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}");
} }
hasTerminalEvent = true; hasTerminalEvent = true;
} }
@@ -169,7 +150,6 @@ public partial class ChatWindow
anyNeedsRender = true; anyNeedsRender = true;
} }
// ── 디바운스 저장: 2초마다 또는 종료 이벤트 시 즉시 ──
if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000)) if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000))
{ {
try try
@@ -182,11 +162,11 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}"); LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}");
} }
pendingPersist = null; pendingPersist = null;
persistStopwatch.Restart(); persistStopwatch.Restart();
} }
// ── UI 새로고침 신호: 배치 전체에 대해 단 1회 ──
if (anyNeedsRender) if (anyNeedsRender)
{ {
try try
@@ -195,7 +175,10 @@ public partial class ChatWindow
() => ScheduleExecutionHistoryRender(autoScroll: true), () => ScheduleExecutionHistoryRender(autoScroll: true),
DispatcherPriority.Background); DispatcherPriority.Background);
} }
catch { /* 앱 종료 중 무시 */ } catch
{
// 창 종료 중에는 무시한다.
}
} }
} }
} }
@@ -206,10 +189,16 @@ public partial class ChatWindow
LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}"); LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}");
} }
// ── 종료 시 미저장 대화 플러시 ──
if (pendingPersist != null) if (pendingPersist != null)
{ {
try { _storage.Save(pendingPersist); } catch { } try
{
_storage.Save(pendingPersist);
}
catch
{
// 종료 중 마지막 저장 실패는 무시한다.
}
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,435 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
private string BuildCoworkSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var sb = new System.Text.StringBuilder();
// ══════════════════════════════════════════════════════════════════
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
// ══════════════════════════════════════════════════════════════════
sb.AppendLine("## [절대 규칙] 도구 우선 — USE TOOLS WHEN THEY MATERIALLY ADVANCE THE TASK");
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
sb.AppendLine("Treat these as operating guidance, not as a requirement to force unnecessary tool calls.");
sb.AppendLine("");
sb.AppendLine("### [규칙 1] 필요한 도구를 먼저 사용하라 — Tools First When Needed");
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
sb.AppendLine("Use tools whenever they materially advance the task. Do not answer with text only when inspection, file creation, editing, or verification is still needed.");
sb.AppendLine("A text-only response is fine once the requested artifact already exists, the requested analysis is complete, or enough evidence has been gathered.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '작업을 시작하겠습니다' / '먼저 ... 하겠습니다'");
sb.AppendLine(" ✗ '다음과 같이 진행하겠습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...' / 'First, I need to ...'");
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 간단한 설명은 도구 결과 이후에만 허용됩니다.");
sb.AppendLine("CORRECT: call the tool immediately. A brief explanation may follow AFTER the tool result, never before.");
sb.AppendLine("");
sb.AppendLine("### [규칙 3] 한 번에 여러 도구를 호출하라 — Batch Multiple Tools");
sb.AppendLine("독립적인 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 순차적으로 하지 마세요.");
sb.AppendLine("Batch independent reads only when it genuinely saves time and keeps the scope narrow.");
sb.AppendLine(" 나쁜 예(BAD): 응답1: folder_map → 응답2: document_read → 응답3: html_create (순차 처리 — 금지)");
sb.AppendLine(" 좋은 예(GOOD): 응답1: folder_map + document_read 동시 호출 → 응답2: html_create (배치 처리)");
sb.AppendLine("");
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty");
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때도 사용자에게 묻지 말고 즉시 folder_map이나 document_read를 호출하세요.");
sb.AppendLine("If the task requires existing files, start with glob or grep, then use document_read or file_read on the best candidates. If the user wants a brand-new document, skip file exploration and create the artifact directly.");
sb.AppendLine("");
sb.AppendLine("### [규칙 5] 완료까지 계속 도구를 호출하라 — Continue Until Complete");
sb.AppendLine("사용자의 요청이 완전히 완료될 때까지 도구 호출을 계속하세요. '작업을 시작했습니다'는 완료가 아닙니다.");
sb.AppendLine("'완료'의 정의: 결과 파일이 실제로 존재하고, 검증되고, 사용자에게 보고된 상태.");
sb.AppendLine("Keep working until the artifact exists or a concrete blocker is reached. Prefer the shortest path to a complete result.");
sb.AppendLine("");
// 소개 및 메타 정보
sb.AppendLine("---");
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
sb.AppendLine("\nOnly present a step-by-step plan when the user explicitly asks for a plan or a short execution outline is required to unblock the task safely.");
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the requested result.");
sb.AppendLine("If the user asks for a brand-new report, proposal, analysis, manual, or other document and does not explicitly ask to reference workspace files, do NOT start with glob, grep, document_read, or folder_map.");
sb.AppendLine("In that case, go straight to the creation tool. Use document_plan only when it materially improves a multi-section document.");
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered.");
sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates.");
sb.AppendLine("IMPORTANT: For reports, proposals, analyses, and manuals with multiple sections:");
sb.AppendLine(" 1. Decide the document structure internally first.");
sb.AppendLine(" 2. Use document_plan only when it improves the actual output, not as a mandatory user-facing approval step.");
sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write.");
sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only.");
sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");
// 문서 품질 검증 루프
sb.AppendLine("\n## Document Quality Review");
sb.AppendLine("After creating a document, verify it in the lightest way that is sufficient:");
sb.AppendLine("1. Start with file_read or document_read to confirm the generated file exists and covers the requested sections");
sb.AppendLine("2. Use document_review only for large generated documents, conversion-heavy output, or when the user explicitly asks for a quality check");
sb.AppendLine("3. Check logical errors only when relevant: dates, missing sections, broken formatting, or placeholder text");
sb.AppendLine("4. If issues are found, fix them using file_write or file_edit, then re-verify");
sb.AppendLine("5. Report what was checked and whether corrections were needed");
// 문서 포맷 변환 지원
sb.AppendLine("\n## Format Conversion");
sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):");
sb.AppendLine("Use format conversion tools only when the user explicitly requests conversion between formats.");
sb.AppendLine("1. Use file_read or document_read to read the source file content");
sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)");
sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible");
// 사용자 지정 출력 포맷
var fmt = llm.DefaultOutputFormat;
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
{
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["xlsx"] = "Excel (.xlsx) using excel_create",
["docx"] = "Word (.docx) using docx_create",
["html"] = "HTML (.html) using html_create",
["md"] = "Markdown (.md) using markdown_create",
["csv"] = "CSV (.csv) using csv_create",
};
if (fmtMap.TryGetValue(fmt, out var fmtDesc))
sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise.");
}
// 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내
if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern")
sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template.");
else
sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"Current work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
sb.Append(BuildSubAgentDelegationSection(false));
// 폴더 데이터 활용 지침 (사용자 옵션이 아닌 탭별 자동 정책)
switch (GetAutomaticFolderDataUsage())
{
case "active":
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
sb.AppendLine("Use workspace exploration only when the user explicitly asks to reference existing materials, provides a file/path hint, or asks about folder contents.");
sb.AppendLine("For brand-new document creation requests, skip glob/grep/document_read/folder_map and proceed directly to the creation tool. Use document_plan only when structure work materially improves the output.");
sb.AppendLine("[CRITICAL] FILE SELECTION STRATEGY — DO NOT READ ALL FILES:");
sb.AppendLine(" 1. Identify candidate files by filename or topic keywords first.");
sb.AppendLine(" 2. Read ONLY files that clearly match the user's topic. Skip unrelated topics.");
sb.AppendLine(" 3. Maximum 2-3 relevant files for the first pass. Expand only when evidence shows more files are needed.");
sb.AppendLine(" 4. Do NOT read every file 'just in case'. Broad reading without evidence is forbidden.");
sb.AppendLine(" 5. If no files match the topic, proceed WITHOUT reading any workspace files.");
sb.AppendLine("VIOLATION: Reading all files in the folder is FORBIDDEN. It wastes tokens and degrades quality.");
break;
case "passive":
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
"Only read folder documents when the user explicitly asks you to reference or use them.");
break;
default: // "none"
sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path.");
break;
}
// 프리셋 시스템 프롬프트가 있으면 추가
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand))
sb.AppendLine("\n" + _currentConversation.SystemCommand);
}
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
private string BuildCodeSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var code = llm.Code;
var sb = new System.Text.StringBuilder();
// ══════════════════════════════════════════════════════════════════
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
// ══════════════════════════════════════════════════════════════════
sb.AppendLine("## [절대 규칙] 도구 우선 — USE TOOLS WHEN THEY MATERIALLY ADVANCE THE TASK");
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
sb.AppendLine("Treat these as operating guidance, not as a requirement to force unnecessary tool calls.");
sb.AppendLine("");
sb.AppendLine("### [규칙 1] 필요한 도구를 먼저 사용하라 — Tools First When Needed");
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
sb.AppendLine("Use tools whenever they materially advance the task. Do not answer with text only when code inspection, editing, diff review, or verification is still needed.");
sb.AppendLine("A text-only response is fine once the requested code work is complete or enough evidence has been gathered.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete.");
sb.AppendLine("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — folder_map을 즉시 호출하세요, 예고하지 마세요");
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'");
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 한 문장 설명은 도구 결과 이후에만 허용됩니다.");
sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, never precede it.");
sb.AppendLine("");
sb.AppendLine("### [규칙 3] 모든 독립 작업은 병렬로 호출하라 — Parallelise Everything Possible");
sb.AppendLine("독립적인 읽기/검색 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 절대 순차적으로 하지 마세요.");
sb.AppendLine("Batch independent reads/searches only when it genuinely saves time and keeps the scope narrow.");
sb.AppendLine(" 나쁜 예(BAD): R1: folder_map → R2: grep → R3: file_read → R4: file_edit (순차 — 금지)");
sb.AppendLine(" 좋은 예(GOOD): R1: folder_map + grep + file_read 동시 호출 → R2: file_edit + build_run");
sb.AppendLine("모든 독립적인 읽기/검색 작업은 같은 응답에 배치해야 합니다.");
sb.AppendLine("Prefer targeted grep/glob plus a small number of file reads over broad parallel sweeps.");
sb.AppendLine("");
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty, Do Not Ask");
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때: 즉시 glob이나 grep을 호출하세요.");
sb.AppendLine("의존성이 불확실할 때: 즉시 dev_env_detect를 호출하세요.");
sb.AppendLine("If unsure which file, use glob or grep to narrow the target. If unsure about dependencies or tooling, use dev_env_detect.");
sb.AppendLine("'잘 모르겠습니다, 알려주시겠어요?' — 도구 호출로 답을 구할 수 있다면 절대 사용자에게 묻지 마세요.");
sb.AppendLine("Prefer answering uncertainty with a focused tool call instead of asking avoidable clarification questions.");
sb.AppendLine("");
sb.AppendLine("### [규칙 5] 도구로 검증하라, 가정하지 마라 — Verify with Tools, Not Assumptions");
sb.AppendLine("편집 후에는 반드시 file_read로 최종 상태를 확인하세요. 편집이 성공했다고 가정하지 마세요.");
sb.AppendLine("빌드/테스트 후에는 build_run의 실제 출력을 인용하세요. '정상 작동할 것입니다'라고 말하지 마세요.");
sb.AppendLine("After editing, re-open the changed file when confirmation is helpful. Do not assume a risky edit succeeded.");
sb.AppendLine("When you run build/test, cite the actual build_run output instead of assuming success.");
sb.AppendLine("'완료'의 정의: 빌드 통과 + 테스트 통과 + 편집 파일 재확인 + 결과 보고 — 모두 도구 출력으로 증명된 상태.");
sb.AppendLine("'Done' = build passes + tests pass + edited files re-read + result reported — all proven by tool output.");
sb.AppendLine("");
// 소개 및 메타 정보
sb.AppendLine("---");
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), lsp_code_intel, folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("\n## Core Workflow");
sb.AppendLine("1. ORIENT: Pick the smallest next step that can answer the request.");
sb.AppendLine(" - If a specific file is already obvious, start with file_read directly.");
sb.AppendLine(" - If you need definitions, references, symbols, implementations, or call hierarchy, prefer lsp_code_intel before broad searching.");
sb.AppendLine(" - Otherwise use grep/glob to find the smallest relevant set of files.");
sb.AppendLine(" - Use folder_map only when the repository structure itself is unclear.");
sb.AppendLine("2. ANALYZE: Read only the files needed to understand the change.");
sb.AppendLine(" - Check impacted callers/references when changing shared code or public behavior. Use lsp_code_intel when semantic lookup will be more precise than text search.");
sb.AppendLine("3. IMPLEMENT: Apply the smallest safe edit. Use file_edit for existing files and file_write for new files.");
sb.AppendLine("4. VERIFY: Run build_run/test_loop when the change affects buildable or testable behavior, or when the user explicitly asks for verification.");
sb.AppendLine(" - Use git_tool(diff) when it helps confirm the final change set or explain what changed.");
sb.AppendLine("5. REPORT: Summarize what changed and what was verified. Mention remaining risk only when something is actually unresolved.");
sb.AppendLine("\n## Development Environment");
sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands.");
sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed.");
// 패키지 저장소 정보
sb.AppendLine("\n## Package Repositories");
if (!string.IsNullOrEmpty(code.NexusBaseUrl))
sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}");
sb.AppendLine($"NuGet (.NET): {code.NugetSource}");
sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}");
sb.AppendLine($"Maven (Java): {code.MavenSource}");
sb.AppendLine($"npm (JavaScript): {code.NpmSource}");
sb.AppendLine("When adding dependencies, use these repository URLs.");
// IDE 정보
if (!string.IsNullOrEmpty(code.PreferredIdePath))
sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}");
// 사용자 선택 개발 언어
if (_selectedLanguage != "auto")
{
var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage };
sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation.");
}
// 언어별 가이드라인
sb.AppendLine("\n## Language Guidelines");
sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions.");
sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred.");
sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide.");
sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines.");
sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API.");
// 코드 품질 + 안전 수칙
sb.AppendLine("\n## Code Quality & Safety");
sb.AppendLine("- NEVER delete or overwrite files without user confirmation.");
sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents.");
sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff).");
sb.AppendLine("- When porting/referencing external code, do not copy verbatim. Rename and re-structure to match AX Copilot conventions.");
sb.AppendLine("- Use grep to find ALL references before renaming/removing anything.");
sb.AppendLine("- After editing, re-open the changed files and nearby callers to verify the final state, not just the patch intent.");
sb.AppendLine("- Treat verification as incomplete unless you can cite build/test or direct file-read evidence.");
sb.AppendLine("- If unsure about a change's impact, ask the user first.");
sb.AppendLine("- For large refactors, do them incrementally with build verification between steps.");
sb.AppendLine("- Use git_tool action='diff' to review your changes before committing.");
sb.AppendLine("\n## Lint & Format");
sb.AppendLine("After code changes, check for available linters:");
sb.AppendLine("- Python: ruff, black, flake8, pylint");
sb.AppendLine("- JavaScript: eslint, prettier");
sb.AppendLine("- C#: dotnet format");
sb.AppendLine("- C++: clang-format");
sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"\nCurrent work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
sb.Append(BuildSubAgentDelegationSection(true));
// 폴더 데이터 활용
sb.AppendLine("\nFolder Data Usage = ACTIVE.");
sb.AppendLine("Prefer targeted file_read, grep/glob, and lsp_code_intel for narrow requests.");
sb.AppendLine("Use folder_map only when structure is unclear, the user explicitly asks for folder contents, or the request is repo-wide.");
sb.AppendLine("Read only files that are relevant to the current question. Avoid broad codebase sweeps without evidence.");
// 프리셋 시스템 프롬프트
lock (_convLock)
{
if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd)
sb.AppendLine("\n" + sysCmd);
}
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
private static string BuildSubAgentDelegationSection(bool codeMode)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n## Sub-Agent Delegation");
sb.AppendLine("Use spawn_agent only when a bounded side investigation can run in parallel without blocking your own next step.");
sb.AppendLine("Good delegation targets: impact analysis, reference/caller search, test-file discovery, diff review, and bug root-cause investigation.");
sb.AppendLine("Do not delegate the final editing decision, the final report, or blocking work that you must inspect immediately yourself.");
sb.AppendLine("When spawning a sub-agent, give a concrete task with the exact question, likely file/module scope, and the output shape you want.");
sb.AppendLine("Expected sub-agent result shape: conclusion, files checked, key evidence, recommended next action, risks/unknowns.");
sb.AppendLine("Use wait_agents only when you are ready to integrate the result. Keep doing useful local work while the sub-agent runs.");
if (codeMode)
{
sb.AppendLine("For code tasks, prefer delegating read-only investigations such as caller mapping, related test discovery, or build/test failure triage.");
sb.AppendLine("For high-impact code changes, consider delegation when it quickly adds evidence, but do not treat it as mandatory.");
}
else
{
sb.AppendLine("For cowork tasks, consider delegation only when fact gathering or source cross-checking clearly speeds up the work.");
}
return sb.ToString();
}
/// <summary>프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildProjectRulesSection(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
if (!_settings.Settings.Llm.EnableProjectRules) return "";
try
{
var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder);
if (rules.Count == 0) return "";
// 컨텍스트별 필터링: Cowork=document, Code=always (기본)
var when = _activeTab == "Code" ? "always" : "always";
var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when);
return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered);
}
catch
{
return "";
}
}
/// <summary>에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildMemorySection(string? workFolder)
{
if (!_settings.Settings.Llm.EnableAgentMemory) return "";
var app = System.Windows.Application.Current as App;
var memService = app?.MemoryService;
if (memService == null) return "";
// 메모리를 로드 (작업 폴더 변경 시 재로드)
memService.Load(workFolder ?? "");
var all = memService.All;
var layeredDocs = memService.InstructionDocuments;
if (all.Count == 0 && layeredDocs.Count == 0) return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n## 메모리 계층");
sb.AppendLine("다음 메모리는 claude-code와 비슷하게 관리형 → 사용자 → 프로젝트 → 로컬 순서로 조립됩니다.");
sb.AppendLine("현재 작업 디렉토리에 가까운 메모리가 더 높은 우선순위를 가집니다.\n");
const int maxLayeredDocs = 8;
const int maxDocChars = 1800;
foreach (var doc in layeredDocs.Take(maxLayeredDocs))
{
sb.AppendLine($"[{doc.Label}] {doc.Path}");
var text = doc.Content;
if (text.Length > maxDocChars)
text = text[..maxDocChars] + "\n...(생략)";
sb.AppendLine(text);
sb.AppendLine();
}
if (all.Count > 0)
{
sb.AppendLine("## 학습 메모리 (이전 대화에서 학습한 내용)");
sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요.");
sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요.");
sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n");
foreach (var group in all.GroupBy(e => e.Type))
{
var label = group.Key switch
{
"rule" => "프로젝트 규칙",
"preference" => "사용자 선호",
"fact" => "프로젝트 사실",
"correction" => "이전 교정",
_ => group.Key,
};
sb.AppendLine($"[{label}]");
foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15))
sb.AppendLine($"- {e.Content}");
sb.AppendLine();
}
}
return sb.ToString();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -859,11 +859,30 @@ internal sealed class PlanViewerWindow : Window
_btnPanel.Children.Clear(); _btnPanel.Children.Clear();
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentColor = accentBrush is SolidColorBrush accentSolid ? accentSolid.Color : Color.FromRgb(0x4B, 0x5E, 0xFC);
var approveLabel = _uiExpressionLevel == "simple" ? "승인" : "승인 후 실행"; var approveLabel = _uiExpressionLevel == "simple" ? "승인" : "승인 후 실행";
var editLabel = _uiExpressionLevel == "simple" ? "수정" : "수정 피드백"; var editLabel = _uiExpressionLevel == "simple" ? "수정" : "수정 피드백";
var rejectLabel = _uiExpressionLevel == "simple" ? "취소" : "거부"; var rejectLabel = _uiExpressionLevel == "simple" ? "취소" : "거부";
_btnPanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x16, accentColor.R, accentColor.G, accentColor.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(0, 0, 12, 0),
Child = new TextBlock
{
Text = "검토가 끝나면 바로 실행하거나 방향만 짧게 남길 수 있습니다.",
FontSize = 11.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
}
});
var approveBtn = CreateActionButton("\uE73E", approveLabel, accentBrush, Brushes.White, true); var approveBtn = CreateActionButton("\uE73E", approveLabel, accentBrush, Brushes.White, true);
approveBtn.MouseLeftButtonUp += (_, _) => approveBtn.MouseLeftButtonUp += (_, _) =>
{ {
@@ -904,20 +923,31 @@ internal sealed class PlanViewerWindow : Window
private void ShowEditInput() private void ShowEditInput()
{ {
var itemBackground = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var launcherBackground = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var editPanel = new Border var editPanel = new Border
{ {
Margin = new Thickness(20, 0, 20, 12), Margin = new Thickness(20, 0, 20, 12),
Padding = new Thickness(12, 8, 12, 8), Padding = new Thickness(14, 12, 14, 12),
CornerRadius = new CornerRadius(10), CornerRadius = new CornerRadius(12),
Background = Application.Current.TryFindResource("ItemBackground") as Brush Background = itemBackground,
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)), BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
}; };
var editStack = new StackPanel(); var editStack = new StackPanel();
editStack.Children.Add(new TextBlock editStack.Children.Add(new TextBlock
{ {
Text = "수정 사항을 입력하세요:", Text = "어떤 방향으로 바꾸면 좋을지 짧게 남겨주세요.",
FontSize = 11.5, FontSize = 11.5,
Foreground = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6), Margin = new Thickness(0, 0, 0, 6),
}); });
var textBox = new TextBox var textBox = new TextBox
@@ -927,29 +957,32 @@ internal sealed class PlanViewerWindow : Window
AcceptsReturn = true, AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap, TextWrapping = TextWrapping.Wrap,
FontSize = 13, FontSize = 13,
Background = Application.Current.TryFindResource("LauncherBackground") as Brush Background = launcherBackground,
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)), Foreground = primaryText,
Foreground = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, CaretBrush = primaryText,
CaretBrush = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, BorderBrush = borderBrush,
BorderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
Padding = new Thickness(10, 8, 10, 8), Padding = new Thickness(10, 8, 10, 8),
}; };
editStack.Children.Add(textBox); editStack.Children.Add(textBox);
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush var actionRow = new StackPanel
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); {
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 8, 0, 0),
};
var sendBtn = new Border var sendBtn = new Border
{ {
Background = accentBrush, Background = accentBrush,
CornerRadius = new CornerRadius(8), CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 6, 14, 6), Padding = new Thickness(14, 6, 14, 6),
Margin = new Thickness(0, 8, 0, 0), Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand, Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
Child = new TextBlock Child = new TextBlock
{ {
Text = "전송", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, Text = "수정 요청 보내기", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White,
}, },
}; };
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
@@ -960,7 +993,36 @@ internal sealed class PlanViewerWindow : Window
if (string.IsNullOrEmpty(feedback)) return; if (string.IsNullOrEmpty(feedback)) return;
_tcs?.TrySetResult(feedback); _tcs?.TrySetResult(feedback);
}; };
editStack.Children.Add(sendBtn); actionRow.Children.Add(sendBtn);
var closeBtn = new Border
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 6, 14, 6),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = "닫기",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
},
};
closeBtn.MouseEnter += (s, _) => ((Border)s).Background = itemBackground;
closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
closeBtn.MouseLeftButtonUp += (_, _) =>
{
if (editPanel.Parent is Grid grid)
{
grid.Children.Remove(editPanel);
_btnPanel.Margin = new Thickness(20, 12, 20, 16);
}
};
actionRow.Children.Add(closeBtn);
editStack.Children.Add(actionRow);
editPanel.Child = editStack; editPanel.Child = editStack;
if (_btnPanel.Parent is Grid parentGrid) if (_btnPanel.Parent is Grid parentGrid)
@@ -1008,10 +1070,12 @@ internal sealed class PlanViewerWindow : Window
Brush textColor, bool filled) Brush textColor, bool filled)
{ {
var color = ((SolidColorBrush)borderColor).Color; var color = ((SolidColorBrush)borderColor).Color;
var hoverBg = Application.Current?.TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var btn = new Border var btn = new Border
{ {
CornerRadius = new CornerRadius(12), CornerRadius = new CornerRadius(14),
Padding = new Thickness(16, 8, 16, 8), Padding = new Thickness(16, 9, 16, 9),
Margin = new Thickness(4, 0, 4, 0), Margin = new Thickness(4, 0, 4, 0),
Cursor = Cursors.Hand, Cursor = Cursors.Hand,
Background = filled ? borderColor Background = filled ? borderColor
@@ -1033,8 +1097,20 @@ internal sealed class PlanViewerWindow : Window
Foreground = filled ? Brushes.White : textColor, Foreground = filled ? Brushes.White : textColor,
}); });
btn.Child = sp; btn.Child = sp;
btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; btn.MouseEnter += (s, _) =>
btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; {
var border = (Border)s;
border.Opacity = 0.96;
if (!filled)
border.Background = hoverBg;
};
btn.MouseLeave += (s, _) =>
{
var border = (Border)s;
border.Opacity = 1.0;
if (!filled)
border.Background = new SolidColorBrush(Color.FromArgb(0x18, color.R, color.G, color.B));
};
return btn; return btn;
} }