import { listActiveAgentsByCategory, resolveAgent } from './companyConfig'; import type { AgentRoleCategory, AgentTurnOutput, CompanyState, PipelineStage, } from './types'; /** * `dispatcher.ts` 의 6개 stateless helper 모음. dispatcher 본 흐름에서 떼어내 * (a) 단위 테스트 가능, (b) parsing 정책 변경 시 한 곳만 수정. * * - resolveInspector(reviewWith, state) — `inspector` / `role:` / `agent:` 라우팅 * - parseInspectorVerdict(text) — pass / revise / unclear * - parseCeoVerdict(text) — pass / revise / abort / unclear * - renderStageInstruction(stage, ...) — instruction 템플릿 토큰 치환 * - hasActionTag(text) — action-tag 존재 cheap pre-check * - claimsFileCreation(text) — past-tense 파일 생성 narration 검출 */ /** * 검수자 (또는 직군) 를 stage.reviewWith 값에 따라 한 명 결정: * - 'inspector' → inspector 직군 활성 후보 중 첫 번째 * - 'role:' → 해당 직군 활성 후보 중 첫 번째 * - 'agent:' → 그 에이전트 (활성/비활성 무관) * 후보 없으면 null — 호출자가 검수 사이클 skip. */ export function resolveInspector(reviewWith: string, state: CompanyState): { agentId: string } | null { if (reviewWith === 'inspector') { const list = listActiveAgentsByCategory(state)['inspector'] ?? []; return list[0] ? { agentId: list[0].id } : null; } if (reviewWith.startsWith('role:')) { const cat = reviewWith.slice(5) as AgentRoleCategory; const list = listActiveAgentsByCategory(state)[cat] ?? []; return list[0] ? { agentId: list[0].id } : null; } if (reviewWith.startsWith('agent:')) { const id = reviewWith.slice(6); return resolveAgent(state, id) ? { agentId: id } : null; } return null; } /** * 검수자 응답의 verdict 추출. 작은 모델이 라벨 흐트러뜨릴 수 있어 키워드 매칭으로 * 관대하게. 못 잡으면 'unclear' — 호출자가 안전한 쪽 (보통 'revise') 으로 폴백. */ export function parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' { const head = (text || '').split(/\n/, 1)[0] ?? ''; if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass'; if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise'; // 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우. if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass'; if (/❌|보완 필요|재작업/.test(text)) return 'revise'; return 'unclear'; } /** * CEO 메타-판단 verdict. pass / revise / abort / unclear. 검수자 verdict 와 같은 * 키워드-우선 휴리스틱이지만 abort 옵션이 추가됨 (의도적으로 중단). */ export function parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' { const head = (text || '').split(/\n/, 1)[0] ?? ''; if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass'; if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise'; if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort'; if (/✅\s*통과/.test(text)) return 'pass'; if (/🛑|중단/.test(text)) return 'abort'; if (/🔁|보완|한 번 더/.test(text)) return 'revise'; return 'unclear'; } /** * Stage instruction 의 템플릿 토큰 치환. 템플릿 비어 있으면 raw user prompt 그대로 * 폴백 — 사용자가 매 stage 마다 긴 템플릿을 채울 필요 없게. * * 지원 토큰: * - {{userPrompt}} — 원본 사용자 prompt * - {{brief}} — 플래너가 생성한 brief * - {{stage.}} — 다른 stage 의 가장 최근 response (미실행 시 placeholder) */ export function renderStageInstruction( stage: PipelineStage, userPrompt: string, brief: string, latestByStage: Record, ): string { const tpl = (stage.instructionTemplate || '').trim(); if (!tpl) return userPrompt; return tpl .replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt) .replace(/\{\{\s*brief\s*\}\}/g, brief) .replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => { const o = latestByStage[sid]; return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`; }); } /** * Cheap pre-check — text 에 어떤 action-tag 라도 있으면 true. action-tag executor * 를 매 specialist 응답마다 띄우지 않게 가드. */ export function hasActionTag(text: string): boolean { return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text); } /** * Heuristic: response 가 *narrate* "파일 생성했음" 했는지 (action-tag 없이). * * 과거형 / 완료 표현 + 파일/폴더 mention 둘 다 있어야 true. plan ("다음에 X 를 만들어야") * 같은 미래형은 의도적으로 안 잡음. agent 가 `` 태그 안 쓰고 "foo.py * 파일을 생성했습니다" 환각하는 패턴 검출 → dispatcher 가 CEO 와 사용자에게 flag. */ export function claimsFileCreation(text: string): boolean { const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i; if (!claimRe.test(text)) return false; const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text); const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text); return fileLike || folderLike; }