diff --git a/backend-node/src/services/aiAgentGroupService.ts b/backend-node/src/services/aiAgentGroupService.ts index 81868540..7d1fd045 100644 --- a/backend-node/src/services/aiAgentGroupService.ts +++ b/backend-node/src/services/aiAgentGroupService.ts @@ -126,6 +126,8 @@ export class AiAgentGroupService { role_name?: string; connectors?: ConnectorRef[]; execution_order?: number; + config?: Record; + is_coordinator?: boolean; }): Promise { const sets: string[] = []; const params: any[] = []; @@ -134,6 +136,12 @@ export class AiAgentGroupService { if (data.role_name !== undefined) { sets.push(`role_name = $${idx++}`); params.push(data.role_name); } if (data.connectors !== undefined) { sets.push(`connectors = $${idx++}::jsonb`); params.push(JSON.stringify(data.connectors)); } if (data.execution_order !== undefined) { sets.push(`execution_order = $${idx++}`); params.push(data.execution_order); } + if (data.config !== undefined) { sets.push(`config = $${idx++}::jsonb`); params.push(JSON.stringify(data.config)); } + // is_coordinator 단축키 — 기존 config 와 머지 + if (data.is_coordinator !== undefined && data.config === undefined) { + sets.push(`config = COALESCE(config, '{}'::jsonb) || $${idx++}::jsonb`); + params.push(JSON.stringify({ is_coordinator: data.is_coordinator })); + } if (sets.length === 0) return null; params.push(memberId); diff --git a/backend-node/src/services/multiAgentExecutionEngine.ts b/backend-node/src/services/multiAgentExecutionEngine.ts index 6e7012fe..4d431c39 100644 --- a/backend-node/src/services/multiAgentExecutionEngine.ts +++ b/backend-node/src/services/multiAgentExecutionEngine.ts @@ -78,6 +78,8 @@ export class MultiAgentExecutionEngine { allResults = await this.executeParallel(group.members, enrichedMessage, ""); } else if (executionMode === "sequential") { allResults = await this.executeSequential(group.members, enrichedMessage); + } else if (executionMode === "coordinator") { + allResults = await this.executeCoordinator(group.members, enrichedMessage, userMessage); } else { allResults = await this.executeMixed(group.members, enrichedMessage); } @@ -187,6 +189,157 @@ export class MultiAgentExecutionEngine { return Promise.all(promises); } + /** + * Orchestrator-Worker 패턴 + * Round 1: coordinator 가 사용자 요청 분해 → JSON 라우팅 결정 + * Round 2: 결정된 sub-agent 들 병렬 실행 (각자 자신의 task 만) + * Round 3: coordinator 가 결과 합성 → 사용자 친화적 최종 답변 + * + * Coordinator 식별 우선순위: + * - member.config.is_coordinator === true + * - 없으면 execution_order 가 가장 작은 멤버 + */ + private static async executeCoordinator( + members: GroupMember[], + userMessage: string, + originalUserMessage: string + ): Promise { + if (!members || members.length === 0) return []; + + // 1) coordinator 식별 + const coord = + members.find((m: any) => m.config?.is_coordinator === true) || + members.slice().sort((a: any, b: any) => (a.execution_order ?? 999) - (b.execution_order ?? 999))[0]; + const workers = members.filter((m: any) => m !== coord); + + if (workers.length === 0) { + // worker 없으면 coordinator 단독 실행 + return [await this.executeSingleAgent(coord, userMessage, "")]; + } + + // 2) Round 1 — 라우팅 계획 생성 + // coordinator 에게 "사용 가능 에이전트 목록" + 원문 → JSON 출력 요구 + const workerCatalog = workers + .map((m: GroupMember) => { + const conns = (m.connectors || []).map((c: any) => `${c.name}(${c.type})`).join(", ") || "없음"; + const desc = (m as any).description || m.agent_name || m.role_name || ""; + return `- role: "${m.role_name}"\n 설명: ${desc}\n 데이터 소스: ${conns}`; + }) + .join("\n"); + + const planSystemPrompt = + `당신은 멀티에이전트 코디네이터입니다. 사용자 요청을 분석하고 적합한 sub-agent 에게 작업을 분배합니다.\n\n` + + `[사용 가능한 sub-agent 들]\n${workerCatalog}\n\n` + + `[작업 분배 규칙]\n` + + `1. 사용자 요청을 의도별로 분해하라. 예: "환율과 날씨" → 환율 부분 + 날씨 부분.\n` + + `2. 각 sub-agent 의 역할/데이터소스에 맞는 부분만 정확히 그 에이전트에게 분배한다.\n` + + `3. 어떤 sub-agent 도 처리할 수 없는 부분은 분배하지 않고 빈 배열로 답한다.\n` + + `4. 한 sub-agent 에게는 자기 역할에 해당하는 부분만 짧고 명확하게 전달한다 (사용자 원문을 그대로 넘기지 말 것).\n` + + `5. 출력은 반드시 다음 JSON 스키마 한 가지로만, 다른 설명 없이 출력한다:\n` + + `{\n "delegations": [\n { "role": "", "task": "<해당 에이전트가 처리할 짧은 지시문>" }\n ]\n}\n` + + `6. delegations 가 비어 있으면 코디네이터 본인이 직접 답한다.\n`; + + const planResp = await LlmClient.chatCompletion({ + model: coord.agent_model || (coord as any).agent?.model || (coord as any).model || "claude-sonnet-4-20250514", + messages: [ + { role: "system", content: planSystemPrompt }, + { role: "user", content: `사용자 원문 메시지:\n${originalUserMessage}` }, + ], + max_tokens: 2048, + temperature: 0.2, + } as any); + const planText = planResp.choices?.[0]?.message?.content || ""; + const planTokens = planResp.usage?.total_tokens || 0; + + // JSON 파싱 (코드블록/잡설 제거) + let delegations: Array<{ role: string; task: string }> = []; + try { + const cleaned = planText + .replace(/```json\s*/gi, "") + .replace(/```\s*$/g, "") + .replace(/^[^{]*({[\s\S]*})[^}]*$/, "$1"); + const parsed = JSON.parse(cleaned); + delegations = Array.isArray(parsed?.delegations) ? parsed.delegations : []; + } catch (err) { + logger.warn(`[Coordinator] 라우팅 JSON 파싱 실패. 모든 worker 에 원문 전달로 fallback. raw=${planText.substring(0, 200)}`); + delegations = workers.map((m: any) => ({ role: m.role_name, task: originalUserMessage })); + } + + const planResult: ExecutionResult = { + memberId: coord.id, + roleName: coord.role_name, + agentName: coord.agent_name || coord.role_name, + modelName: coord.agent_model || "", + executionOrder: 0, + response: + `[코디네이터 - 작업 분배 계획]\n` + + (delegations.length === 0 + ? "(분배할 sub-agent 없음 — 코디네이터 단독 응답)" + : delegations.map((d, i) => ` ${i + 1}. ${d.role} ← "${d.task}"`).join("\n")), + tokensUsed: planTokens, + durationMs: 0, + }; + + // 3) Round 2 — sub-agent 병렬 실행 (역할명 일치하는 것만) + const workerResults: ExecutionResult[] = []; + if (delegations.length > 0) { + const tasks = delegations + .map((d) => { + const target = workers.find((w: any) => w.role_name === d.role); + if (!target) return null; + return this.executeSingleAgent(target, d.task, ""); + }) + .filter((p) => p !== null) as Promise[]; + const settled = await Promise.all(tasks); + workerResults.push(...settled); + } + + // 4) Round 3 — coordinator 가 결과 합성 + const synthesisSystemPrompt = + `당신은 멀티에이전트 코디네이터입니다. sub-agent 들이 분담해서 만든 답변을 모아 사용자에게 일관된 최종 답변을 작성합니다.\n` + + `[작성 규칙]\n` + + `1. sub-agent 답변에 있는 사실/수치는 그대로 인용하고 추측을 더하지 않는다.\n` + + `2. 사용자가 물어본 모든 부분에 대해 답을 정리한다 (못 받은 부분은 "데이터 소스 미연결" 등으로 명시).\n` + + `3. 마크다운 사용 가능, 핵심 수치는 굵게 또는 표로 정리.\n` + + `4. sub-agent 의 신원(역할명) 을 굳이 노출할 필요는 없으나 자료 출처(예: rates.JPY) 는 유지한다.\n` + + `5. 인사말/잡설 없이 곧바로 답변 본문부터 시작한다.`; + + const collected = workerResults.length > 0 + ? workerResults + .map((r) => `--- [${r.roleName}] ---\n${r.response}`) + .join("\n\n") + : "(sub-agent 응답 없음)"; + const synthResp = await LlmClient.chatCompletion({ + model: coord.agent_model || (coord as any).agent?.model || (coord as any).model || "claude-sonnet-4-20250514", + messages: [ + { role: "system", content: synthesisSystemPrompt }, + { + role: "user", + content: + `사용자 원문 질문:\n${originalUserMessage}\n\n` + + `sub-agent 들의 분담 답변:\n${collected}\n\n` + + `위를 종합해 사용자 친화적 최종 답변을 작성하라.`, + }, + ], + temperature: 0.5, + } as any); + const synthText = synthResp.choices?.[0]?.message?.content || ""; + const synthTokens = synthResp.usage?.total_tokens || 0; + + const finalResult: ExecutionResult = { + memberId: coord.id, + roleName: `${coord.role_name} (최종)`, + agentName: coord.agent_name || coord.role_name, + modelName: coord.agent_model || "", + executionOrder: 999, + response: synthText, + tokensUsed: synthTokens, + durationMs: 0, + }; + + return [planResult, ...workerResults, finalResult]; + } + /** * 혼합 실행: execution_order 같으면 병렬, 다르면 순차 */ diff --git a/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx b/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx index 4f9cfb68..256585ec 100644 --- a/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx @@ -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(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 (
@@ -628,12 +642,30 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem {member.role_name} {member.agent_name || "에이전트"} + {isCoord && ( + + 코디네이터 + + )}

{member.agent_model}

- +
+ + +
{/* 연결된 커넥터 */}