feat(ai-orch): Coordinator (Orchestrator-Worker) 패턴 구현
Build and Push Images / build-and-push (push) Has been cancelled

진짜 멀티 에이전트 오케스트레이션 — 라우터 + 워커 + 합성 3단계

Engine (multiAgentExecutionEngine.ts)
- execution_mode 'coordinator' 신규 추가
- executeCoordinator() 3 라운드 구현:
  Round 1: coordinator 가 사용자 요청 분해 → JSON delegations 출력
           system prompt 에 sub-agent 카탈로그(role/desc/connectors) 자동 inject
  Round 2: delegated sub-agent 들 각자 짧은 task 만 받아 병렬 실행
  Round 3: coordinator 가 모든 sub-agent 응답을 받아 사용자 친화적 최종 답변 합성
- coordinator 식별: member.config.is_coordinator === true 우선,
                    없으면 execution_order 가 가장 작은 멤버
- JSON 파싱 실패 시 모든 worker 에 원문 fallback

Service (aiAgentGroupService.ts)
- updateMember 에 config / is_coordinator 옵션 추가
  is_coordinator 단축키는 기존 config jsonb 와 || 머지

UI (workspace/page.tsx)
- EXEC_MODES 에 'coordinator' (Compass 아이콘) 추가
- MemberCardContent 에 코디네이터 토글 버튼 (왕관 아이콘) — 클릭 즉시
  updateGroupMember(is_coordinator) 호출
- 활성 시 분홍 배지 "코디네이터" 표시

검증 결과 (PLM 그룹 / 환율전문가=coordinator)
- 사용자: "환율과 화성시 날씨 알려줘"
  Round 1: 기상전문가 ← "화성시의 날씨 정보를 알려줘"
  Round 2: 기상전문가 → API 호출 → HTML 응답 → 정직하게 "데이터 못받음"
  Round 3: coordinator 가 환율/날씨 모두 정리한 단일 답변 합성

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 20:19:01 +09:00
parent 9891c06038
commit e2845508fa
3 changed files with 197 additions and 4 deletions
@@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check } from "lucide-react";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check, Crown, Compass } from "lucide-react";
import { toast } from "sonner";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
@@ -20,6 +20,7 @@ const EXEC_MODES = [
{ value: "parallel", label: "병렬", desc: "모두 동시 실행", icon: Zap, color: "text-amber-500" },
{ value: "sequential", label: "순차", desc: "1→2→3 순서대로", icon: ListOrdered, color: "text-blue-500" },
{ value: "mixed", label: "혼합", desc: "같은 순서=병렬", icon: Shuffle, color: "text-violet-500" },
{ value: "coordinator", label: "코디네이터", desc: "리더가 분배·합성", icon: Compass, color: "text-rose-500" },
];
export default function WorkspacePage() {
@@ -614,12 +615,25 @@ const CONNECTOR_TYPE_OPTIONS = [
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const [isCoord, setIsCoord] = useState(!!member.config?.is_coordinator);
const memberConnectors = member.connectors || [];
const filteredConnectors = selectedType
? connectors.filter((c) => c.type === selectedType)
: [];
const toggleCoordinator = async () => {
const next = !isCoord;
setIsCoord(next);
try {
await aiAgentApi.updateGroupMember(member.id, { is_coordinator: next });
toast.success(next ? "코디네이터로 지정" : "코디네이터 해제");
} catch (e: any) {
setIsCoord(!next);
toast.error(e?.response?.data?.message || "코디네이터 토글 실패");
}
};
return (
<div>
<div className="flex items-start justify-between">
@@ -628,12 +642,30 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold">{member.role_name}</span>
<Badge variant="outline" className="h-4 text-[9px]">{member.agent_name || "에이전트"}</Badge>
{isCoord && (
<Badge className="h-4 gap-0.5 bg-rose-500/15 px-1 text-[9px] text-rose-700 hover:bg-rose-500/20">
<Crown className="h-2.5 w-2.5" />
</Badge>
)}
</div>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono">{member.agent_model}</p>
</div>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
<div className="flex items-center gap-1">
<button
onClick={toggleCoordinator}
className={`flex h-5 px-1.5 items-center justify-center rounded text-[9px] gap-0.5 transition-colors ${
isCoord
? "bg-rose-500/20 text-rose-700 hover:bg-rose-500/30"
: "border border-dashed text-muted-foreground hover:border-rose-400 hover:text-rose-500"
}`}
title="이 에이전트를 그룹 코디네이터로 지정 (요청 분배·결과 합성 담당)"
>
<Crown className="h-2.5 w-2.5" />
</button>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
{/* 연결된 커넥터 */}