diff --git a/.copilot/mcp-config.json b/.copilot/mcp-config.json deleted file mode 100644 index dbf7ac8..0000000 --- a/.copilot/mcp-config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "mcpServers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": { - "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" - }, - "tools": ["*"] - }, - "azure": { - "type": "local", - "command": "npx", - "args": ["-y", "@azure/mcp@latest", "server", "start"], - "tools": ["*"] - }, - "microsoftLearn": { - "type": "http", - "url": "https://learn.microsoft.com/api/mcp", - "tools": ["*"] - } - } -} diff --git a/.env.example b/.env.example index acbdeb6..5db6381 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,3 @@ SEARCH_INDEX_NAME_IQ=maf-lab-knowledge-iq-v1 # (선택) 질의 계획 추론 강도 (minimal/low/medium, 기본값: minimal) # FOUNDRY_IQ_REASONING_EFFORT=minimal - -# ===== Copilot CLI MCP 연동 — GitHub MCP 서버 인증 ===== -# .copilot/mcp-config.json의 github 서버 블록에서 Bearer 토큰으로 사용됩니다. -# GitHub Personal Access Token (repo, issues, pull_requests 스코프) -# 발급: https://github.com/settings/tokens -# 필요 스코프: repo, read:org, read:discussion (최소) -# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx diff --git a/.github/agents/debugger.agent.md b/.github/agents/debugger.agent.md index 3b213ca..dd08146 100644 --- a/.github/agents/debugger.agent.md +++ b/.github/agents/debugger.agent.md @@ -18,7 +18,7 @@ tools: ["read", "search", "execute"] 문제가 보고되면 다음 순서로 점검한다: ### 1단계: 환경 기본 점검 -- Python 버전 (`python --version` → 3.14.5 권장/검증) +- Python 버전 (`python --version` → 3.14.x 권장/검증, MAF 최소 3.10) - 가상환경 활성화 여부 (`which python` → `.venv/` 경로인지) - 의존성 설치 (`pip list` → `agent-framework`, `azure-identity`, `python-dotenv` 등 존재 여부) @@ -48,7 +48,7 @@ tools: ["read", "search", "execute"] | 항목 | 상태 | 설명 | |------|------|------| -| Python 버전 | ✅ | 3.14.5 | +| Python 버전 | ✅ | 3.14.x | | Azure 로그인 | ❌ | 만료됨 → `az login` 실행 필요 | | ... | ... | ... | diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md index b84739b..be64853 100644 --- a/.github/agents/reviewer.agent.md +++ b/.github/agents/reviewer.agent.md @@ -21,7 +21,8 @@ tools: ["read", "search"] - **패턴 준수**: `FoundryChatClient` + `Agent` + 빌더 패턴, 비동기 구조가 인스트럭션과 일치하는가 - **보안**: 환경변수 하드코딩, 입력값 미검증, 민감정보 노출이 없는가 - **에러 처리**: 필수 환경변수 검증, Azure API 호출 실패에 대한 처리가 있는가 - - **워크플로우 정합성**: GroupChat `max_rounds` 설정 여부, Sequential/Concurrent 참여자 목록 완결성 + - **워크플로우 정합성**: GroupChat `max_rounds` 설정 여부, 참여자 목록 완결성, + `intermediate_output_from`으로 실습에 필요한 중간 결과가 노출되는지 - **한국어 품질**: docstring, 주석, 사용자 메시지가 자연스러운가 ## 출력 규칙 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8c7c47c..5750efe 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,6 +30,8 @@ - 클라이언트·에이전트는 키워드 인자로 생성: `FoundryChatClient(project_endpoint=..., model=..., credential=...)`, `Agent(client=..., name=..., instructions=...)` (역할은 `instructions`로 부여) - 오케스트레이션: `SequentialBuilder(participants=[...])`, `GroupChatBuilder(participants=..., selection_func=..., max_rounds=...)`, `ConcurrentBuilder(participants=[...])` 뒤에 `.build()` +- 참여자별 진행 결과를 콘솔에 표시하는 예제는 빌더의 `intermediate_output_from`을 명시하고 + `stream_workflow()`로 `output`·`intermediate` 이벤트를 함께 출력 - 진입점은 `if __name__ == "__main__": asyncio.run(main())`, 환경변수는 `load_dotenv`로 로드하고 `PROJECT_ENDPOINT` 누락 시 친절한 오류 후 종료 - 새 예제는 `src/`에 `NN_.py` 규칙으로 추가 diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md index 02ede37..5395b02 100644 --- a/.github/instructions/python.instructions.md +++ b/.github/instructions/python.instructions.md @@ -6,7 +6,7 @@ applyTo: "**/*.py" ## 환경 / 실행 -- **Python 3.14.5** 사용(실습 표준; MAF 공식 최소 요구는 3.13). +- **Python 3.14.x** 사용(실습 검증 버전; MAF 공식 최소 요구는 3.10). - 프로젝트 루트에서 가상환경을 만들고 의존성을 설치한다: ```bash python3 -m venv .venv @@ -19,7 +19,8 @@ applyTo: "**/*.py" - 의존성은 루트 `requirements.txt`에 명시하고, 패키지를 추가하면 갱신한다. - 메타 패키지 `agent-framework`(foundry·orchestrations 포함)를 사용한다. -- SDK·핵심 패키지는 `>=x`로 최소 버전을 명시해 재현성을 유지한다(프리릴리스 허용). +- 검증된 SDK 조합은 `==x.y.z`로 고정한다. 프리릴리스 연동 패키지도 정확한 빌드를 고정하고, + 관련 핵심 패키지는 호환 버전으로 함께 갱신한다. ## 코드 스타일 diff --git a/.github/mcp.json b/.github/mcp.json new file mode 100644 index 0000000..59a15b5 --- /dev/null +++ b/.github/mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "azure-lab": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@azure/mcp@latest", "server", "start"], + "tools": ["*"] + }, + "microsoft-learn-lab": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp", + "tools": ["*"] + } + } +} diff --git a/.github/prompts/add-agent.prompt.md b/.github/prompts/add-agent.prompt.md index 1f575bf..023d71b 100644 --- a/.github/prompts/add-agent.prompt.md +++ b/.github/prompts/add-agent.prompt.md @@ -17,9 +17,9 @@ mode: "agent" ## 현재 시나리오 구조 (`src/`) 1. **단일 에이전트** (`01_single_agent.py`) — `Agent(client=client, name=..., instructions=...)` + `stream_agent(agent, prompt)` -2. **순차(Sequential)** (`02_sequential_workflow.py`) — `SequentialBuilder(participants=[...])` + `stream_workflow(workflow, topic)` -3. **GroupChat** (`03_group_chat.py`) — `GroupChatBuilder(participants=..., selection_func=..., max_rounds=...)` + `stream_workflow(workflow, topic)` -4. **동시(Concurrent)** (`04_concurrent_workflow.py`) — `ConcurrentBuilder(participants=[...])` + `stream_workflow(workflow, design)` +2. **순차(Sequential)** (`02_sequential_workflow.py`) — `SequentialBuilder(..., intermediate_output_from=[중간 참여자...])` + `stream_workflow(workflow, topic)` +3. **GroupChat** (`03_group_chat.py`) — `GroupChatBuilder(..., max_rounds=..., intermediate_output_from=participants)` + `stream_workflow(workflow, topic)` +4. **동시(Concurrent)** (`04_concurrent_workflow.py`) — `ConcurrentBuilder(..., intermediate_output_from=participants)` + `stream_workflow(workflow, design)` 5. **MCP 도구 연동** (`05_mcp_agent.py`) — `MCPStreamableHTTPTool(url=...)` + `Agent(tools=mcp_tool)` + `stream_agent(agent, prompt)` 6. **RAG** (`06_rag_agent.py`) — 검색(Search) → 증강(Augment) → 단일 에이전트 실행 + `stream_agent(agent, augmented_prompt)` 7. **RAG (Foundry IQ)** (`06_rag_agent_foundry_iq.py`) — Foundry IQ 검색 결과를 바탕으로 단일 에이전트를 실행 + `stream_agent(agent, question)` @@ -31,4 +31,5 @@ mode: "agent" - **스트리밍 출력**: 단일 에이전트는 `from _streaming import stream_agent`, 워크플로우는 `from _streaming import stream_workflow` (`agent.run()` 직접 print 금지) - `instructions`와 콘솔 출력은 한국어로 작성 -- 무한 루프 방지를 위해 워크플로우에는 `max_rounds`/수렴 조건을 둔다 +- 반복형 워크플로우(GroupChat·Handoff)는 `max_rounds`·종료 조건·turn limit 중 적절한 제한을 둔다 +- 실습에서 참여자별 결과를 보여줘야 하면 빌더의 `intermediate_output_from`을 명시한다 diff --git a/.github/prompts/review-code.prompt.md b/.github/prompts/review-code.prompt.md index ad84b20..fd8c079 100644 --- a/.github/prompts/review-code.prompt.md +++ b/.github/prompts/review-code.prompt.md @@ -17,7 +17,9 @@ mode: "agent" 2. **보안** — 환경변수 노출, 입력값 검증 누락, 민감정보 하드코딩은 없는가? 3. **패턴 준수** — `FoundryChatClient` + `Agent` + 빌더(`SequentialBuilder`/`GroupChatBuilder`/`ConcurrentBuilder`/`HandoffBuilder`) 패턴을 따르는가? 4. **비동기** — 모든 에이전트 호출이 `async/await`이고 진입점이 `asyncio.run(main())`인가? -5. **워크플로우 정합성** — Handoff의 `add_handoff` 대상이 명시되었는가? GroupChat에 `max_rounds`가 있는가? +5. **워크플로우 정합성** — Handoff 라우팅이 의도한 토폴로지인가? 제한된 경로가 필요하면 + `add_handoff`를 사용했는가? GroupChat에 `max_rounds` 또는 종료 조건이 있는가? + 참여자별 출력을 보여주는 예제에 `intermediate_output_from`이 있는가? 6. **한국어 품질** — docstring, 주석, 사용자 메시지, 에이전트 instructions가 자연스러운가? ## 출력 형식 diff --git a/.github/skills/agent-framework-codegen/SKILL.md b/.github/skills/agent-framework-codegen/SKILL.md index 4e2f8a4..817c397 100644 --- a/.github/skills/agent-framework-codegen/SKILL.md +++ b/.github/skills/agent-framework-codegen/SKILL.md @@ -5,216 +5,246 @@ description: "Microsoft Agent Framework SDK를 사용한 AI 에이전트·워크 # Microsoft Agent Framework 코드 생성 스킬 -이 프로젝트에서 Microsoft Agent Framework SDK로 에이전트·워크플로우를 작성할 때 따라야 하는 -패턴과 레퍼런스입니다. 모든 예제는 `src/`의 콘솔 스크립트 형태입니다. +이 프로젝트에서 Microsoft Agent Framework SDK로 에이전트·워크플로우를 작성할 때 따르는 +패턴과 레퍼런스입니다. 검증 기준은 `agent-framework==1.11.0`이며, 모든 예제는 `src/`의 +비동기 콘솔 스크립트 형태입니다. --- -## 1. SDK Import 경로 +## 1. SDK 임포트 경로 ```python -from agent_framework import Agent, MCPStreamableHTTPTool # MCP 도구 연동(8절) -from agent_framework import WorkflowBuilder, Case, Default # 조건부 라우팅 그래프(7절) +from agent_framework import ( + Agent, + AgentExecutorResponse, + Case, + Default, + MCPStreamableHTTPTool, + WorkflowBuilder, +) from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ( - SequentialBuilder, # 순차(Sequential) 워크플로우 - GroupChatBuilder, # GroupChat 워크플로우 - GroupChatState, # GroupChat 발화자 선택 상태 - ConcurrentBuilder, # 동시(Concurrent) 워크플로우 - HandoffBuilder, # Handoff 워크플로우 + ConcurrentBuilder, + GroupChatBuilder, + GroupChatState, + HandoffBuilder, + SequentialBuilder, ) from azure.identity import AzureCliCredential ``` -> **주의**: 핵심 클래스(`Agent`), Foundry 연동(`agent_framework.foundry`), 오케스트레이션 -> (`agent_framework.orchestrations`)은 서로 다른 서브모듈이다. 경로를 혼동하지 않는다. -> `WorkflowBuilder`·`Case`·`Default`는 `agent_framework` 최상위에서 임포트한다. +- 핵심 클래스와 그래프 빌더는 `agent_framework` 최상위에서 임포트한다. +- Foundry 클라이언트는 `agent_framework.foundry`에서 임포트한다. +- 미리 정의된 오케스트레이션 빌더는 `agent_framework.orchestrations`에서 임포트한다. +- Foundry IQ 컨텍스트 프로바이더는 별도 패키지를 설치한 뒤 + `from agent_framework.azure import AzureAISearchContextProvider`로 임포트한다. --- ## 2. 공통 골격 -모든 예제는 다음 골격을 따른다: - ```python import asyncio import os import sys -from dotenv import load_dotenv - -load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env")) from agent_framework import Agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env")) async def main(): project_endpoint = os.getenv("PROJECT_ENDPOINT") - model = os.getenv("MODEL_DEPLOYMENT_NAME", "gpt-5.4") + model = os.getenv("MODEL_DEPLOYMENT_NAME") or "gpt-5.4" if not project_endpoint: print("오류: PROJECT_ENDPOINT 환경 변수를 설정해주세요.") sys.exit(1) + credential = AzureCliCredential() client = FoundryChatClient( project_endpoint=project_endpoint, model=model, - credential=AzureCliCredential(), + credential=credential, ) - # ... 에이전트/워크플로우 구성 ... + # 에이전트 또는 워크플로우 구성 if __name__ == "__main__": asyncio.run(main()) ``` -- 클라이언트는 한 번만 생성하여 모든 에이전트에 공유한다. -- 모든 에이전트 호출은 `await`로 한다. +- 클라이언트와 자격 증명은 한 번 생성해 참여 에이전트가 공유한다. +- 모든 에이전트 호출은 `await` 또는 `async for`를 사용한다. +- 단일 에이전트는 `_streaming.stream_agent()`, 워크플로우는 + `_streaming.stream_workflow()`로 출력한다. --- -## 3. 에이전트 생성 (Single Agent) +## 3. 단일 에이전트 ```python +from _streaming import stream_agent + agent = Agent( client=client, name="기술_어시스턴트", - instructions="당신은 ... 한국어로 답변합니다.", # 역할 지시문 (한국어) + instructions="당신은 Microsoft 기술 전문가입니다. 한국어로 간결하게 답변합니다.", ) -# 방법 A: 이 repo의 표준 패턴 — 스트리밍 헬퍼 사용 (응답이 토큰 단위로 실시간 출력) -from _streaming import stream_agent -await stream_agent(agent, "질문 내용", label="에이전트 응답") - -# 방법 B: 단순 API 예시 — 완성된 응답을 한 번에 받음 -result = await agent.run("질문 내용") -print(result) +await stream_agent(agent, "Microsoft Agent Framework가 무엇인가요?") ``` - 역할·도메인·말투는 `instructions`로 부여한다. -- **이 프로젝트 표준**: `src/_streaming.py`의 `stream_agent()` 헬퍼를 사용한다 - (비스트리밍 `print(result)` 직접 출력은 교육 예시용으로만 허용). -- 단일 에이전트의 `name`은 한국어도 가능하다. **단, Handoff에서는 `name`이 `handoff_to_` - 도구명이 되므로 ASCII(영문/숫자/`_`)만 사용**한다 (Foundry/OpenAI 도구명 규칙 `^[a-zA-Z0-9_.-]+$`). +- Handoff 이외의 단일 에이전트 이름은 한국어도 가능하다. +- 비스트리밍이 필요한 경우에만 `result = await agent.run(...)`을 사용한다. --- ## 4. Handoff 워크플로우 -접수(Coordinator) 에이전트가 요청을 분석해 전문가 에이전트에게 위임한다. +Handoff에서는 에이전트 이름이 `handoff_to_` 도구명에 포함되므로 +ASCII 영문·숫자·`_`·`.`·`-`만 사용한다. ```python -from agent_framework.orchestrations import HandoffBuilder - -# 전문가 + 접수 에이전트 생성 (Handoff는 모든 참여 Agent에 이 플래그가 필수) -# 주의: name은 handoff_to_ 도구명이 되므로 ASCII만 사용(페르소나는 instructions로 한국어 부여) -tech_agent = Agent(client=client, name="tech_support", instructions="당신은 기술 지원 전문가입니다. ...", - require_per_service_call_history_persistence=True) -billing_agent = Agent(client=client, name="billing", instructions="당신은 결제 지원 전문가입니다. ...", - require_per_service_call_history_persistence=True) -triage_agent = Agent(client=client, name="triage", instructions=( - "당신은 접수 담당자입니다. 요청을 분석하여 적절한 전문가에게 연결합니다.\n" - "- 기술 문제 → handoff_to_tech_support 도구 호출\n" - "- 결제 문제 → handoff_to_billing 도구 호출" -), require_per_service_call_history_persistence=True) +tech_agent = Agent( + client=client, + name="tech_support", + instructions="당신은 기술 지원 전문가입니다. 한국어로 답변합니다.", + require_per_service_call_history_persistence=True, +) +billing_agent = Agent( + client=client, + name="billing", + instructions="당신은 결제 지원 전문가입니다. 한국어로 답변합니다.", + require_per_service_call_history_persistence=True, +) +triage_agent = Agent( + client=client, + name="triage", + instructions=( + "당신은 접수 담당자입니다. " + "기술 문제는 handoff_to_tech_support, 결제 문제는 handoff_to_billing 도구로 위임합니다." + ), + require_per_service_call_history_persistence=True, +) workflow = ( - HandoffBuilder(name="고객_지원", - participants=[triage_agent, tech_agent, billing_agent]) - .with_start_agent(triage_agent) # 시작 에이전트 - .add_handoff(triage_agent, [tech_agent, billing_agent]) # 위임 대상 명시 - .with_autonomous_mode() # 사용자 개입 없이 자동 진행 + HandoffBuilder( + name="고객_지원", + participants=[triage_agent, tech_agent, billing_agent], + ) + .with_start_agent(triage_agent) + .add_handoff(triage_agent, [tech_agent, billing_agent]) + .with_autonomous_mode() .build() ) -result = await workflow.run("결제 오류가 발생했어요.") -for output in result.get_outputs(): # 최종 응답만 추출 - print(output) -``` -| 메서드 | 용도 | -|--------|------| -| `HandoffBuilder(name=..., participants=...)` | 워크플로우 빌더 생성 (키워드 인자) | -| `.with_start_agent(agent)` | 시작(접수) 에이전트 지정 | -| `.add_handoff(from, [to...])` | 세부 라우팅 제어가 필요할 때 특정 위임 경로를 제한 | -| `.with_autonomous_mode()` | 사용자 입력 없이 자동 진행 | -| `.build()` | 워크플로우 객체 생성 | +await stream_workflow(workflow, "결제 오류가 발생했어요.") +``` -> **핵심**: 세부 라우팅 제어가 필요할 때 `add_handoff`를 사용한다. -> 생략 시 기본 mesh topology가 적용되어 모든 에이전트 간 handoff가 허용된다. -> 또한 **모든 참여 Agent**에 `require_per_service_call_history_persistence=True`를 지정해야 한다 -> (누락 시 `build()`가 `ValueError`를 발생시킨다). +- 모든 참여 `Agent`에 `require_per_service_call_history_persistence=True`가 필요하다. +- `add_handoff()`를 생략하면 기본 mesh topology가 적용된다. 특정 경로만 허용할 때 명시한다. +- 자동 진행이 필요하면 `with_autonomous_mode()`를 사용하고 필요 시 agent별 turn limit을 둔다. --- ## 5. GroupChat 워크플로우 -여러 에이전트가 한 대화에 참여해 협업한다. 발화자는 `selection_func`으로 결정한다. - ```python -from agent_framework.orchestrations import GroupChatBuilder, GroupChatState +participants = [planner_agent, developer_agent, designer_agent] +speaker_names = [participant.name for participant in participants] + def select_next_speaker(state: GroupChatState) -> str: - """라운드 로빈으로 다음 발화자 선택.""" - speakers = ["기획자", "개발자", "디자이너"] - return speakers[state.current_round % len(speakers)] + """라운드 로빈으로 다음 발화자를 선택합니다.""" + return speaker_names[state.current_round % len(speaker_names)] + workflow = GroupChatBuilder( - participants=[planner_agent, developer_agent, designer_agent], + participants=participants, selection_func=select_next_speaker, - max_rounds=6, # 무한 토론 방지 (권장) + max_rounds=6, + intermediate_output_from=participants, ).build() -result = await workflow.run("토론 주제") + +await stream_workflow(workflow, "토론 주제") ``` -- `GroupChatState`: `current_round`, `participants`, `conversation` 제공. -- `max_rounds` 사용을 권장한다(미설정 시 `termination_condition`으로 종료 제어 가능). -- 참여자 `name`은 도구명이 아니므로 한국어도 가능하다(Handoff와 다른 점). -- 최종 토론 내용은 `result.get_outputs()`(종료 메시지)가 아니라 이벤트의 `AgentExecutorResponse`에서 - 추출한다. `from agent_framework import AgentExecutorResponse` 후 `isinstance` 필터로 발언을 모은다. +- `GroupChatState`는 `current_round`, `participants`, `conversation`을 제공한다. +- 무한 토론을 막기 위해 `max_rounds` 또는 `termination_condition`을 둔다. +- 기본 설정에서는 오케스트레이터 종료 메시지만 최종 출력으로 노출된다. +- 참여자 발언을 표시하려면 `intermediate_output_from=participants`를 지정한다. +- 현재 SDK의 참여자 스트림은 `AgentResponseUpdate.author_name`과 `text`로 식별한다. --- -## 6. Custom 순차 워크플로우 (조건부 라우팅) +## 6. Sequential·Concurrent 출력 설정 -SDK 빌더 없이 **일반 Python 제어 흐름**으로 에이전트를 순차 연결한다. +기본 빌더는 최종 단계 또는 집계기만 `output` 이벤트로 노출한다. 교육용 예제에서 각 참여자의 +결과를 보이려면 중간 출력 소스를 명시한다. ```python -analysis = await agents["topic_analyzer"].run(input_topic) # 1) 분석 -route = "tech_writer" if "기술" in str(analysis).split("\n")[0] else "general_writer" # 2) 라우팅 -draft = await agents[route].run(f"...{analysis}...") # 3) 초안 -final = await agents["editor"].run(f"...{draft}...") # 4) 편집 -print(final) +sequential = SequentialBuilder( + participants=[analyzer, writer, editor], + intermediate_output_from=[analyzer, writer], +).build() + +concurrent = ConcurrentBuilder( + participants=[security, performance, ux], + intermediate_output_from=[security, performance, ux], +).build() ``` -- 라우팅 함수는 이전 에이전트의 출력 텍스트를 파싱해 다음 경로를 결정한다. -- 더 복잡한 조건 분기가 필요하면 아래 7절의 `WorkflowBuilder`로 전환한다. +- Sequential은 마지막 참여자가 최종 `output`이므로 앞 단계만 중간 출력으로 지정한다. +- Concurrent의 기본 집계기는 모든 응답을 하나의 `AgentResponse`로 모은다. + 참여자 스트림을 함께 노출하면 출력 헬퍼에서 집계 중복을 제거해야 한다. --- -## 7. WorkflowBuilder — 조건부 라우팅 그래프 +## 7. Python 제어 흐름 기반 순차 처리 -`SequentialBuilder`·`ConcurrentBuilder`처럼 선언적이지만, **조건부 분기(switch-case)** 와 -**팬아웃/팬인**이 필요한 복잡한 흐름에 사용한다. `Agent`를 직접 노드로 쓸 수 있다. +간단한 조건 분기는 일반 Python 흐름으로 연결해도 된다. ```python -from agent_framework import WorkflowBuilder, Case, Default +analysis = await agents["topic_analyzer"].run(input_topic) +route = "tech_writer" if "기술" in str(analysis).split("\n")[0] else "general_writer" +draft = await agents[route].run(f"다음 분석을 바탕으로 초안을 작성하세요.\n{analysis}") +final = await agents["editor"].run(f"다음 초안을 다듬으세요.\n{draft}") +print(final) +``` + +복잡한 조건 분기·팬아웃·팬인은 `WorkflowBuilder`를 사용한다. + +--- + +## 8. WorkflowBuilder 조건부 그래프 + +```python +def is_technical_topic(message: AgentExecutorResponse) -> bool: + """분석 에이전트의 응답 본문에서 기술 주제 여부를 판별합니다.""" + return "기술" in (message.agent_response.text or "") + -# 에이전트를 노드로 직접 전달 (자동 래핑) workflow = ( - WorkflowBuilder(start_executor=analyzer_agent) # 시작 노드 + WorkflowBuilder( + start_executor=analyzer_agent, + output_from=[editor_agent], + ) .add_switch_case_edge_group( analyzer_agent, [ - # 분석 결과에 "기술" 포함 → 기술 작가로 라우팅 - Case(condition=lambda msg: "기술" in str(msg), target=tech_writer_agent), - # 그 외 → 일반 작가 (Default는 조건 없이 나머지 처리) + Case(condition=is_technical_topic, target=tech_writer_agent), Default(target=general_writer_agent), ], ) - .add_edge(tech_writer_agent, editor_agent) # 기술 작가 → 편집자 - .add_edge(general_writer_agent, editor_agent) # 일반 작가 → 편집자 + .add_edge(tech_writer_agent, editor_agent) + .add_edge(general_writer_agent, editor_agent) .build() ) + result = await workflow.run("Kubernetes 비용 최적화 전략") for output in result.get_outputs(): print(output) @@ -222,91 +252,102 @@ for output in result.get_outputs(): | 메서드 | 용도 | |--------|------| -| `WorkflowBuilder(start_executor=...)` | 빌더 생성 (시작 노드 지정, 키워드 인자) | -| `.add_edge(source, target)` | 단순 순차 엣지 (조건 없이 항상 통과) | -| `.add_switch_case_edge_group(source, [Case..., Default])` | 조건부 분기 — 조건 순서대로 평가, 첫 일치 노드로 전달 | -| `.add_fan_out_edges(source, [target1, target2])` | 팬아웃 — 같은 메시지를 여러 노드에 병렬 전송 | -| `Case(condition=lambda msg: ..., target=agent)` | 조건 분기 케이스. `condition`은 `(msg) -> bool` | -| `Default(target=agent)` | 모든 `Case` 불일치 시 수신하는 기본 케이스 | -| `.build()` | `Workflow` 객체 생성 | - -> **선택 기준**: -> - **단순 순차(A→B→C)**: `SequentialBuilder` 사용 (더 간결) -> - **조건부 분기 / 팬아웃 / 복잡한 그래프**: `WorkflowBuilder` 사용 -> - **Python 제어 흐름으로 충분한 경우**: 6절의 `if/else` 패턴 사용 +| `WorkflowBuilder(start_executor=...)` | 시작 노드 지정 | +| `.add_edge(source, target)` | 단순 순차 엣지 | +| `.add_switch_case_edge_group(source, cases)` | 순서대로 평가하는 조건 분기 | +| `.add_fan_out_edges(source, targets)` | 같은 메시지를 여러 노드에 전송 | +| `.add_fan_in_edges(sources, target)` | 여러 결과를 한 노드로 수집 | +| `Case(condition=..., target=...)` | 조건이 참일 때의 대상. Agent 소스에서는 `AgentExecutorResponse`를 받음 | +| `Default(target=...)` | 모든 Case가 거짓일 때의 대상 | --- -## 8. MCP 도구 연동 (외부 시스템 호출) - -에이전트가 외부 MCP 서버의 도구를 런타임에 호출하게 한다. `tools=` 인자로 전달한다. +## 9. MCP 도구 연동 ```python -from agent_framework import Agent, MCPStreamableHTTPTool - -# HTTP(SSE) 원격 MCP 서버. 인증 필요 시 header_provider 또는 커스텀 http_client 사용 learn_mcp = MCPStreamableHTTPTool( name="MicrosoftLearn", url="https://learn.microsoft.com/api/mcp", description="Microsoft/Azure 공식 문서 검색", - header_provider=lambda: {"Authorization": f"Bearer {token}"}, ) -# async with 안에서만 세션 활성화 (진입=connect, 종료=close) async with learn_mcp: agent = Agent( client=client, name="문서_리서치_어시스턴트", - instructions="답변 전 도구로 검색해 출처와 함께 답한다.", + instructions="답변 전에 공식 문서를 검색하고 출처와 함께 한국어로 답변합니다.", tools=learn_mcp, ) - result = await agent.run("질문") + await stream_agent(agent, "질문") ``` -| 클래스 | 연결 방식 | -|--------|-----------| -| `MCPStreamableHTTPTool` | HTTP/SSE 원격 서버 | -| `MCPStdioTool` | 로컬 프로세스(stdio) 서버 | -| `MCPWebsocketTool` | WebSocket 서버 | +- MCP 세션은 반드시 `async with` 안에서 연결하고 종료한다. +- 여러 MCP 도구는 `tools=[tool_a, tool_b]`로 전달한다. +- 인증 서버의 `header_provider`는 호출 컨텍스트 인자 하나를 받는다. + +```python +import os + + +def build_headers(_: dict[str, object]) -> dict[str, str]: + token = os.environ["MCP_ACCESS_TOKEN"] + return {"Authorization": f"Bearer {token}"} + -- 반드시 `async with mcp_tool:` 컨텍스트 안에서 에이전트를 생성·실행한다. -- 여러 도구는 `tools=[tool_a, tool_b]` 리스트로 전달한다. -- **Copilot CLI의 `.copilot/mcp-config.json`(개발자용)과 혼동하지 않는다.** 이 절은 *생성된 - MAF 에이전트가 런타임에 쓰는 도구*다. +secured_mcp = MCPStreamableHTTPTool( + name="SecuredMCP", + url="https://example.com/mcp", + header_provider=build_headers, +) +``` + +- Copilot CLI 개발자용 MCP 설정은 `.mcp.json` 또는 `.github/mcp.json`이다. + 이 절의 MCP 도구는 생성된 Agent Framework 애플리케이션이 런타임에 사용하는 별도 연결이다. --- -## 9. RAG (검색 증강 생성) +## 10. RAG -질문 관련 문서를 먼저 검색해 컨텍스트로 주입한 뒤 답하게 한다: 검색 → 증강 → 생성. +질문 관련 문서를 검색하고 컨텍스트로 주입한 뒤 답변을 생성한다. ```python -docs = retrieve(question, top_k=2) # 1) 검색 (지식 베이스에서 추출) -context = build_context(docs) # 검색 결과를 문자열로 -augmented = ( # 2) 증강 (프롬프트에 주입) - f"다음 참고 문서를 바탕으로 답하세요.\n\n--- 참고 문서 ---\n{context}\n\n" +docs = retrieve(question, top_k=2) +context = build_context(docs) +augmented_prompt = ( + f"다음 참고 문서 안의 정보만 사용하세요.\n\n" + f"--- 참고 문서 ---\n{context}\n\n" f"--- 질문 ---\n{question}" ) -agent = Agent(client=client, name="RAG_어시스턴트", - instructions="제공된 문서 안의 정보만 근거로 답하고, 없으면 모른다고 한다.") -result = await agent.run(augmented) # 3) 생성 + +agent = Agent( + client=client, + name="RAG_어시스턴트", + instructions=( + "제공된 문서 안의 정보만 근거로 한국어로 답변하고, " + "정보가 없으면 모른다고 답합니다." + ), +) +await stream_agent(agent, augmented_prompt) ``` -- 정확도를 좌우하는 두 축: **(1) 검색 품질**, **(2) "문서 밖은 추측 금지" 지시문**. -- 실습(`src/06_rag_agent.py`)은 **Azure AI Search 하이브리드(BM25 + 벡터) 검색**을 사용한다. - 환경변수 `SEARCH_SERVICE_ENDPOINT`, `SEARCH_INDEX_NAME`(인덱스 없으면 자동 생성)가 필요하다. +- 기본 예제 `06_rag_agent.py`는 Azure AI Search 하이브리드 검색을 직접 구현한다. +- Foundry IQ 변형은 `AzureAISearchContextProvider(mode="agentic")`가 모델 호출 전에 + 멀티홉 검색 결과를 컨텍스트에 주입한다. +- 검색 품질과 "문서 밖 추측 금지" 지시문을 함께 검증한다. --- -## 10. 트러블슈팅 +## 11. 트러블슈팅 | 증상 | 원인 / 해결 | |------|-------------| -| `PROJECT_ENDPOINT 환경 변수를 설정해주세요` | 루트 `.env` 작성 + `load_dotenv` 경로 확인 | +| `PROJECT_ENDPOINT 환경 변수를 설정해주세요` | 루트 `.env` 작성과 `load_dotenv` 경로 확인 | | 인증 실패 | `az login` 재실행, `az account set`으로 구독 선택 | -| `400 Invalid 'tools[0].name'` (handoff) | Agent `name`에 한글/공백 사용 — handoff 도구명은 ASCII(`^[a-zA-Z0-9_.-]+$`)만 허용. name을 영문으로 변경 | -| Handoff `build()`가 `ValueError`(persistence) | 일부 Agent에 `require_per_service_call_history_persistence=True` 누락 — 모든 참여 Agent에 지정 | -| GroupChat이 끝나지 않음 | `max_rounds` 또는 `termination_condition` 미설정 | -| GroupChat 결과가 종료 메시지만 나옴 | `get_outputs()`는 종료 메시지만 반환 — 토론 내용은 이벤트의 `AgentExecutorResponse`에서 추출 | -| `WorkflowBuilder` `Case` 조건이 항상 첫 케이스로만 분기됨 | 조건은 **순서대로 평가**되며 첫 번째 `True`에서 멈춤 — 조건 순서를 좁은 것부터 배치할 것 | -| `ImportError: agent_framework...` | `pip install -U agent-framework`, 가상환경 활성화 확인 | +| Handoff 도구명 400 오류 | Handoff 참여자 `name`을 ASCII 규칙에 맞게 변경 | +| Handoff `build()` persistence 오류 | 모든 참여 Agent에 `require_per_service_call_history_persistence=True` 지정 | +| GroupChat이 끝나지 않음 | `max_rounds` 또는 `termination_condition` 설정 | +| GroupChat이 종료 메시지만 표시 | 참여자를 `intermediate_output_from`에 지정 | +| Concurrent 결과가 비어 있음 | `AgentResponse` 집계 이벤트를 처리하거나 참여자 중간 출력을 지정 | +| 조건 분기가 항상 첫 Case로 감 | Case는 순서대로 평가되므로 좁은 조건부터 배치 | +| pip 의존성 해석 실패 | `requirements.txt`의 검증된 정확 버전을 함께 설치 | +| `ImportError: agent_framework...` | 가상환경 활성화 후 `pip install -r requirements.txt` 재실행 | diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index dec0059..abe75be 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -1,7 +1,6 @@ name: smoke -# 예제 스크립트의 구문 오류를 빠르게 잡는 경량 스모크 테스트입니다. -# Azure 자격증명이 없어도 동작하도록 실제 실행 대신 바이트컴파일만 수행합니다. +# 의존성 해석, 모듈 임포트, 워크플로우 출력을 Azure 자격 증명 없이 검증합니다. on: push: branches: ["**"] @@ -17,7 +16,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.14.5" + python-version: "3.14" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: python -m pip install -r requirements.txt - name: Byte-compile all example scripts run: python -m compileall -q src + + - name: Run offline smoke tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 16d1d8f..2b02c07 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,15 @@ | **GitHub Copilot 구독** | 필수 | CLI 사용 권한 | | | **GitHub Copilot CLI** | 필수 | 터미널 AI 에이전트 | `npm install -g @github/copilot` | | **Node.js 22+** | 필수 | CLI 런타임 (+ `npx` MCP 서버) | `node --version` · | -| **이 저장소 클론** | 필수 | `.github/`·`.copilot/` 설정을 실습 대상으로 사용 | `git clone ` | -| **GitHub PAT** | 선택 | `github` MCP·이슈/PR 작업 시 (실습 4) | | +| **이 저장소 클론** | 필수 | `.github/` 설정을 실습 대상으로 사용 | `git clone ` | +| **Copilot용 PAT** | 선택 | 브라우저 로그인 대신 CLI 인증 | Fine-grained PAT + `Copilot Requests` 권한 | | **GitHub CLI(`gh`)** | 선택 | 커밋·PR 실습 시 (실습 7) | `gh auth login` · | -| **Azure CLI** | 선택 | `azure` MCP 서버 인증 시 (실습 4) | `az version` → `az login` | -| **Python 3.14.5** | 선택 | 생성 코드 문법 검증 (실습 5) | `python3 --version` | +| **Azure CLI** | 선택 | `azure-lab` MCP 서버 인증 시 (실습 4) | `az version` → `az login` | +| **Python 3.14.x** | 선택 | 생성 코드 문법 검증 (실습 5) | `python3 --version` | -> 💡 **선택 항목은 없어도 완주할 수 있습니다.** PAT·Azure·Python 없이도 인증이 필요 없는 -> `microsoftLearn` MCP 서버와 `/diff`·`reviewer` 검토만으로 모든 실습을 따라갈 수 있습니다. +> 💡 **선택 항목은 없어도 완주할 수 있습니다.** Azure·Python 없이도 인증이 필요 없는 +> Microsoft Learn MCP 서버와 `/diff`·`reviewer` 검토만으로 모든 실습을 따라갈 수 있습니다. +> Copilot CLI 자체 인증은 브라우저 OAuth 로그인을 권장합니다. ## 목차 @@ -123,7 +124,7 @@ VS Code Agent 모드와 Copilot CLI는 **둘 다 에이전틱**(다단계 자율 | 항목 | 🖥️ VS Code Copilot Chat | 💻 Copilot CLI | |------|---|---| | **실행 위치** | 에디터(GUI) 내 채팅·인라인 | 터미널 — SSH·서버·CI·헤드리스 가능 | -| **상호작용 모드** | Ask · Edit · Agent (모드 선택) | 단일 대화 + `Shift+Tab`로 Interactive ↔ Plan, `--autopilot` | +| **상호작용 모드** | Ask · Edit · Agent (모드 선택) | `Shift+Tab`로 Interactive → Plan → Autopilot 순환 | | **공유 설정** | `copilot-instructions.md`, `instructions/`, `agents/`, `skills/`, `prompts/`, `AGENTS.md` | 위 + **`CLAUDE.md`·`GEMINI.md`** 인식(Claude/Gemini 호환) | | **에이전트 호출** | 에이전트 피커 | `/agent` · `--agent ` · 자연어로 이름 언급 | | **프롬프트 파일** | `/프롬프트명` (채팅) | 직접 호출 없음 → 내용을 자연어로 요청 | @@ -144,15 +145,15 @@ VS Code Agent 모드와 Copilot CLI는 **둘 다 에이전틱**(다단계 자율 |------|----------|--------| | **슬래시 커맨드** | 세션 제어 명령 | `/help`로 전체 보기 — `/plan`·`/model`·`/mcp`·`/agent`·`/diff`·`/review` 등 | | **멘션** | 입력 보조 | `@`파일 · `#`이슈/PR · `!`로컬 셸 명령 직접 실행 | -| **모드** | 진행 방식 | Interactive(단계 승인) · Plan(먼저 계획) · Autopilot(끝까지 자동) | +| **모드** | 진행 방식 | Interactive(대화형) · Plan(계획 우선) · Autopilot(완료까지 연속 진행) | | **Custom Agent** | 역할·도구가 제한된 전용 에이전트 | 내장(Explore·Task·Research 등) + `.github/agents/*.agent.md` 커스텀, `--agent`로 실행 | | **Skill** | 주입하는 전문 지식·패턴 묶음 | `.github/skills/*/SKILL.md` — 관련 작업 감지 시 자동 로드, `/skills` 관리 | | **Instructions** | 항상/조건부 적용 규칙 | `copilot-instructions.md`(전역) + `instructions/*`(`applyTo` 글롭) + `AGENTS.md` | -| **MCP 서버** | 외부 시스템을 도구로 연결 | GitHub MCP 기본 내장, `.copilot/mcp-config.json`로 추가(Azure·Learn 등) | +| **MCP 서버** | 외부 시스템을 도구로 연결 | GitHub MCP 기본 내장, `.mcp.json` 또는 `.github/mcp.json`으로 추가 | | **LSP** | 코드 인텔리전스 | `.github/lsp.json`(이 저장소엔 미설정 — 직접 추가) — go-to-definition·hover·진단 | | **서브에이전트** | 작업 병렬 위임 | 모델이 자동 위임하거나 `/fleet`로 병렬 실행, `/tasks`로 관리 | | **세션/컨텍스트** | 대화 관리 | `/compact`·`/context`·`/usage`·`/resume`·`copilot --continue`·`/share`·`/memory` | -| **자동화** | 손 안 대고 진행 | `--autopilot`·`/delegate`(클라우드 PR)·`-p`(비대화형/CI) | +| **자동화** | 손 안 대고 진행 | `--autopilot`·`/every`·`/after`(실험적)·`/delegate`·`-p` | | **코드 작업** | 개발 보조 | `/diff`·`/review`·`/pr`·`/research`·`/ide` | --- @@ -173,7 +174,7 @@ VS Code Agent 모드와 Copilot CLI는 **둘 다 에이전틱**(다단계 자율 node --version # 먼저 v22 이상인지 확인 npm install -g @github/copilot # 또는 macOS/Linux: curl -fsSL https://gh.io/copilot-install | bash -# 또는: brew install copilot-cli / winget install GitHub.Copilot +# 또는: brew install --cask copilot-cli / winget install GitHub.Copilot copilot --version # 1.0.x 출력 ``` @@ -184,7 +185,7 @@ copilot --version # 1.0.x 출력 ### 2) 실행 · 폴더 신뢰 · 로그인 ```bash -# 이 저장소에서 실행해야 .github/·.copilot/ 설정을 함께 읽습니다 +# 이 저장소에서 실행해야 .github/ 설정을 함께 읽습니다 cd copilot-cli-labs copilot ``` @@ -197,8 +198,9 @@ copilot # 브라우저가 열리고 device code 인증을 안내합니다. 완료하면 세션으로 돌아옵니다. ``` -> 💡 PAT로 인증하려면 토큰을 환경변수로 두고 실행합니다(우선순위 `COPILOT_GITHUB_TOKEN > GH_TOKEN > -> GITHUB_TOKEN`). 자세히는 [설정 파일·환경변수](#설정-파일환경변수). +> 💡 PAT로 인증하려면 **개인 계정에서 만든 fine-grained PAT**에 `Copilot Requests` 권한을 부여하고 +> 환경변수로 전달합니다(우선순위 `COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN`). Classic PAT +> (`ghp_` 접두사)는 Copilot CLI 인증에 사용할 수 없습니다. ✅ **확인**: 배너가 뜨고 로그인 상태가 되면 완료입니다. 막히면 [트러블슈팅](#트러블슈팅)을 보세요. @@ -223,7 +225,7 @@ copilot | 동작 | 방법 | |------|------| -| Plan(계획 우선) ↔ Interactive 전환 | `Shift+Tab` | +| Interactive → Plan → Autopilot 순환 | `Shift+Tab` | | 모델 변경 | `/model` (Claude·GPT-5·Gemini 등, `auto` 가능) | | 추론 과정 표시 토글 | `Ctrl+T` | | 전체 슬래시 커맨드 | `/help` | @@ -246,7 +248,7 @@ Plan 모드에서는 코드를 바꾸기 전에 **구현 계획**을 먼저 제 > 💡 `@`는 파일, `#`는 이슈/PR, `!`는 로컬 셸 명령을 가리킵니다. `!`로 시작하면 모델을 거치지 않고 > 바로 실행되므로 `!git status` 같은 확인 작업이 빠릅니다. -✅ **확인**: 파일 멘션(`@`)에 대한 한국어 설명을 받고, `Shift+Tab`으로 Plan↔Interactive를 오가며, +✅ **확인**: 파일 멘션(`@`)에 대한 한국어 설명을 받고, `Shift+Tab`으로 세 모드를 순환하며, `/model`로 모델을 한 번 바꿔 봤다면 완료입니다. ## 실습 2. `.github/` 설정으로 Copilot 조종하기 @@ -259,6 +261,7 @@ Copilot은 작업 디렉토리의 `.github/` 설정과 `AGENTS.md`를 읽어 ** ```text .github/ ├── copilot-instructions.md # 전역 페르소나·코딩 스타일·프로젝트 규칙 +├── mcp.json # Workspace MCP 서버(Azure · Microsoft Learn) ├── instructions/ # 경로/언어별 세부 규칙 (applyTo 글롭) │ ├── python.instructions.md │ ├── azure.instructions.md @@ -348,8 +351,8 @@ copilot --agent debugger # 환경/런타임 진단 > reviewer 에이전트로 src/04_concurrent_workflow.py를 검토해줘 ``` -`/agent`를 입력하면 이 저장소의 7개 에이전트(+CLI 내장 에이전트)가 목록으로 나타나고, 화살표로 -선택합니다. +`/agent`를 입력하면 이 저장소의 7개 에이전트와 직접 선택 가능한 내장 에이전트가 목록으로 나타납니다. +Rubber duck처럼 자동으로만 호출되는 일부 내장 에이전트는 선택 목록에 보이지 않을 수 있습니다. ✅ **확인**: `/agent` 목록에 7개 에이전트가 보이고, `reviewer`가 읽기 전용으로 코드 리뷰를 내놓으면 완료입니다. (패턴별 팀 구성·협업 흐름은 [Custom Agent·Skill 만들기](#custom-agentskill-만들기) 참고) @@ -358,72 +361,72 @@ copilot --agent debugger # 환경/런타임 진단 > 🎯 MCP 서버를 연결해 외부 도구를 쓴다 · ⏱️ 약 10분 -**MCP(Model Context Protocol)** 서버를 붙이면 Copilot이 외부 시스템을 **도구**로 사용합니다. Copilot CLI에는 -GitHub MCP 기능이 **기본 제공**되며, 이 저장소는 `.copilot/mcp-config.json`에 `github`(명시 PAT 인증)·`azure`· -`microsoftLearn` 3개 서버를 리포 설정으로 선언해 둡니다. +**MCP(Model Context Protocol)** 서버를 붙이면 Copilot이 외부 시스템을 **도구**로 사용합니다. GitHub MCP +서버는 CLI에 기본 내장되어 별도 PAT나 설정 블록이 필요 없습니다. 이 저장소의 `.github/mcp.json`은 +추가 실습용 서버 두 개만 선언합니다. ```json { "mcpServers": { - "github": { "type": "http", "url": "https://api.githubcopilot.com/mcp/", - "headers": { "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" }, "tools": ["*"] }, - "azure": { "type": "local", "command": "npx", - "args": ["-y", "@azure/mcp@latest", "server", "start"], "tools": ["*"] }, - "microsoftLearn":{ "type": "http", "url": "https://learn.microsoft.com/api/mcp", "tools": ["*"] } + "azure-lab": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@azure/mcp@latest", "server", "start"], + "tools": ["*"] + }, + "microsoft-learn-lab": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp", + "tools": ["*"] + } } } ``` | 서버 | 용도 | 인증 | |------|------|------| -| **github** | 이슈·PR·리포 탐색/조작 | PAT — `GITHUB_PERSONAL_ACCESS_TOKEN` | -| **azure** | 구독 내 Azure 리소스 조회·관리 | `az login` 세션 | -| **microsoftLearn** | Microsoft/Azure 공식 문서·코드 샘플 검색 | 불필요 | +| **GitHub MCP(내장)** | 이슈·PR·리포 탐색/조작 | Copilot CLI 로그인 | +| **azure-lab** | 구독 내 Azure 리소스 조회·관리 | `az login` 세션 | +| **microsoft-learn-lab** | Microsoft/Azure 공식 문서·코드 샘플 검색 | 불필요 | ```bash -# (선택) github·azure 서버를 쓸 때만 인증 -export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx # repo·read:org 등 최소 권한 PAT 권장 +# Workspace 설정 인식 확인 +copilot mcp list + +# (선택) Azure MCP를 사용할 때만 인증 az login copilot -> /mcp # 등록된 서버 목록·상태 확인 ( /mcp add 로 새 서버 추가 ) +# 세션 안에서 /mcp를 입력해 서버를 관리하고 상태 확인 ``` ```text -> /mcp - github http ✓ connected - azure local ✓ connected - microsoftLearn http ✓ connected +Workspace servers: + azure-lab (local) + microsoft-learn-lab (http) ``` -PAT·Azure 로그인을 건너뛴 경우에는 다음처럼 `microsoftLearn`만 연결되어도 정상입니다. +사용자 설정이나 플러그인이 있으면 별도 섹션이 추가될 수 있습니다. 내장 GitHub MCP는 Workspace 서버가 +아니므로 위 목록에 없어도 정상입니다. -```text -> /mcp - github http 인증 필요 / disconnected - azure local 인증 필요 / disconnected - microsoftLearn http ✓ connected -``` - -> 💡 **인증 없이 진행 가능**: PAT·Azure를 설정하지 않으면 위 `github`·`azure`는 `connected`로 보이지 -> 않을 수 있지만, 인증이 필요 없는 `microsoftLearn`만으로 이 실습을 끝낼 수 있습니다. 또한 Copilot CLI는 -> **GitHub MCP를 기본 내장**하므로 `github` 블록이 없어도 기본 GitHub 기능은 동작합니다(블록을 유지하면 -> `GITHUB_PERSONAL_ACCESS_TOKEN`이 있어야 인증 오류가 나지 않습니다). +> 💡 **Azure 인증 없이 진행 가능**: `azure-lab`을 사용하지 않아도 +> `microsoft-learn-lab`만으로 이 실습을 끝낼 수 있습니다. Azure 설정은 목록에 나타나더라도 실제 도구 +> 호출 때 인증이 필요할 수 있으므로, 최종 확인은 아래 검색 요청으로 합니다. > -> ⚠️ `tools: ["*"]`는 모든 도구를 허용하므로, 특히 `azure`는 **조회뿐 아니라 변경/삭제**도 가능합니다 +> ⚠️ `tools: ["*"]`는 모든 도구를 허용하므로, 특히 `azure-lab`은 **조회뿐 아니라 변경/삭제**도 가능합니다 > (실제 범위는 `az login` 계정의 RBAC로 제한되고 실행 전 승인을 받습니다). 읽기 전용만 노출하려면 > `tools`를 도구명으로 좁히세요. -인증이 필요 없는 `microsoftLearn` 서버로 실제 공식 문서를 검색해 봅니다. +인증이 필요 없는 `microsoft-learn-lab` 서버로 실제 공식 문서를 검색해 봅니다. ```text > Microsoft Learn에서 'Agent Framework Concurrent orchestration' 문서를 찾아 핵심을 한국어로 요약해줘 ``` -Copilot이 `microsoftLearn` MCP 도구를 호출해 공식 문서를 가져와 요약합니다(도구 호출 시 승인을 물을 수 있음). +Copilot이 Microsoft Learn MCP 도구를 호출해 공식 문서를 가져와 요약합니다(도구 호출 시 승인을 물을 수 있음). -✅ **확인**: `/mcp`에 서버가 `connected`로 보이고, 위 요청에 Copilot이 Learn 문서를 검색해 요약하면 -완료입니다. (PAT·Azure 없이 `microsoftLearn`만으로 가능) +✅ **확인**: `copilot mcp list`에 두 Workspace 서버가 보이고, 위 요청에 Copilot이 Learn 문서를 검색해 요약하면 +완료입니다. (Azure 없이 Microsoft Learn 서버만으로 가능) ## 실습 5. 바이브 코딩 — 설정만으로 코드 생성·리뷰 @@ -451,8 +454,9 @@ Copilot이 `microsoftLearn` MCP 도구를 호출해 공식 문서를 가져와 > 리뷰에서 지적된 부분을 반영해서 수정해줘 # 생성 → 리뷰 → 수정 사이클을 한 바퀴 돌려 봅니다 ``` -`/diff`에는 **대략 다음 두 가지**가 보여야 합니다 — ① 새 `Agent` 정의, ② `ConcurrentBuilder`의 -`participants`에 그 에이전트 추가. (모델 출력이라 문구는 조금씩 다를 수 있습니다.) +`/diff`에는 **대략 다음 세 가지**가 보여야 합니다 — ① 새 `Agent` 정의, ② `ConcurrentBuilder`의 +`participants`에 그 에이전트 추가, ③ `intermediate_output_from`에도 같은 에이전트 추가. +(모델 출력이라 문구는 조금씩 다를 수 있습니다.) ```python cost_agent = Agent( @@ -466,11 +470,13 @@ cost_agent = Agent( ) # ... workflow = ConcurrentBuilder( - participants=[security_agent, performance_agent, ux_agent, cost_agent] + participants=[security_agent, performance_agent, ux_agent, cost_agent], + intermediate_output_from=[security_agent, performance_agent, ux_agent, cost_agent], ).build() ``` -새 `Agent` 정의만 생기고 `participants`에서 빠지면 병렬 검토에 합류하지 못하므로 **둘 다** 있어야 합니다. +새 `Agent` 정의만 생기고 `participants`에서 빠지면 병렬 검토에 합류하지 못합니다. 또한 현재 SDK에서 +참여자별 검토 내용을 스트리밍하려면 같은 목록을 `intermediate_output_from`에도 포함해야 합니다. (선택, Python 설치 시) 문법만 검증 — Azure 불필요: @@ -490,27 +496,35 @@ python3 -m py_compile src/04_concurrent_workflow.py # 오류 없으면 아무 | 기능 | 명령 | 설명 | |------|------|------| | **Plan 모드** | `Shift+Tab` 또는 `/plan` | 실행 전 구현 계획을 먼저 수립 | -| **Autopilot** | `copilot --autopilot` 또는 `/autopilot` | 매 단계 승인 없이 끝까지 자동 진행 | +| **Autopilot** | `copilot --autopilot` 또는 `/autopilot` | 완료 조건까지 후속 턴을 이어서 실행 | +| **권한 자동 승인** | `/allow-all`, `--allow-all`, `--yolo` | 도구·경로·URL 승인 생략(Autopilot과 별도) | | **병렬 서브에이전트** | `/fleet` | 여러 서브에이전트를 병렬 실행, `/tasks`로 관리 | +| **예약 실행(실험적)** | `/every <주기> <프롬프트>` · `/after <지연> <프롬프트>` | 세션이 열린 동안 반복/일회성 프롬프트 예약 | | **클라우드 위임** | `/delegate` | 세션을 GitHub에 보내 Copilot이 PR 생성 | | **비대화형 실행** | `copilot -p "..." --allow-all-tools` | 스크립트·CI·cron 등에서 한 번 실행 후 종료 | | **세션 재개** | `copilot --continue` / `/resume` | 직전/특정 세션을 컨텍스트째 이어서 | > ℹ️ `/sandbox`(로컬 샌드박스)와 `copilot --cloud`(클라우드 샌드박스)는 **공개 미리보기**라 버전·계정에 -> 따라 노출이 다를 수 있고, 클라우드 샌드박스는 조직/엔터프라이즈 정책 활성화가 필요합니다(현재 지원 -> 여부는 `/help`로 확인). 정기 실행이 필요하면 OS 스케줄러(cron·작업 스케줄러)로 위 `copilot -p`를 호출하세요. +> 따라 노출이 다를 수 있고, 클라우드 샌드박스는 조직/엔터프라이즈 정책 활성화가 필요합니다. 현재 지원 +> 여부는 `/help`로 확인하세요. ```text > /plan > 새 RAG 예제를 추가하는 작업을 단계로 나눠줘 -# 계획이 마음에 들면 Shift+Tab 으로 Interactive 전환 후 실행하거나, --autopilot 로 끝까지 자동 진행 +# 계획이 마음에 들면 Shift+Tab으로 다음 모드로 전환해 실행 +> /experimental on +> /every 1h 프런트엔드 테스트를 실행하고 실패만 보고해줘 +> /after 20m 현재 작업 상태를 요약해줘 ``` -> ⚠️ Autopilot·`--yolo`는 파일 변경·명령을 자동 실행합니다. **신뢰할 수 있는 환경**에서만 쓰고, -> 위험한 작업은 `/sandbox enable`(로컬 샌드박스)이나 `copilot --cloud`(클라우드)에서 시도하세요. +> ⚠️ Autopilot은 작업을 연속 진행하지만 도구 권한을 자동 승인하지는 않습니다. `/allow-all`·`--yolo`를 +> 함께 사용할 때만 승인까지 생략되므로 **신뢰할 수 있는 환경**에서만 사용하세요. +> +> `/every`와 `/after`는 현재 실험적 기능입니다. `/experimental on` 또는 시작 옵션 `--experimental`이 +> 필요하며, 예약한 세션이 열려 있을 때만 실행됩니다. -✅ **확인**: `/plan`으로 계획을 받고, `/fleet`·`/tasks`·`/delegate` 같은 자동화 명령이 무엇인지 이해했다면 -완료입니다. +✅ **확인**: `/plan`으로 계획을 받고, Autopilot과 권한 자동 승인의 차이 및 `/every`·`/after`·`/fleet`· +`/tasks`·`/delegate`의 용도를 이해했다면 완료입니다. ## 실습 7. 가드레일(AGENTS.md)로 안전하게 커밋·PR @@ -579,8 +593,14 @@ tools: [read, edit] 1. 대상 파일을 읽고 → 2. docstring을 추가하고 → 3. 변경을 요약합니다. ``` -**2) 만든 에이전트를 실행합니다.** 새 에이전트는 **새 세션에서 로드**되므로, 현재 세션 안에서 파일을 -만들었다면 `/exit`로 나간 뒤(또는 새 터미널에서) 저장소 루트에서 실행합니다. +**2) 만든 에이전트를 실행합니다.** 세션에서 `/agent doc_writer`로 선택하거나 새 프로세스를 시작합니다. +목록에 즉시 보이지 않으면 `/restart`로 현재 세션을 유지한 채 CLI를 다시 로드합니다. + +```text +> /agent doc_writer +``` + +또는: ```bash copilot --agent doc_writer @@ -634,8 +654,10 @@ copilot --agent doc_writer | `/delegate` | 세션을 GitHub에 보내 Copilot이 PR 생성 | | `/fleet` | 병렬 서브에이전트 실행 모드 | | `/autopilot` | Autopilot 모드 토글 | -| `/tasks` · `/sidekicks` | 백그라운드 태스크 / 실행 중 서브에이전트 관리 | +| `/tasks` | 서브에이전트와 셸 명령 태스크 조회·관리 | +| `/subagents` | 기본·에이전트별 서브에이전트 모델 설정 | | `/plan` | 코딩 전 구현 계획 작성 | +| `/every` · `/after` | 반복 또는 일회성 프롬프트 예약(실험 모드, 세션 실행 중에만 동작) | **코드 작업** @@ -643,6 +665,8 @@ copilot --agent doc_writer |--------|------| | `/diff` | 현재 디렉토리 변경사항 리뷰 | | `/review` | 코드 리뷰 에이전트 실행 | +| `/security-review` | staged·unstaged 변경의 보안 취약점 분석 | +| `/rubber-duck` | 현재 작업에 대한 독립적 비평 요청 | | `/pr` | 현재 브랜치의 PR 작업 | | `/research` | GitHub·웹 소스를 활용한 심층 리서치 | | `/ide` | IDE 워크스페이스 연결 | @@ -669,9 +693,10 @@ copilot --agent doc_writer | `/compact` | 히스토리 요약으로 컨텍스트 절약 (95% 근접 시 자동) | | `/share` · `/copy` | 세션 공유(md·HTML·Gist) / 마지막 응답 복사 | | `/memory` | 세션 간 메모리 토글 | -| `/rewind` · `/undo` | 마지막 턴 되돌리기 + 파일 변경 복원 | +| `/rewind` | 마지막 턴 되돌리기 + 파일 변경 복원 | | `/remote` | GitHub 웹·모바일에서 원격 제어 토글 | | `/chronicle` · `/search` | 세션 히스토리 도구 / 타임라인 검색 | +| `/limits` | 현재 대화의 AI Credit 제한 조회·설정 | **도움말·기타** @@ -683,11 +708,15 @@ copilot --agent doc_writer | `/restart` · `/exit` | CLI 재시작(세션 유지) / 종료 | | `/instructions` | 인스트럭션 파일 확인·토글 | | `/voice` | 음성 입력(받아쓰기) 모드 | -| `/theme` · `/statusline` · `/footer` · `/streamer-mode` | 색상·상태줄·스트리머 모드 | +| `/theme` · `/statusline` · `/footer` | 색상·상태줄 설정 | +| `/settings` | CLI 설정 UI 또는 개별 설정값 조회·변경 | | `/experimental` | 실험적 기능 관리 | | `/feedback` | 피드백 제출 | | `/login` · `/logout` · `/user` | 로그인·로그아웃·GitHub 사용자 목록 관리 | | `/ask` | 히스토리에 남기지 않는 빠른 곁가지 질문 | +| `/refine` | 거친 프롬프트를 실행 전 명확한 요청으로 재작성 | +| `/diagnose` | 현재 세션 로그 진단 | +| `/app` | Copilot 데스크톱 앱 안내 | | `/keep-alive` | 시스템 절전 방지 토글 | ## 키보드 단축키 @@ -696,7 +725,7 @@ copilot --agent doc_writer | 단축키 | 기능 | 단축키 | 기능 | |--------|------|--------|------| -| `Shift+Tab` | 모드 전환 | `Ctrl+T` | 추론 과정 표시 토글 | +| `Shift+Tab` | Interactive → Plan → Autopilot 순환 | `Ctrl+T` | 추론 과정 표시 토글 | | `Ctrl+S` | 프롬프트 임시 저장/복원 | `Ctrl+Q` | 프롬프트 대기열에 추가 | | `Ctrl+R` | 히스토리 역방향 검색 | `Ctrl+O`/`Ctrl+E` | 타임라인 확장 | | `Ctrl+C` | 취소 (`×2` 종료) | `Esc` | 현재 작업 취소 | @@ -722,7 +751,10 @@ copilot --agent orchestrator --autopilot --yolo |--------|------| | `--agent ` | 특정 에이전트로 시작 | | `-p, --prompt "..."` | 비대화형으로 프롬프트 전달(스크립트·CI) | +| `--mode ` | 시작 모드를 명시 | +| `--plan` | Plan 모드로 시작 | | `--autopilot` | Autopilot으로 시작 | +| `--model ` | 시작 모델 선택 (`auto` 가능) | | `--yolo` / `--allow-all` | 자동 승인 — 모든 도구·경로·URL 허용 | | `--cloud` | 클라우드 샌드박스 세션으로 시작 (공개 미리보기, 조직 정책 필요) | | `--continue` | 가장 최근 로컬 세션 이어서 | @@ -735,7 +767,7 @@ copilot --agent orchestrator --autopilot --yolo ```bash # 스크립트 (macOS/Linux): 루트 설치는 | sudo bash, 버전·경로는 VERSION/PREFIX curl -fsSL https://gh.io/copilot-install | bash -brew install copilot-cli # @prerelease 로 프리릴리즈 +brew install --cask copilot-cli # Homebrew winget install GitHub.Copilot # .Prerelease 로 프리릴리즈 npm install -g @github/copilot # @prerelease 로 프리릴리즈 ``` @@ -745,7 +777,8 @@ npm install -g @github/copilot # @prerelease 로 프리릴리즈 | 위치 | 설명 | |------|------| | `~/.copilot/settings.json` | CLI 설정 (`copilot help config`) | -| `~/.copilot/mcp-config.json` | 유저 레벨 MCP 서버 (리포는 `.copilot/mcp-config.json`) | +| `~/.copilot/mcp-config.json` | 유저 레벨 MCP 서버 | +| `.mcp.json` / `.github/mcp.json` | Workspace MCP 서버 | | `~/.copilot/lsp-config.json` | 유저 레벨 LSP (리포는 `.github/lsp.json`) | | `~/.copilot/agents/` | 유저 레벨 커스텀 에이전트 | | `.github/hooks/*.json` | 에이전트 수명주기 훅 (편집 후 포맷·도구 승인/차단·시크릿 스캔 등) | @@ -753,9 +786,8 @@ npm install -g @github/copilot # @prerelease 로 프리릴리즈 | 환경변수 | 설명 | |----------|------| | `COPILOT_HOME` | 설정 디렉토리 경로 변경(기본 `~/.copilot`) | -| `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | PAT 인증 (이 우선순위로 적용) | +| `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | Fine-grained PAT 인증(이 우선순위, `Copilot Requests` 필요) | | `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` | 추가 인스트럭션 디렉토리 | -| `GITHUB_PERSONAL_ACCESS_TOKEN` | `.copilot/mcp-config.json`의 github 서버 토큰 | **인스트럭션 인식·우선순위** — Copilot은 아래 위치를 자동 인식합니다. 여러 파일이 있으면 **모두 동시 적용**되며, 충돌 시 단일 우선순위로 단정할 수 없습니다(조합에 따라 비결정적). 현재 적용은 @@ -834,12 +866,13 @@ model: auto # 선택 |------|------|------| | `copilot: command not found` | 설치 경로가 PATH에 없음 | 재설치 후 새 터미널. `npm prefix -g`/`ls ~/.local/bin/copilot` 확인, 필요 시 `export PATH="$HOME/.local/bin:$PATH"` | | 로그인 안내만 반복 | 인증 미완료 | `/login` 후 브라우저 인증(자동으로 안 열리면 표시 URL 수동 열기), 또는 `export GH_TOKEN=...`. 조직 정책 비활성화 시 관리자 확인 | -| `/mcp`에 서버 안 보임 | `.copilot/mcp-config.json` 미인식/JSON 오류 | 저장소 루트에서 실행, JSON 문법 확인 후 세션 재시작 | -| github 서버 인증 오류 | `GITHUB_PERSONAL_ACCESS_TOKEN` 미설정 | PAT를 `export` 하거나 `github` 블록 제거(기본 내장 사용) | +| `/mcp`에 Workspace 서버 안 보임 | `.mcp.json`/`.github/mcp.json` 위치 또는 JSON 오류 | 저장소 루트에서 `copilot mcp list` 실행, JSON 문법 확인 후 세션 재시작 | +| Copilot PAT 인증 오류 | Classic PAT 또는 권한 부족 | `Copilot Requests` 권한이 있는 fine-grained PAT 사용, 또는 `/login` OAuth 사용 | | azure 서버 연결 실패 | `az login` 세션 없음/만료 | `az login` 재실행, `az account show` 확인 | | `--agent ` 안 됨 | 파일명/위치 불일치 | `ls .github/agents/*.agent.md` 확인, `/agent`로 목록 확인 | | 응답 품질 저하 / 컨텍스트 초과 | 대화가 너무 김 | `/compact`·`/context`·`/clear` (95% 근접 시 자동 압축) | | Node.js 버전 오류 | Node 22 미만 | `node -v` 확인 후 22+ 설치 () | +| `pip install` 의존성 해석 실패 | 개별 SDK를 임의 버전으로 섞음 | 새 가상환경에서 루트 `requirements.txt`의 고정 버전을 함께 설치 | ## 더 알아보기 @@ -855,19 +888,20 @@ model: auto # 선택 ``` . ├── README.md # 이 가이드 (개념 + 실습 + 레퍼런스) -├── AGENTS.md # 에이전트 공통 가드레일 (push 금지·영문 커밋·PR 규칙) -├── .copilot/ -│ └── mcp-config.json # MCP 서버 설정 (github · azure · microsoftLearn) +├── AGENTS.md # 에이전트 공통 가드레일 (보호 브랜치·커밋·PR 규칙) +├── requirements.txt # 검증된 Python 의존성 고정 버전 ├── .github/ │ ├── copilot-instructions.md # 프로젝트 전역 인스트럭션 +│ ├── mcp.json # Workspace MCP (Azure · Microsoft Learn) │ ├── instructions/ # python · azure · korean · git-commit 규칙 │ ├── prompts/ # add-agent · review-code (재사용 프롬프트) │ ├── agents/ # orchestrator + 4 패턴 + reviewer · debugger (7개) │ ├── skills/ │ │ └── agent-framework-codegen/SKILL.md # MAF 코드 생성 패턴 │ └── workflows/ -│ └── smoke.yml # 예제 스크립트 바이트컴파일 스모크 CI +│ └── smoke.yml # 의존성·컴파일·오프라인 테스트 CI ├── docs/ # GitHub 멀티 계정 설정 가이드 +├── tests/ # 임포트·워크플로우 출력 회귀 테스트 └── src/ # 바이브 코딩 예시 도메인 (Microsoft Agent Framework 예제) ``` @@ -883,14 +917,15 @@ model: auto # 선택 지시하세요.) > 💡 아래 각 코드 블록은 **한 번에 입력하는 하나의 프롬프트**입니다(`>`는 프롬프트 시작 표시, `-` -> 줄은 같은 프롬프트에 포함되는 요구사항). 스트리밍 헬퍼 `_streaming.py`(전 예제 공유)와 Foundry IQ -> 헬퍼 `_rag_iq.py`(7번 전용)는 이를 처음 `import`하는 예제를 만들 때 함께 생성됩니다. +> 줄은 같은 프롬프트에 포함되는 요구사항). 스트리밍 헬퍼 `_streaming.py`(전 예제 공유), 인덱싱 대기 +> 헬퍼 `_indexing.py`(RAG 예제 공유), Foundry IQ 헬퍼 `_rag_iq.py`(7번 전용)는 이를 처음 `import`하는 +> 예제를 만들 때 함께 생성됩니다. **1. 단일 에이전트** → `src/01_single_agent.py` ```text > src/01_single_agent.py를 만들어줘. 요구사항: -- FoundryChatClient(project_endpoint, model=MODEL_DEPLOYMENT_NAME, credential=AzureCliCredential)로 Foundry에 연결 +- FoundryChatClient(project_endpoint=PROJECT_ENDPOINT, model=MODEL_DEPLOYMENT_NAME, credential=AzureCliCredential())로 Foundry에 연결 - 에이전트 이름은 "기술_어시스턴트", 역할은 Microsoft 기술 전문 어시스턴트로서 기술 질문에 정확하고 이해하기 쉽게, 간결하지만 핵심을 담아 한국어로 답변 - 질문 "Microsoft Agent Framework가 무엇인가요?"를 던지고, _streaming의 stream_agent로 응답을 토큰 단위로 스트리밍 출력 - 클라이언트 초기화가 실패하면 az login 상태를 확인하라는 한국어 안내를 출력 @@ -903,7 +938,7 @@ model: auto # 선택 - 분석가: 주제의 핵심 논점 3가지를 간결히 정리 - 작가: 앞 단계 분석을 바탕으로 400자 이내 글 초안 작성 - 편집자: 초안의 논리 흐름·가독성을 다듬어 최종본 작성 -- 세 에이전트를 SequentialBuilder(participants=[분석가, 작가, 편집자])로 연결 +- 세 에이전트를 SequentialBuilder(participants=[분석가, 작가, 편집자], intermediate_output_from=[분석가, 작가])로 연결 - 입력 주제는 "Kubernetes 클러스터 비용 최적화 전략", 결과는 stream_workflow로 출력 ``` @@ -915,7 +950,7 @@ model: auto # 선택 - 개발자(시니어 풀스택): 기술적 실현 가능성·아키텍처와 Azure/AI 활용 방안 제시 - 디자이너(시니어 UX/UI): 사용자 경험·인터페이스·접근성 관점 제시 - 발화자 선택은 participants 삽입 순서 기준 라운드 로빈 selection_func(state.current_round 사용) -- GroupChatBuilder(participants, selection_func, max_rounds=6)로 구성하고 stream_workflow로 출력 +- GroupChatBuilder(participants=participants, selection_func=selection_func, max_rounds=6, intermediate_output_from=participants)로 구성하고 stream_workflow로 출력 - 주제는 "모바일 앱 신규 기능 기획: AI 기반 개인화 추천 시스템 도입" ``` @@ -926,7 +961,7 @@ model: auto # 선택 - 보안 리뷰어: 보안 위험과 완화 방안을 핵심만 평가 - 성능 리뷰어: 성능 병목과 확장성 개선점을 핵심만 평가 - UX 리뷰어: 사용성과 접근성 개선점을 핵심만 평가 -- 세 에이전트를 ConcurrentBuilder(participants=[...])로 병렬 실행하고 stream_workflow로 출력 +- 세 에이전트를 ConcurrentBuilder(participants=[...], intermediate_output_from=[...])로 병렬 실행하고 stream_workflow로 출력 - 검토 대상 설계안은 "로그인 없이 게스트 결제를 허용하고 추천 데이터를 단말에 캐시하는 신규 모바일 앱" ``` @@ -948,6 +983,7 @@ model: auto # 선택 - 지식 베이스는 한국어 문서 4건(환불 정책, 구독 요금제, 기술 지원 SLA, 계정 보안) - 인덱스가 없으면 키리스로 생성: id/title/content + content_vector, title·content는 ko.microsoft 분석기, HNSW 코사인, 벡터 차원은 임베딩 모델 실제 출력으로 동적 결정 - 문서를 Azure OpenAI 임베딩(키리스 AAD)으로 임베딩해 merge_or_upload로 멱등 시드하고, 인덱싱 반영을 문서 수로 폴링 +- 인덱싱 대기는 _indexing.py의 비동기 deadline 헬퍼로 분리하고 30초 안에 완료되지 않으면 TimeoutError - 질문 "Pro 요금제는 얼마이고 기술 지원은 얼마나 빨리 받을 수 있나요?"로 top_k=2 하이브리드 검색 → 컨텍스트 주입 - 에이전트 "고객지원_RAG_어시스턴트": 제공된 참고 문서 안의 정보로만 한국어로 답변하고, 없으면 모른다고 답한 뒤 답변 끝에 [출처: 문서제목] 표기 - 필요 env: PROJECT_ENDPOINT, SEARCH_SERVICE_ENDPOINT, AZURE_OPENAI_ENDPOINT, EMBEDDING_DEPLOYMENT_NAME. 최종 답변은 stream_agent로 출력 @@ -957,11 +993,12 @@ model: auto # 선택 ```text > src/06_rag_agent_foundry_iq.py를 만들어줘. 06번과 같은 지식 베이스를 Foundry IQ(지식 베이스 + agentic retrieval)에 위임하는 변형이야. 요구사항: -- 검색·증강을 직접 코딩하지 말고 agent_framework.azure의 AzureAISearchContextProvider(agentic 모드)에 위임 — before_run 훅에서 멀티홉 검색 결과를 세션 컨텍스트에 자동 주입 +- 검색·증강을 직접 코딩하지 말고 agent_framework.azure의 AzureAISearchContextProvider(agentic 모드)에 위임 — 모델 호출 전에 멀티홉 검색 결과를 세션 컨텍스트에 자동 주입 - 인덱스는 기본 semantic 구성과 함께 생성(agentic retrieval 필수 요건), 하이브리드 예제와 충돌하지 않게 별도 인덱스명(기본 maf-lab-knowledge-iq-v1) 사용 - 지식 베이스 model에는 임베딩이 아니라 채팅 모델 배포명(gpt-5.x)을 전달하고, 문서 임베딩은 EMBEDDING_DEPLOYMENT_NAME으로 시드 단계에서 수행 - 컨텍스트 프로바이더는 비동기 자격 증명, 시드·채팅 클라이언트는 동기 자격 증명을 사용 -- 시드·프로바이더 구성·env 해석 같은 공용 로직은 _rag_iq.py로 분리하고, 최종 답변은 stream_agent로 출력 +- 시드·프로바이더 구성·env 해석 같은 공용 로직은 _rag_iq.py로 분리하고, 인덱싱 대기는 _indexing.py의 동기 deadline 헬퍼를 재사용 +- 최종 답변은 stream_agent로 출력 ``` > 🔍 생성 후에는 `/diff`로 변경을 확인하고 `copilot --agent reviewer`로 규칙(import 경로 · async · diff --git a/docs/github-multi-account-setup.md b/docs/github-multi-account-setup.md index eb6f432..916db21 100644 --- a/docs/github-multi-account-setup.md +++ b/docs/github-multi-account-setup.md @@ -1,156 +1,134 @@ # GitHub 멀티 계정 설정 가이드 -> GitHub CLI(`gh`)를 활용하여 **개인 계정**(Git 작업용)과 **조직 계정**(Copilot용)을 동시에 사용하는 설정 방법. +> Git 작업용 계정과 GitHub Copilot 구독 계정을 한 머신에서 안전하게 분리하는 HTTPS 기준 절차입니다. ---- - -## 개요 +## 핵심 원리 -| 용도 | 계정 | 활성 상태 | -|------|------|-----------| -| Git push/pull, GitHub CLI | `` | Active | -| GitHub Copilot용 GitHub 계정 | `` | `gh auth status` 기준 Inactive 가능 | +| 용도 | 인증 주체 | 전환 방법 | +|------|-----------|-----------| +| `git push`/`pull`, `gh` 명령 | GitHub CLI의 현재 active 계정 | `gh auth switch` | +| Git 커밋 작성자 | Git의 `user.name`·`user.email` | 리포별 `git config --local` | +| Copilot CLI | Copilot CLI에 로그인한 계정 또는 전용 환경변수 | `/login`·`/user` | -두 계정을 `gh auth login`으로 등록해도, **active 계정** 개념은 `gh`의 GitHub CLI 동작에만 적용됩니다. -Copilot CLI 인증은 `gh` active/inactive 상태와 **독립적**이므로, 원하는 계정으로 Copilot CLI에 -별도 로그인(`/login`)하거나 `COPILOT_GITHUB_TOKEN` 같은 인증 정보를 따로 설정해야 합니다. +`gh`의 active 계정과 Copilot CLI 로그인은 서로 독립적입니다. Git 작업용 계정을 active로 유지한 채 +Copilot CLI에는 구독 계정으로 따로 로그인할 수 있습니다. --- -## 1단계: GitHub CLI에 두 계정 등록 - -```bash -# 첫 번째 계정 (개인 — Git 작업용) -gh auth login --hostname github.com - -# 두 번째 계정 (조직 — Copilot용) -gh auth login --hostname github.com -``` +## 1단계: GitHub CLI에 계정 등록 -로그인 후 확인: +두 계정을 차례로 등록합니다. 각 브라우저 인증 단계에서 올바른 계정을 선택하세요. ```bash +gh auth login --hostname github.com --git-protocol https +gh auth login --hostname github.com --git-protocol https gh auth status ``` -출력 예시: +`gh auth status`에 ``와 ``가 모두 표시되는지 확인합니다. -``` -github.com - ✓ Logged in to github.com account (keyring) - - Active account: true - - ✓ Logged in to github.com account (keyring) - - Active account: false -``` +> `GH_TOKEN` 또는 `GITHUB_TOKEN`이 셸에 설정되어 있으면 저장된 계정보다 우선할 수 있습니다. +> 대화형 멀티 계정 전환을 사용할 때는 불필요한 토큰 환경변수를 제거하세요. --- -## 2단계: 활성 계정 설정 +## 2단계: Git 작업 계정 선택 -Git 작업에 사용할 계정을 active로 설정한다: +Git 작업에 사용할 계정을 active로 전환하고, GitHub CLI를 HTTPS credential helper로 등록합니다. ```bash -gh auth switch --user +gh auth switch --hostname github.com --user +gh auth setup-git --hostname github.com +gh api user --jq .login ``` -결과로 `~/.config/gh/hosts.yml`이 다음과 같이 설정된다: +마지막 명령이 ``를 출력해야 합니다. `gh auth setup-git`은 GitHub CLI가 관리하는 자격 +증명을 Git에 연결하므로, 토큰을 직접 출력하거나 별도 helper 스크립트에 저장할 필요가 없습니다. + +다른 GitHub 계정으로 Git 작업을 전환할 때는 다음처럼 active 계정만 바꿉니다. -```yaml -github.com: - git_protocol: https - users: - : - : - user: # ← active 계정 +```bash +gh auth switch --hostname github.com --user +gh api user --jq .login ``` --- -## 3단계: Git Credential Helper 설정 - -Git이 push/pull 시 `gh`의 토큰을 자동으로 사용하도록 credential helper를 설정한다. +## 3단계: 커밋 작성자 정보 설정 -### 3-1. Helper 스크립트 생성 +인증 계정과 커밋 작성자 정보는 별도입니다. 여러 계정을 쓴다면 전역값보다 리포별 설정을 권장합니다. ```bash -mkdir -p ~/.config/git -cat > ~/.config/git/credential-helper.sh << 'EOF' -#!/bin/bash -# 를 실제 계정명으로 교체하세요 -TOKEN=$(gh auth token --user 2>/dev/null) -if [ -n "$TOKEN" ]; then - echo "protocol=https" - echo "host=github.com" - echo "username=" - echo "password=$TOKEN" -fi -EOF -chmod +x ~/.config/git/credential-helper.sh -``` - -### 3-2. 글로벌 Git 설정에 등록 +git config --local user.name "" +git config --local user.email "" -```bash -# 기존 credential helper 초기화 후 커스텀 helper 등록 -git config --global credential.helper "" -git config --global --add credential.helper '!~/.config/git/credential-helper.sh' +git config --local --get user.name +git config --local --get user.email ``` -결과로 `~/.gitconfig`에 다음이 추가된다: - -```ini -[credential] - helper = "" - helper = !~/.config/git/credential-helper.sh -``` - -> **참고:** `helper = ""`를 먼저 설정하여 macOS Keychain 등 기본 credential helper를 비활성화한다. +GitHub 프로필에 등록된 이메일 또는 계정의 `noreply` 이메일을 사용해야 커밋이 올바른 계정에 연결됩니다. --- -## 4단계: Git 사용자 정보 설정 +## 4단계: Copilot 구독 계정으로 로그인 + +저장소 루트에서 Copilot CLI를 시작하고 Copilot 구독이 있는 계정으로 로그인합니다. ```bash -git config --global user.name "" -git config --global user.email "" +copilot ``` ---- +```text +> /login +> /user +``` -## 설정 파일 요약 +`/user`에서 ``가 현재 Copilot 사용자로 선택됐는지 확인합니다. 이 과정에서 +`gh auth switch`를 ``로 바꿀 필요는 없습니다. -| 파일 | 역할 | -|------|------| -| `~/.config/gh/hosts.yml` | gh CLI 멀티 계정 관리 (active/inactive) | -| `~/.config/gh/config.yml` | gh CLI 전역 설정 (protocol, editor 등) | -| `~/.config/git/credential-helper.sh` | Git에 `` 토큰 제공 | -| `~/.gitconfig` | 글로벌 Git 설정 (user, credential helper) | +브라우저 로그인을 사용할 수 없는 자동화 환경에서는 개인 계정에서 만든 **fine-grained PAT**에 +`Copilot Requests` 권한을 부여한 뒤 다음 우선순위의 환경변수 중 하나로 전달할 수 있습니다. + +```text +COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN +``` + +Classic PAT(`ghp_` 접두사)는 Copilot CLI 인증에 사용할 수 없습니다. 토큰 값은 문서·스크립트·셸 +히스토리에 기록하지 말고 CI 시크릿 또는 운영체제의 보안 저장소로 주입하세요. --- -## 자주 쓰는 명령어 +## 최종 확인 ```bash -# 현재 인증 상태 확인 +# gh와 HTTPS Git 작업에 쓰일 active 계정 gh auth status +gh api user --jq .login -# 활성 계정 전환 -gh auth switch --user - -# 특정 계정의 토큰 확인 -gh auth token --user - -# credential helper 동작 테스트 -printf 'protocol=https\nhost=github.com\n\n' | git credential fill +# 현재 리포의 커밋 작성자 +git config --local --get user.name +git config --local --get user.email ``` +Copilot CLI 안에서는 `/user`로 구독 계정을 확인합니다. 토큰 자체를 출력하는 `gh auth token`이나 +`git credential fill`은 검증 목적으로 사용하지 마세요. + --- -## 주의 사항 +## 문제 해결 -1. **Copilot 인증은 별도 로그인 필요**: ``를 Copilot CLI에서 쓰려면 그 계정으로 - `/login`을 수행하거나 해당 계정의 `COPILOT_GITHUB_TOKEN`을 명시적으로 설정해야 합니다. -2. **토큰 갱신**: `gh auth refresh --user `로 만료된 토큰을 갱신할 수 있다. -3. **리포별 계정 분리가 필요한 경우**: 특정 리포에서 다른 계정을 쓰려면 로컬 `.git/config`에 별도 credential helper를 설정한다. -4. **SSH를 사용하는 경우**: 이 가이드는 HTTPS 기반이다. SSH를 쓸 때는 `~/.ssh/config`에 Host alias를 설정하는 방식을 사용한다. +| 증상 | 해결 | +|------|------| +| `gh`가 잘못된 계정으로 동작 | `gh auth status` 확인 후 `gh auth switch --hostname github.com --user ` | +| Git push가 다른 계정 권한으로 실패 | `gh auth switch` 후 `gh auth setup-git --hostname github.com` 재실행 | +| `gh auth switch` 결과가 환경변수와 다름 | 셸의 `GH_TOKEN`·`GITHUB_TOKEN` 제거 후 다시 확인 | +| Copilot이 Git 작업 계정으로 로그인됨 | Copilot CLI에서 `/logout` 후 `/login`, 이어서 `/user` 확인 | +| PAT가 거부됨 | Classic PAT 대신 `Copilot Requests` 권한이 있는 fine-grained PAT 사용 | +| 커밋이 잘못된 프로필에 연결됨 | 해당 리포의 `user.name`·`user.email`을 `--local`로 수정 | + +## 보안 주의 사항 + +1. `~/.config/gh/hosts.yml`을 직접 편집하거나 토큰을 복사하지 않습니다. +2. 기존 글로벌 credential helper를 빈 값으로 초기화하지 않습니다. +3. 토큰을 출력하는 커스텀 credential helper를 만들지 않습니다. +4. SSH로 여러 Git 계정을 동시에 고정해야 한다면 계정별 키와 `~/.ssh/config` Host alias를 사용합니다. diff --git a/requirements.txt b/requirements.txt index 91587a9..d4ccae9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,14 +7,14 @@ openai==2.38.0 # MCP (Model Context Protocol) mcp[cli]==1.27.2 -# Azure AI Search (Foundry IQ 지식 베이스 연동용) -azure-search-documents==11.7.0b2 +# Azure AI Search (Foundry IQ agentic retrieval의 확장 추론 지원) +azure-search-documents==12.1.0b1 # Microsoft Agent Framework -agent-framework==1.8.0 +agent-framework==1.11.0 # Foundry IQ agentic 검색 컨텍스트 프로바이더 (예제 06 변형) -agent-framework-azure-ai-search>=1.0.0b260521 +agent-framework-azure-ai-search==1.0.0b260709 # 유틸리티 python-dotenv==1.2.2 diff --git a/src/01_single_agent.py b/src/01_single_agent.py index 544d6be..0aa640c 100644 --- a/src/01_single_agent.py +++ b/src/01_single_agent.py @@ -28,14 +28,11 @@ async def main(): # ── 1단계: Foundry Chat 클라이언트 설정 ── # 환경 변수에서 프로젝트 엔드포인트와 모델 이름을 가져옵니다 project_endpoint = os.getenv("PROJECT_ENDPOINT") - model = os.getenv("MODEL_DEPLOYMENT_NAME") + model = os.getenv("MODEL_DEPLOYMENT_NAME") or "gpt-5.4" if not project_endpoint: print("오류: PROJECT_ENDPOINT 환경 변수를 설정해주세요.") sys.exit(1) - if not model: - print("오류: MODEL_DEPLOYMENT_NAME 환경 변수를 설정해주세요.") - sys.exit(1) # FoundryChatClient는 Microsoft Foundry 프로젝트에 연결합니다 try: diff --git a/src/02_sequential_workflow.py b/src/02_sequential_workflow.py index 69e9018..761b942 100644 --- a/src/02_sequential_workflow.py +++ b/src/02_sequential_workflow.py @@ -88,7 +88,8 @@ async def main(): # ── 3단계: 순차 워크플로우 구성 ── # SequentialBuilder가 참여자 순서대로 출력을 다음 단계로 전달합니다 workflow = SequentialBuilder( - participants=[analyzer_agent, writer_agent, editor_agent] + participants=[analyzer_agent, writer_agent, editor_agent], + intermediate_output_from=[analyzer_agent, writer_agent], ).build() # ── 4단계: 파이프라인 결과 스트리밍 출력 ── diff --git a/src/03_group_chat.py b/src/03_group_chat.py index e91c12e..a0b7717 100644 --- a/src/03_group_chat.py +++ b/src/03_group_chat.py @@ -108,10 +108,11 @@ def select_next_speaker(state: GroupChatState) -> str: participants=participants, selection_func=select_next_speaker, max_rounds=6, # 무한 토론 방지를 위한 최대 라운드 + intermediate_output_from=participants, ).build() # ── 5단계: 토론 결과 스트리밍 출력 ── - # stream=True로 각 참여자의 발언을 발화 순서대로 토큰 단위 실시간 출력합니다. + # 각 참여자의 완성된 발언을 발화 순서대로 출력합니다. print("\n[GroupChat 토론 결과]") await stream_workflow(workflow, topic) diff --git a/src/04_concurrent_workflow.py b/src/04_concurrent_workflow.py index cb18fb9..34f1f82 100644 --- a/src/04_concurrent_workflow.py +++ b/src/04_concurrent_workflow.py @@ -88,7 +88,8 @@ async def main(): # ── 3단계: 동시 워크플로우 구성 ── # ConcurrentBuilder가 모든 참여자에게 같은 입력을 병렬로 전달합니다 workflow = ConcurrentBuilder( - participants=[security_agent, performance_agent, ux_agent] + participants=[security_agent, performance_agent, ux_agent], + intermediate_output_from=[security_agent, performance_agent, ux_agent], ).build() # ── 4단계: 동시 리뷰 결과 스트리밍 출력 ── diff --git a/src/06_rag_agent.py b/src/06_rag_agent.py index d2bfba2..8960925 100644 --- a/src/06_rag_agent.py +++ b/src/06_rag_agent.py @@ -43,6 +43,8 @@ VectorSearchProfile, ) from azure.search.documents.models import VectorizedQuery + +from _indexing import wait_for_document_count_async from openai import AzureOpenAI from _streaming import stream_agent @@ -191,13 +193,8 @@ async def seed_documents(search_client: SearchClient, embed) -> None: raise RuntimeError(f"문서 업로드 실패: {[r.key for r in failed]}") # 인덱싱 반영 대기 (최대 30초). - # embed/merge_or_upload/get_document_count는 동기 Azure SDK 호출이며, - # sleep만 비동기화하여 이벤트 루프 블로킹을 최소화합니다. target = len(KNOWLEDGE_BASE) - for _ in range(30): - if search_client.get_document_count() >= target: - break - await asyncio.sleep(1) + await wait_for_document_count_async(search_client, target) print(f" → 문서 {target}건 임베딩·업로드 완료") @@ -216,7 +213,7 @@ def retrieve(search_client: SearchClient, embed, query: str, top_k: int = 2) -> query_vector = embed([query])[0] vector_query = VectorizedQuery( vector=query_vector, - k=max(5, top_k), # 하이브리드 융합용 후보 풀은 넉넉히 + k_nearest_neighbors=max(5, top_k), # 하이브리드 융합용 후보 풀은 넉넉히 fields="content_vector", ) @@ -300,7 +297,9 @@ async def main(): docs = retrieve(search_client, embed, question, top_k=2) print(" → 검색된 문서:") for doc in docs: - print(f" - {doc['title']} ({doc['id']}, score={doc['score']:.3f})") + score = doc["score"] + score_text = f"{score:.3f}" if isinstance(score, (int, float)) else "n/a" + print(f" - {doc['title']} ({doc['id']}, score={score_text})") context = build_context(docs) # ── 6단계: 증강(Augmentation) — 검색 결과를 프롬프트에 주입 ── diff --git a/src/06_rag_agent_foundry_iq.py b/src/06_rag_agent_foundry_iq.py index 3ee7ee3..6d61136 100644 --- a/src/06_rag_agent_foundry_iq.py +++ b/src/06_rag_agent_foundry_iq.py @@ -8,7 +8,7 @@ 핵심 차이(기존 06 대비): - 인덱스를 **기본 semantic 구성**과 함께 생성합니다(agentic retrieval 필수). - - 검색·증강을 직접 코딩하지 않고, 컨텍스트 프로바이더가 ``before_run`` 훅에서 + - 검색·증강을 직접 코딩하지 않고, 컨텍스트 프로바이더가 모델 호출 전에 멀티홉 검색을 수행하고 결과를 세션 컨텍스트에 주입합니다. - 프로바이더는 인덱스로부터 지식 소스/지식 베이스(``-kb``)를 자동 생성합니다. diff --git a/src/_indexing.py b/src/_indexing.py new file mode 100644 index 0000000..3bf1135 --- /dev/null +++ b/src/_indexing.py @@ -0,0 +1,48 @@ +"""Azure AI Search 인덱싱 완료 대기 공용 헬퍼.""" + +import asyncio +import time +from typing import Protocol + + +class SupportsDocumentCount(Protocol): + """문서 수 조회를 지원하는 검색 클라이언트 프로토콜.""" + + def get_document_count(self) -> int: + """현재 인덱스 문서 수를 반환합니다.""" + ... + + +async def wait_for_document_count_async( + search_client: SupportsDocumentCount, + target: int, + *, + timeout_seconds: float = 30, + poll_interval_seconds: float = 1, +) -> None: + """비동기로 목표 문서 수까지 기다립니다.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_seconds + + while search_client.get_document_count() < target: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError(f"{timeout_seconds:g}초 안에 문서 {target}건의 인덱싱이 완료되지 않았습니다.") + await asyncio.sleep(min(poll_interval_seconds, remaining)) + + +def wait_for_document_count( + search_client: SupportsDocumentCount, + target: int, + *, + timeout_seconds: float = 30, + poll_interval_seconds: float = 1, +) -> None: + """동기 방식으로 목표 문서 수까지 기다립니다.""" + deadline = time.monotonic() + timeout_seconds + + while search_client.get_document_count() < target: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"{timeout_seconds:g}초 안에 문서 {target}건의 인덱싱이 완료되지 않았습니다.") + time.sleep(min(poll_interval_seconds, remaining)) diff --git a/src/_rag_iq.py b/src/_rag_iq.py index e320863..ee41051 100644 --- a/src/_rag_iq.py +++ b/src/_rag_iq.py @@ -8,8 +8,8 @@ - 인덱스를 **기본 semantic 구성**과 함께 생성합니다(agentic retrieval 필수 요건). - 검색 단계는 ``agent_framework.azure.AzureAISearchContextProvider``(agentic 모드)가 담당합니다. 이 프로바이더는 인덱스로부터 지식 소스(``-source``)와 지식 - 베이스(``-kb``)를 자동 생성하고, 멀티홉 검색 결과를 에이전트 세션 - 컨텍스트에 주입(``before_run`` 훅)합니다. + 베이스(``-kb``)를 자동 생성하고, 모델 호출 전에 멀티홉 검색 결과를 + 에이전트 세션 컨텍스트에 주입합니다. 지식 베이스(Foundry IQ)·인덱스는 기존 하이브리드 예제와 충돌하지 않도록 **별도 인덱스 이름**(기본 ``maf-lab-knowledge-iq-v1``)을 사용합니다. @@ -22,8 +22,6 @@ """ import os -import time - from azure.core.credentials import TokenCredential from azure.identity import get_bearer_token_provider from azure.search.documents import SearchClient @@ -46,6 +44,8 @@ ) from openai import AzureOpenAI +from _indexing import wait_for_document_count + # agentic retrieval에 필요한 기본 semantic 구성 이름 SEMANTIC_CONFIG_NAME = "maf-lab-semantic" @@ -213,10 +213,7 @@ def seed_documents(search_client: SearchClient, embed) -> None: # 인덱싱 반영 대기 (최대 30초) target = len(KNOWLEDGE_BASE) - for _ in range(30): - if search_client.get_document_count() >= target: - break - time.sleep(1) + wait_for_document_count(search_client, target) print(f" → 문서 {target}건 임베딩·업로드 완료") diff --git a/src/_streaming.py b/src/_streaming.py index a59980e..5e0f794 100644 --- a/src/_streaming.py +++ b/src/_streaming.py @@ -1,12 +1,12 @@ """스트리밍 출력 공용 헬퍼. -에이전트·워크플로우 응답을 토큰(청크) 단위로 실시간 출력해, 답변이 생성되는 -과정을 콘솔에서 바로 확인할 수 있게 합니다. 모든 예제(01~06)가 공유합니다. +단일 에이전트는 토큰(청크) 단위로 출력하고, 워크플로우는 병렬 청크가 섞이지 +않도록 각 발화자의 완성 응답 단위로 출력합니다. 모든 예제(01~06)가 공유합니다. """ from typing import Any -from agent_framework import AgentExecutorResponse, AgentResponseUpdate +from agent_framework import AgentExecutorResponse, AgentResponse, AgentResponseUpdate async def stream_agent( @@ -39,9 +39,33 @@ async def stream_agent( return "".join(chunks) -def _is_orchestrator(speaker: str | None) -> bool: - """발화자 id가 내부 오케스트레이터(중계/종료용)인지 판별합니다.""" - return bool(speaker) and "orchestrator" in speaker +def _is_internal_executor(speaker: str | None) -> bool: + """발화자 id가 내부 중계·집계 executor인지 판별합니다.""" + if not speaker: + return False + normalized = speaker.lower() + return "orchestrator" in normalized or "aggregator" in normalized + + +def _response_blocks( + response: Any, + fallback_speaker: str | None, +) -> list[tuple[str | None, str]]: + """완성 응답을 발화자와 텍스트 블록 목록으로 변환합니다.""" + blocks: list[tuple[str | None, str]] = [] + messages = getattr(response, "messages", None) or [] + for message in messages: + text = (getattr(message, "text", "") or "").strip() + if not text: + continue + speaker = getattr(message, "author_name", None) or fallback_speaker + blocks.append((speaker, text)) + + if not blocks: + text = str(response).strip() + if text: + blocks.append((fallback_speaker, text)) + return blocks async def stream_workflow( @@ -50,16 +74,13 @@ async def stream_workflow( *, name_map: dict[str, str] | None = None, ) -> Any: - """워크플로우를 스트리밍 실행하며 발화자별 응답을 실시간 출력합니다. + """워크플로우를 스트리밍 실행하며 발화자별 완성 응답을 출력합니다. - 오케스트레이션 종류에 따라 이벤트 형태가 다릅니다. - - Handoff: 발화자별로 **토큰 단위 갱신**(AgentResponseUpdate)이 흘러옵니다. - → 토큰을 실시간으로 이어 붙여 출력합니다. - - GroupChat / Sequential / Concurrent: 참여자는 내부에서 실행되고 **완성된 응답** - (AgentExecutorResponse)만 이벤트로 노출됩니다. → 완성된 발언을 발화 순서대로 - 블록 출력합니다. - 두 경우 모두 내부 오케스트레이터의 중계·종료 메시지는 생략하고, 토큰으로 이미 - 출력한 발화자의 완성 이벤트는 중복 출력하지 않습니다. + 현재 SDK는 선택된 ``output``/``intermediate`` 소스의 토큰 갱신 + (``AgentResponseUpdate``)을 내보냅니다. Concurrent 기본 집계기처럼 완성된 + ``AgentResponse``가 한 번에 오는 경우와 이전 SDK의 ``AgentExecutorResponse``도 + 함께 처리합니다. 토큰 갱신은 발화자별로 모아 병렬 응답이 서로 섞이지 않게 하고, + 내부 오케스트레이터 메시지와 중복 집계 결과는 출력하지 않습니다. Args: workflow: 실행할 워크플로우. @@ -70,43 +91,68 @@ async def stream_workflow( 최종 ``WorkflowRunResult``. """ name_map = name_map or {} - current_speaker = None - streamed = set() # 토큰으로 이미 출력한 발화자 (완성 이벤트 중복 방지) - printed_blocks = set() # 블록으로 출력한 (발화자, 텍스트) 중복 방지 + pending_chunks: dict[str | None, list[str]] = {} + pending_order: list[str | None] = [] + printed_blocks: set[tuple[str | None, str]] = set() stream = workflow.run(message, stream=True) async for event in stream: data = getattr(event, "data", None) + event_type = getattr(event, "type", None) + event_executor = getattr(event, "executor_id", None) - # 1) 토큰 단위 스트리밍 (Handoff 등) + # 1) 병렬 실행에서도 섞이지 않도록 발화자별 토큰을 모읍니다. if isinstance(data, AgentResponseUpdate): - speaker = getattr(event, "executor_id", None) + speaker = getattr(data, "author_name", None) or event_executor text = getattr(data, "text", "") or "" - if not text or _is_orchestrator(speaker): + if not text or _is_internal_executor(speaker): continue - if speaker != current_speaker: - current_speaker = speaker - print(f"\n\n[{name_map.get(speaker, speaker)}]") - print(text, end="", flush=True) - streamed.add(speaker) + if speaker not in pending_chunks: + pending_chunks[speaker] = [] + pending_order.append(speaker) + pending_chunks[speaker].append(text) continue - # 2) 완성된 응답 (Sequential / GroupChat / Concurrent 참여자 등) + # 2) 완성된 응답 또는 Concurrent 집계 결과 + if event_type not in {"intermediate", "output", "executor_completed"}: + continue items = data if isinstance(data, list) else [data] for item in items: - if not isinstance(item, AgentExecutorResponse): - continue - speaker = getattr(item, "executor_id", getattr(event, "executor_id", None)) - if _is_orchestrator(speaker) or speaker in streamed: + is_executor_response = isinstance(item, AgentExecutorResponse) + if isinstance(item, AgentExecutorResponse): + fallback_speaker = getattr(item, "executor_id", event_executor) + response = item.agent_response + elif isinstance(item, AgentResponse): + fallback_speaker = event_executor + response = item + else: continue - text = str(item.agent_response).strip() - key = (speaker, text) - if not text or key in printed_blocks: - continue - printed_blocks.add(key) - current_speaker = None # 이후 토큰 스트림이 다시 머리말을 출력하도록 초기화 - print(f"\n\n[{name_map.get(speaker, speaker)}]\n{text}") + + for speaker, text in _response_blocks(response, fallback_speaker): + if _is_internal_executor(speaker): + continue + buffered = "".join(pending_chunks.get(speaker, [])) + if is_executor_response and event_type == "executor_completed" and not buffered: + continue + key = (speaker, text) + if not is_executor_response and key in printed_blocks: + pending_chunks.pop(speaker, None) + continue + printed_blocks.add(key) + pending_chunks.pop(speaker, None) + display_name = name_map.get(speaker, speaker) or "에이전트" + print(f"\n\n[{display_name}]\n{text}") + + # 완성 이벤트 없이 토큰 갱신만 온 사용자 정의 워크플로우도 누락하지 않습니다. + for speaker in pending_order: + text = "".join(pending_chunks.get(speaker, [])).strip() + if not text or _is_internal_executor(speaker): + continue + key = (speaker, text) + if key in printed_blocks: + continue + display_name = name_map.get(speaker, speaker) or "에이전트" + print(f"\n\n[{display_name}]\n{text}") print() return await stream.get_final_response() - diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..68672bf --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,35 @@ +"""모든 예제 모듈이 네트워크 호출 없이 임포트되는지 검증합니다.""" + +import importlib +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +class ExampleImportTests(unittest.TestCase): + """예제와 공통 헬퍼의 임포트 회귀를 검사합니다.""" + + def test_all_example_modules_import(self) -> None: + """모든 모듈의 SDK 임포트 경로가 유효해야 합니다.""" + modules = [ + "01_single_agent", + "02_sequential_workflow", + "03_group_chat", + "04_concurrent_workflow", + "05_mcp_agent", + "06_rag_agent", + "06_rag_agent_foundry_iq", + "_indexing", + "_rag_iq", + "_streaming", + ] + + for module in modules: + with self.subTest(module=module): + importlib.import_module(module) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_indexing.py b/tests/test_indexing.py new file mode 100644 index 0000000..1030f83 --- /dev/null +++ b/tests/test_indexing.py @@ -0,0 +1,67 @@ +"""인덱싱 완료 대기 경계 조건을 검증합니다.""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from _indexing import wait_for_document_count, wait_for_document_count_async + + +class SequenceCounter: + """호출 순서에 따라 문서 수를 반환합니다.""" + + def __init__(self, counts: list[int]) -> None: + self.counts = counts + self.calls = 0 + + def get_document_count(self) -> int: + """마지막 값은 이후 호출에도 유지합니다.""" + index = min(self.calls, len(self.counts) - 1) + self.calls += 1 + return self.counts[index] + + +class IndexingWaitTests(unittest.IsolatedAsyncioTestCase): + """마지막 대기 이후의 성공 여부를 재확인해야 합니다.""" + + async def test_async_wait_rechecks_after_sleep(self) -> None: + """비동기 대기는 sleep 이후 문서 수를 다시 확인해야 합니다.""" + counter = SequenceCounter([0, 4]) + + await wait_for_document_count_async( + counter, + 4, + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + self.assertEqual(counter.calls, 2) + + async def test_async_wait_times_out(self) -> None: + """제한시간 안에 목표에 못 미치면 명시적으로 실패해야 합니다.""" + counter = SequenceCounter([0]) + + with self.assertRaises(TimeoutError): + await wait_for_document_count_async(counter, 4, timeout_seconds=0) + + def test_sync_wait_rechecks_after_sleep(self) -> None: + """동기 대기는 sleep 이후 문서 수를 다시 확인해야 합니다.""" + counter = SequenceCounter([0, 4]) + + wait_for_document_count( + counter, + 4, + timeout_seconds=0.1, + poll_interval_seconds=0.001, + ) + + self.assertEqual(counter.calls, 2) + + def test_sync_wait_times_out(self) -> None: + """제한시간 안에 목표에 못 미치면 명시적으로 실패해야 합니다.""" + counter = SequenceCounter([0]) + + with self.assertRaises(TimeoutError): + wait_for_document_count(counter, 4, timeout_seconds=0) diff --git a/tests/test_rag_retrieval.py b/tests/test_rag_retrieval.py new file mode 100644 index 0000000..4ff5de2 --- /dev/null +++ b/tests/test_rag_retrieval.py @@ -0,0 +1,49 @@ +"""Azure AI Search 하이브리드 쿼리 구성을 검증합니다.""" + +import importlib +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +class SearchClientStub: + """검색 호출 인자를 기록하고 빈 결과를 반환합니다.""" + + def __init__(self) -> None: + self.search_kwargs: dict[str, object] | None = None + + def search(self, **kwargs): + """검색 인자를 저장합니다.""" + self.search_kwargs = kwargs + return [] + + +class RagRetrievalTests(unittest.TestCase): + """현재 Azure Search SDK에 맞는 벡터 쿼리를 검사합니다.""" + + def test_retrieve_uses_current_vector_query_parameter(self) -> None: + """벡터 후보 수는 k_nearest_neighbors로 전달해야 합니다.""" + module = importlib.import_module("06_rag_agent") + search_client = SearchClientStub() + + result = module.retrieve( + search_client, + lambda _: [[0.1, 0.2, 0.3]], + "요금제 질문", + top_k=2, + ) + + self.assertEqual(result, []) + search_kwargs = search_client.search_kwargs + self.assertIsNotNone(search_kwargs) + assert search_kwargs is not None + vector_query = search_kwargs["vector_queries"][0] + self.assertEqual(vector_query.k_nearest_neighbors, 5) + self.assertEqual(vector_query.fields, "content_vector") + self.assertEqual(search_kwargs["top"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_output.py b/tests/test_workflow_output.py new file mode 100644 index 0000000..11adf47 --- /dev/null +++ b/tests/test_workflow_output.py @@ -0,0 +1,172 @@ +"""워크플로우 예제가 모든 참여자 출력을 표시하는지 검증합니다.""" + +import asyncio +import importlib +import io +import os +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from agent_framework import Agent, ChatResponse, ChatResponseUpdate, ResponseStream +from agent_framework.orchestrations import ConcurrentBuilder + +from _streaming import stream_workflow + + +class FixedChatClient: + """외부 호출 없이 고정 응답을 반환하는 테스트용 채팅 클라이언트입니다.""" + + additional_properties: dict = {} + + def get_response(self, messages, *, stream=False, **kwargs): + """Agent Framework 채팅 클라이언트 프로토콜에 맞는 응답을 반환합니다.""" + + def make_update() -> ChatResponseUpdate: + return ChatResponseUpdate( + role="assistant", + contents=[{"type": "text", "text": "테스트 응답"}], + ) + + if stream: + + async def updates(): + yield make_update() + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + async def response(): + return ChatResponse.from_updates([make_update()]) + + return response() + + +class DelayedChatClient: + """청크 도착 순서를 교차시키는 테스트용 채팅 클라이언트입니다.""" + + additional_properties: dict = {} + + def __init__(self, label: str, delays: list[float]) -> None: + self.label = label + self.delays = delays + + def get_response(self, messages, *, stream=False, **kwargs): + """서로 다른 지연을 둔 세 개의 응답 청크를 반환합니다.""" + + def make_update(index: int) -> ChatResponseUpdate: + return ChatResponseUpdate( + role="assistant", + contents=[{"type": "text", "text": f"{self.label}{index} "}], + ) + + if stream: + + async def updates(): + for index, delay in enumerate(self.delays, start=1): + await asyncio.sleep(delay) + yield make_update(index) + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + async def response(): + return ChatResponse.from_updates([make_update(1)]) + + return response() + + +class WorkflowOutputTests(unittest.IsolatedAsyncioTestCase): + """순차·GroupChat·동시 예제의 콘솔 출력을 검사합니다.""" + + async def _run_example(self, module_name: str) -> str: + """Azure SDK 객체를 테스트 대역으로 교체하고 예제 main을 실행합니다.""" + module = importlib.import_module(module_name) + output = io.StringIO() + env = { + "PROJECT_ENDPOINT": "https://example.services.ai.azure.com/api/projects/lab", + "MODEL_DEPLOYMENT_NAME": "test-model", + } + + with ( + patch.dict(os.environ, env), + patch.object(module, "AzureCliCredential", return_value=object()), + patch.object(module, "FoundryChatClient", return_value=FixedChatClient()), + redirect_stdout(output), + ): + await module.main() + + return output.getvalue() + + async def test_sequential_example_prints_every_stage(self) -> None: + """순차 워크플로우가 분석가·작가·편집자를 모두 표시해야 합니다.""" + output = await self._run_example("02_sequential_workflow") + + for name in ("분석가", "작가", "편집자"): + self.assertEqual(output.count(f"[{name}]"), 1) + + async def test_group_chat_example_prints_every_participant(self) -> None: + """GroupChat이 기획자·개발자·디자이너 발언을 모두 표시해야 합니다.""" + output = await self._run_example("03_group_chat") + + for name in ("기획자", "개발자", "디자이너"): + self.assertGreaterEqual(output.count(f"[{name}]"), 1) + self.assertNotIn("[group_chat_orchestrator]", output) + + async def test_concurrent_example_prints_every_reviewer_once(self) -> None: + """동시 워크플로우가 집계 중복 없이 세 리뷰어를 표시해야 합니다.""" + output = await self._run_example("04_concurrent_workflow") + + for name in ("보안 리뷰어", "성능 리뷰어", "UX 리뷰어"): + self.assertEqual(output.count(f"[{name}]"), 1) + self.assertNotIn("[aggregator]", output) + + async def test_stream_workflow_handles_default_concurrent_aggregation(self) -> None: + """중간 출력을 지정하지 않아도 기본 집계 응답을 표시해야 합니다.""" + agents = [ + Agent(client=FixedChatClient(), name=name, instructions="한국어로 답변합니다.") + for name in ("보안", "성능", "UX") + ] + workflow = ConcurrentBuilder(participants=agents).build() + output = io.StringIO() + + with redirect_stdout(output): + await stream_workflow(workflow, "설계안을 검토해 주세요.") + + rendered = output.getvalue() + for name in ("보안", "성능", "UX"): + self.assertEqual(rendered.count(f"[{name}]"), 1) + + async def test_concurrent_multichunk_responses_do_not_interleave(self) -> None: + """병렬 다중 청크 응답을 발화자별 완성 응답으로 묶어야 합니다.""" + agents = [ + Agent( + client=DelayedChatClient(name, delays), + name=name, + instructions="한국어로 답변합니다.", + ) + for name, delays in ( + ("보안", [0.01, 0.06, 0.01]), + ("성능", [0.03, 0.01, 0.05]), + ("UX", [0.02, 0.03, 0.02]), + ) + ] + workflow = ConcurrentBuilder( + participants=agents, + intermediate_output_from=agents, + ).build() + output = io.StringIO() + + with redirect_stdout(output): + await stream_workflow(workflow, "설계안을 검토해 주세요.") + + rendered = output.getvalue() + for name in ("보안", "성능", "UX"): + self.assertEqual(rendered.count(f"[{name}]"), 1) + self.assertIn(f"[{name}]\n{name}1 {name}2 {name}3", rendered) + + +if __name__ == "__main__": + unittest.main()