From c58bc311a6be89613b9bd57cc26a4b279a7f3ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=9E=91=EC=9D=B4?= Date: Sat, 11 Apr 2026 16:24:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(notify):=20junior=20=EA=B0=80=20LLM=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=A7=81=EC=A0=91=20discord=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=EB=A9=98=ED=8A=B8=20emit=20(=EC=98=B5?= =?UTF-8?q?=EC=85=98=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자기야 결정: 옵션 C — junior 가 자기 work 끝에 ```discord-line``` 블록을 출력하고, sister-agent 가 그걸 추출해서 stage-end Discord notify 메시지로 사용. 추가 LLM 호출 0, 매번 다른 메시지, 자매 본인이 자기가 한 일을 직접 보고하는 느낌. ## 변경 ### prompts.ts - buildPrompt 의 footer 에 role==='junior' 분기 추가 - 새 helper discordLineFooter(ctx): 자매별 페르소나 + stage 별 예시 포함 - 출력 형식 강제: ```discord-line\n<한 줄>\n``` - 50 자 이내, 이모지 1-2 개, 실패시 ✗/⚠️/🛑 명시 지시 ### discord-notify.ts - StageMessageContext.customLine 필드 추가 - extractDiscordLines(text): 모든 ```discord-line``` 블록 추출 + 본문에서 strip → { lines, cleaned } - renderStageEnd 가 customLine 우선, 없으면 hardcoded 풀로 fallback ### spawn.ts - aggregated 만들기 전에 extractDiscordLines() 호출 - cleaned 텍스트만 buildSuccessResult / priorStages 에 전달 (chat noise 가 다음 stage 의 LLM context 로 새지 않게) - 첫 번째 추출된 line 을 customLine 으로 renderStageEnd 에 전달 ## 효과 이전: 다랑이가 항상 "🔍 리뷰 통과! 이랑이 받아" 같은 4 variant pool 에서 픽 → 식상 + 작업 내용 무관 이후: 다랑이가 직접 LLM 응답 끝에 emit - "🔍 체크리스트 다 ✓. addTodo/toggleTodo/deleteTodo 모두 동작. 이랑이 받아." - "⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." LLM 이 자기가 본 코드의 실제 결함을 한 줄로 요약. 같은 stage 라도 매번 다른 메시지. fallback 은 그대로 작동. --- sister-agent/src/discord-notify.ts | 46 +++++++++++++++++++++++++ sister-agent/src/prompts.ts | 54 ++++++++++++++++++++++++++++++ sister-agent/src/spawn.ts | 20 +++++++++-- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/sister-agent/src/discord-notify.ts b/sister-agent/src/discord-notify.ts index c7b414a..f823fdd 100644 --- a/sister-agent/src/discord-notify.ts +++ b/sister-agent/src/discord-notify.ts @@ -102,6 +102,43 @@ export interface StageMessageContext { verdict?: string; childCount?: number; filesProduced?: number; + /** + * Optional custom line provided by the LLM itself (extracted from a + * `discord-line` code block in the junior output). When set, this line + * is used verbatim instead of picking from the hardcoded pool. Falls + * back to the pool if empty / undefined. + */ + customLine?: string; +} + +/** + * Pull every ```discord-line\n\n``` block out of an LLM text blob. + * Returns the extracted lines AND the original text with all such blocks + * removed (so it can be safely passed to downstream stages without chat + * noise polluting their priorStages context). + * + * The block is intentionally a fenced code block so it doesn't conflict + * with regular markdown formatting and is easy for the LLM to emit + * verbatim. + */ +export function extractDiscordLines(text: string): { + lines: string[]; + cleaned: string; +} { + if (!text) return { lines: [], cleaned: text }; + // Match: ```discord-line``` + const pattern = /```discord-line\s*\n([^\n`]*)\n```/g; + const lines: string[] = []; + let m: RegExpExecArray | null; + while ((m = pattern.exec(text)) !== null) { + const line = (m[1] ?? "").trim(); + if (line) lines.push(line); + } + const cleaned = text + .replace(/```discord-line\s*\n[^\n`]*\n```/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return { lines, cleaned }; } const START_POOLS: Record = { @@ -244,6 +281,15 @@ export function renderStageEnd(ctx: StageMessageContext): string { ctx.filesProduced && ctx.filesProduced > 0 ? ` (산출물 ${ctx.filesProduced}개)` : ""; + + // LLM-supplied custom line wins. The junior who actually did the work + // already knows what to say — use it verbatim. (We still substitute + // {tail} in case the LLM left the placeholder in.) + if (ctx.customLine && ctx.customLine.trim().length > 0) { + return ctx.customLine.trim().replace("{tail}", tail); + } + + // Otherwise fall back to the hardcoded persona pool. const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict); const pool = END_POOLS[key]; if (!pool) return `✅ ${ctx.stage} 완료${tail}`; diff --git a/sister-agent/src/prompts.ts b/sister-agent/src/prompts.ts index 0cf6df1..083c6fa 100644 --- a/sister-agent/src/prompts.ts +++ b/sister-agent/src/prompts.ts @@ -89,9 +89,63 @@ export function buildPrompt(ctx: PromptContext): string { lines.push(roleOutputHint(ctx.role, ctx.stage)); lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`); + // 모든 작업 결과 끝에 디스코드용 한 줄 멘트를 LLM 이 직접 emit 하게 한다. + // sister-agent 가 이 블록을 추출해 stage-end Discord notify 메시지로 사용 + // 한다 (없으면 hardcoded 풀로 fallback). junior 가 가장 작업 내용을 잘 + // 알기 때문에 junior 에만 요청한다 — manager 는 작업 시작 전에 결정만 함. + if (ctx.role === "junior") { + lines.push(""); + lines.push(discordLineFooter(ctx)); + } + return lines.join("\n"); } +/** + * Footer instructing the junior LLM to append a `discord-line` block at + * the end of its response. The block is parsed by the sister-agent and + * used as the stage-end Discord notification message. + * + * Persona context (자매 정체성) is included so the LLM matches tone: + * harang — 차분/단정 + * narang — 활달/실용 + * darang — 꼼꼼/엄격 + * erang — 차분/믿음직 + */ +function discordLineFooter(ctx: PromptContext): string { + const persona: Record = { + harang: "차분하고 단정한 plan 단계 부장", + narang: "활달하고 실용적인 implement 단계 부장", + darang: "꼼꼼하고 엄격한 review 단계 부장", + erang: "차분하고 믿음직한 deploy 단계 부장", + }; + const exampleByStage: Record = { + plan: '"📋 MVP 범위 잡았어. 나랑이 받아."', + implement: '"🔨 todo HTML 5개 함수 박았어. 다랑아 봐줘."', + review: + '"🔍 체크리스트 다 ✓. 이랑이 받아." (APPROVE) 또는 ' + + '"⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." (REQUEST_CHANGES)', + deploy: '"🚀 todo-mvp.html 검증 완료. 안전해."', + }; + return [ + `# 디스코드 알림 한 줄`, + `자기야가 디스코드 채널에서 보게 될 너의 한 줄 보고를 마지막에 추가해.`, + `너는 ${persona[ctx.agentName] ?? ctx.agentName} 의 페르소나를 살려.`, + `방금 너가 한 작업의 핵심을 한 줄로 요약 (50 자 이내, 이모지 1-2개).`, + `결과가 실패/REQUEST_CHANGES/ABORT 면 그 사실을 명확히 (✗/⚠️/🛑 중 하나) 표시.`, + ``, + `**정확히 다음 형식으로** 응답 맨 끝에 추가:`, + "```discord-line", + "<여기에 한 줄>", + "```", + ``, + `예시 (${STAGE_KOREAN[ctx.stage]}):`, + exampleByStage[ctx.stage] ?? '"✅ 작업 완료"', + ``, + `이 블록은 따로 파싱되니까 위 형식 정확히 지켜. 본문 어디 다른 곳에는 같은 형식 쓰지 마.`, + ].join("\n"); +} + function roleOutputHint(role: Role, stage: PromptContext["stage"]): string { // Stage-specific instructions take precedence. The original "decompose // into team" wording only makes sense for plan / implement — for review diff --git a/sister-agent/src/spawn.ts b/sister-agent/src/spawn.ts index ce449b3..b5ba324 100644 --- a/sister-agent/src/spawn.ts +++ b/sister-agent/src/spawn.ts @@ -23,6 +23,7 @@ import { notifyDiscord, renderStageStart, renderStageEnd, + extractDiscordLines, } from "./discord-notify.js"; // Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy @@ -213,10 +214,17 @@ export async function executeInvocation( // — to make a meaningful judgement, so the cap is generous. Cap is // sized for full HTML/JS/CSS files; LLM context windows are 200k+ so // 64KB stays well inside budget even after 4 stages of accumulation. - const aggregated = [managerWork.text, ...childTexts] + const aggregatedRaw = [managerWork.text, ...childTexts] .filter(Boolean) - .join("\n\n---\n\n") - .slice(0, 64_000); + .join("\n\n---\n\n"); + + // Pull every ```discord-line``` block out before slicing/persisting. + // The first extracted line becomes the stage-end Discord message; + // the cleaned text (with the blocks stripped) is what flows to the + // next stage as priorStages so chat noise doesn't bleed through. + const { lines: discordLines, cleaned: aggregatedClean } = + extractDiscordLines(aggregatedRaw); + const aggregated = aggregatedClean.slice(0, 64_000); const result = buildSuccessResult( req.stage, @@ -230,8 +238,13 @@ export async function executeInvocation( // so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견" // vs "배포 실패"). Previously this was emitted before the verdict was // known, so darang would always say "통과" even when REQUEST_CHANGES. + // + // If a junior LLM emitted a `discord-line` block, use that verbatim + // (it's the LLM speaking in character about its own work). Otherwise + // fall back to the hardcoded persona pool. if (req.notifyChannelId) { const verdict = "verdict" in result ? result.verdict : ""; + const customLine = discordLines[0] ?? ""; notifyDiscord({ channelId: req.notifyChannelId, message: renderStageEnd({ @@ -241,6 +254,7 @@ export async function executeInvocation( verdict, childCount: childTexts.length, filesProduced: ctx.producedFiles.length, + ...(customLine && { customLine }), }), }) .then((r) => {