feat: Planner 오케스트레이션 루프 구현
이중 루프 구조: - 내부 루프 (Planner 자가검증, 최대 3회): 계획 → 코딩 → Planner가 결과 검증 → 미달 시 추가 태스크 → 반복 Planner가 만족할 때까지 자체적으로 보완 작업 진행 - 외부 루프 (Reviewer, 최대 2회): Planner 만족 → Reviewer 검토 → 반려 시 피드백으로 재계획 새 메서드: - planner_verify(): Planner가 계획 달성도 자가 검증 - _read_recent_files(): 프로젝트 파일 읽기 공용 헬퍼
This commit is contained in:
@@ -352,74 +352,105 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2. Code + Review (실패 시 재계획 루프)
|
# 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
|
||||||
|
MAX_PLANNER_LOOPS = 3 # Planner 내부 자가검증 반복 제한
|
||||||
review = None
|
review = None
|
||||||
code_outputs = []
|
all_code_outputs = []
|
||||||
for attempt in range(1 + MAX_REVIEW_RETRIES):
|
|
||||||
attempt_label = f" (재시도 {attempt})" if attempt > 0 else ""
|
|
||||||
|
|
||||||
# 재시도 시: 리뷰 피드백으로 재계획
|
for review_attempt in range(1 + MAX_REVIEW_RETRIES):
|
||||||
if attempt > 0:
|
review_label = f" (리뷰 재시도 {review_attempt})" if review_attempt > 0 else ""
|
||||||
|
|
||||||
|
# 리뷰어 반려 시: 피드백으로 재계획
|
||||||
|
if review_attempt > 0:
|
||||||
feedback = review.get("summary", str(review))
|
feedback = review.get("summary", str(review))
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
embed=discord.Embed(
|
embed=discord.Embed(
|
||||||
title=f"🔄 재계획 {attempt}/{MAX_REVIEW_RETRIES}",
|
title=f"🔄 리뷰어 피드백 반영 재계획",
|
||||||
description="리뷰 피드백을 바탕으로 다시 계획합니다.",
|
description=feedback[:500],
|
||||||
color=0xF39C12,
|
color=0xE74C3C,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
replan_request = (
|
replan_request = (
|
||||||
f"## 원래 요청\n{text}\n\n"
|
f"## 원래 요청\n{text}\n\n"
|
||||||
f"## 이전 리뷰 피드백 (반드시 반영)\n{feedback}\n\n"
|
f"## 리뷰어 피드백 (반드시 반영)\n{feedback}\n\n"
|
||||||
f"이전 시도가 실패했습니다. 피드백을 분석하고 태스크를 재설계하세요."
|
f"피드백을 분석하고 태스크를 재설계하세요."
|
||||||
)
|
)
|
||||||
plan = await pipeline.plan(replan_request)
|
plan = await pipeline.plan(replan_request)
|
||||||
tasks = plan.get("tasks", [])
|
tasks = plan.get("tasks", [])
|
||||||
|
if not tasks:
|
||||||
|
break
|
||||||
|
|
||||||
if tasks:
|
# ── Planner 내부 루프: 계획 → 코딩 → 자가검증 → 추가작업 ──
|
||||||
task_list = "\n".join(
|
for planner_round in range(MAX_PLANNER_LOOPS):
|
||||||
f"• {t.get('title', '?')}" for t in tasks[:10]
|
round_label = f" (보완 {planner_round})" if planner_round > 0 else ""
|
||||||
)
|
|
||||||
|
# 코딩
|
||||||
|
code_embed = discord.Embed(
|
||||||
|
title=f"⚙️ 코딩 중...{review_label}{round_label} ({len(tasks)}개)",
|
||||||
|
description="\n".join(
|
||||||
|
f"• {t.get('title', '?')[:60]}" for t in tasks[:10]
|
||||||
|
),
|
||||||
|
color=0xE67E22,
|
||||||
|
)
|
||||||
|
code_msg = await message.channel.send(embed=code_embed)
|
||||||
|
|
||||||
|
code_outputs = await pipeline.code_parallel(tasks)
|
||||||
|
all_code_outputs = code_outputs # 최신 결과 유지
|
||||||
|
|
||||||
|
error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]"))
|
||||||
|
code_embed.title = f"✅ 코딩 완료{round_label} ({len(tasks) - error_count}/{len(tasks)})"
|
||||||
|
code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12
|
||||||
|
await code_msg.edit(embed=code_embed)
|
||||||
|
|
||||||
|
# Planner 자가검증
|
||||||
|
verify_embed = discord.Embed(
|
||||||
|
title="🔍 Planner 자가 검증 중...",
|
||||||
|
color=0xF39C12,
|
||||||
|
)
|
||||||
|
verify_msg = await message.channel.send(embed=verify_embed)
|
||||||
|
|
||||||
|
verification = await pipeline.planner_verify(text, plan, code_outputs)
|
||||||
|
|
||||||
|
satisfied = verification.get("satisfied", True)
|
||||||
|
if isinstance(satisfied, str):
|
||||||
|
satisfied = satisfied.lower() in ("true", "yes")
|
||||||
|
|
||||||
|
verify_embed.title = f"{'✅' if satisfied else '🔄'} Planner 검증{round_label}"
|
||||||
|
verify_embed.description = verification.get("feedback", "")[:500]
|
||||||
|
verify_embed.color = 0x2ECC71 if satisfied else 0xF39C12
|
||||||
|
await verify_msg.edit(embed=verify_embed)
|
||||||
|
|
||||||
|
if satisfied:
|
||||||
|
break # Planner 만족 → 리뷰어에게 전달
|
||||||
|
|
||||||
|
# 추가 태스크가 있으면 계속
|
||||||
|
additional = verification.get("additional_tasks", [])
|
||||||
|
if additional:
|
||||||
|
tasks = additional
|
||||||
|
task_list = "\n".join(f"• {t.get('title', '?')}" for t in tasks[:10])
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
embed=discord.Embed(
|
embed=discord.Embed(
|
||||||
title=f"📝 재계획 결과",
|
title=f"📝 추가 작업 {len(additional)}개",
|
||||||
description=f"태스크 {len(tasks)}개",
|
color=0xF39C12,
|
||||||
color=0x2ECC71,
|
|
||||||
).add_field(name="태스크", value=task_list[:1000], inline=False)
|
).add_field(name="태스크", value=task_list[:1000], inline=False)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
break # 추가 태스크 없으면 종료
|
||||||
|
|
||||||
# 코딩 진행 표시
|
# ── 외부 리뷰어 ──
|
||||||
code_embed = discord.Embed(
|
|
||||||
title=f"⚙️ 코딩 중...{attempt_label} ({len(tasks)}개 에이전트)",
|
|
||||||
description="\n".join(
|
|
||||||
f"• {t.get('title', '?')[:60]}" for t in tasks[:10]
|
|
||||||
),
|
|
||||||
color=0xE67E22,
|
|
||||||
)
|
|
||||||
code_msg = await message.channel.send(embed=code_embed)
|
|
||||||
|
|
||||||
code_outputs = await pipeline.code_parallel(tasks)
|
|
||||||
|
|
||||||
error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]"))
|
|
||||||
code_embed.title = f"✅ 코딩 완료{attempt_label} ({len(tasks) - error_count}/{len(tasks)} 성공)"
|
|
||||||
code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12
|
|
||||||
await code_msg.edit(embed=code_embed)
|
|
||||||
|
|
||||||
# 리뷰
|
|
||||||
review_embed = discord.Embed(
|
review_embed = discord.Embed(
|
||||||
title="🔍 리뷰 중...",
|
title="🔍 리뷰어 검토 중...",
|
||||||
color=0xF39C12,
|
color=0xF39C12,
|
||||||
)
|
)
|
||||||
review_msg = await message.channel.send(embed=review_embed)
|
review_msg = await message.channel.send(embed=review_embed)
|
||||||
|
|
||||||
review = await pipeline.batch_review(tasks, code_outputs)
|
review = await pipeline.batch_review(tasks, all_code_outputs)
|
||||||
|
|
||||||
passed = review.get("passed", True)
|
passed = review.get("passed", True)
|
||||||
if isinstance(passed, str):
|
if isinstance(passed, str):
|
||||||
passed = passed.lower() in ("true", "yes", "pass")
|
passed = passed.lower() in ("true", "yes", "pass")
|
||||||
|
|
||||||
review_embed.title = f"{'✅' if passed else '⚠️'} 리뷰 결과{attempt_label}"
|
review_embed.title = f"{'✅' if passed else '⚠️'} 리뷰어 결과{review_label}"
|
||||||
review_embed.description = review.get("summary", str(review))[:500]
|
review_embed.description = review.get("summary", str(review))[:500]
|
||||||
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
|
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
|
||||||
await review_msg.edit(embed=review_embed)
|
await review_msg.edit(embed=review_embed)
|
||||||
@@ -428,7 +459,7 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
|||||||
break
|
break
|
||||||
|
|
||||||
# 3. 총평
|
# 3. 총평
|
||||||
summary = await pipeline.summarize(text, plan, code_outputs, review)
|
summary = await pipeline.summarize(text, plan, all_code_outputs, review)
|
||||||
|
|
||||||
summary_embed = discord.Embed(
|
summary_embed = discord.Embed(
|
||||||
title=f"📊 {summary.get('title', '작업 완료')}",
|
title=f"📊 {summary.get('title', '작업 완료')}",
|
||||||
|
|||||||
@@ -114,31 +114,16 @@ class TaskPipeline:
|
|||||||
return processed
|
return processed
|
||||||
|
|
||||||
# ──────────────────────────────────────────
|
# ──────────────────────────────────────────
|
||||||
# Batch Review
|
# 프로젝트 파일 읽기 (공용)
|
||||||
# ──────────────────────────────────────────
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict:
|
def _read_recent_files(self, cutoff_seconds: int = 600) -> list[str]:
|
||||||
"""에이전트가 생성/수정한 실제 파일을 리뷰.
|
"""최근 변경된 프로젝트 파일 읽기."""
|
||||||
|
|
||||||
code_outputs(에이전트 보고)와 함께 실제 프로젝트 파일 내용을 읽어서 리뷰합니다.
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
# 태스크 요약
|
|
||||||
task_summaries = []
|
|
||||||
for i, task in enumerate(tasks):
|
|
||||||
title = task.get("title", task.get("description", f"Task {i+1}"))
|
|
||||||
task_summaries.append(f"### Task {i+1}: {title}")
|
|
||||||
|
|
||||||
# 에이전트 보고 요약
|
|
||||||
agent_reports = []
|
|
||||||
for i, output in enumerate(code_outputs):
|
|
||||||
agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}")
|
|
||||||
|
|
||||||
# 최근 변경된 파일 읽기 (10분 이내)
|
|
||||||
recent_files = []
|
recent_files = []
|
||||||
cutoff = _time.time() - 600
|
cutoff = _time.time() - cutoff_seconds
|
||||||
project_root = Path(self.project_path)
|
project_root = Path(self.project_path)
|
||||||
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
||||||
|
|
||||||
@@ -156,11 +141,84 @@ class TaskPipeline:
|
|||||||
except (OSError, UnicodeDecodeError):
|
except (OSError, UnicodeDecodeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
return recent_files
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# Planner 자가 검증 (오케스트레이션)
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def planner_verify(
|
||||||
|
self, user_request: str, plan: dict,
|
||||||
|
code_outputs: list[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Planner가 자기 계획의 달성 여부를 검증.
|
||||||
|
|
||||||
|
실제 파일을 읽어서 계획이 충족됐는지 판단합니다.
|
||||||
|
미달이면 추가 태스크를 생성합니다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"satisfied": bool,
|
||||||
|
"feedback": "미충족 사유",
|
||||||
|
"additional_tasks": [...] (satisfied=false일 때)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
recent_files = self._read_recent_files()
|
||||||
|
files_section = "\n\n".join(recent_files) if recent_files else "(파일 없음)"
|
||||||
|
|
||||||
|
agent_reports = "\n".join(
|
||||||
|
f"--- Agent {i+1} ---\n{output}"
|
||||||
|
for i, output in enumerate(code_outputs)
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"## 원래 사용자 요청\n{user_request}\n\n"
|
||||||
|
f"## 내가 세운 계획\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n\n"
|
||||||
|
f"## 에이전트 보고\n{agent_reports}\n\n"
|
||||||
|
f"## 현재 프로젝트 파일\n{files_section}\n\n"
|
||||||
|
f"## 판단 요청\n"
|
||||||
|
f"위 계획이 충족되었는지 판단하세요.\n"
|
||||||
|
f"충족되었으면 satisfied=true.\n"
|
||||||
|
f"미충족이면 satisfied=false + 부족한 부분을 해결할 추가 태스크를 생성하세요.\n\n"
|
||||||
|
f"JSON 형식:\n"
|
||||||
|
f"```json\n"
|
||||||
|
f'{{\n'
|
||||||
|
f' "satisfied": true|false,\n'
|
||||||
|
f' "feedback": "판단 근거 (한국어)",\n'
|
||||||
|
f' "additional_tasks": [\n'
|
||||||
|
f' {{"id": 1, "title": "추가 태스크", "description": "구현 내용", "type": "modify"}}\n'
|
||||||
|
f' ]\n'
|
||||||
|
f'}}\n'
|
||||||
|
f"```"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self.gemini.call("planner", prompt, timeout=180)
|
||||||
|
self._log("planner_verify", user_request, response)
|
||||||
|
|
||||||
|
result = self._extract_json(response)
|
||||||
|
return result or {"satisfied": True, "feedback": response}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
# Batch Review
|
||||||
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict:
|
||||||
|
"""에이전트가 생성/수정한 실제 파일을 리뷰."""
|
||||||
|
task_summaries = []
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
title = task.get("title", task.get("description", f"Task {i+1}"))
|
||||||
|
task_summaries.append(f"### Task {i+1}: {title}")
|
||||||
|
|
||||||
|
agent_reports = []
|
||||||
|
for i, output in enumerate(code_outputs):
|
||||||
|
agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}")
|
||||||
|
|
||||||
|
recent_files = self._read_recent_files()
|
||||||
|
|
||||||
if not recent_files:
|
if not recent_files:
|
||||||
# 변경 파일 없음 → 자동 통과 (삭제 작업 등)
|
|
||||||
return {
|
return {
|
||||||
"passed": True,
|
"passed": True,
|
||||||
"summary": "파일 변경 없음 또는 삭제 작업 — 자동 통과",
|
"summary": "파일 변경 없음 또는 삭제 작업 - 자동 통과",
|
||||||
"issues": [],
|
"issues": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user