feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -65,11 +65,20 @@ import {
|
||||
writeResumeState,
|
||||
} from './resumeStore';
|
||||
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
||||
// ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ──
|
||||
// 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import
|
||||
// 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를
|
||||
// import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다
|
||||
// require 호출 8회 → 0회 (모듈 캐시 자동).
|
||||
import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier';
|
||||
import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution';
|
||||
import { verifyHollow } from '../selfReflector/selfReflectorHollow';
|
||||
import { formatContractForPrompt } from './intentAlignment';
|
||||
import { getConfig as getDispatcherConfig } from '../../config';
|
||||
import {
|
||||
AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
|
||||
PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult,
|
||||
} from './types';
|
||||
import { formatContractForPrompt } from './intentAlignment';
|
||||
|
||||
/** Trim length applied when an agent's output is fed into the next agent. */
|
||||
const PEER_OUTPUT_BUDGET = 1500;
|
||||
@@ -142,7 +151,10 @@ export type CompanyTurnEvent =
|
||||
*/
|
||||
| { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
|
||||
| { phase: 'session-saved'; sessionDir: string }
|
||||
| { phase: 'aborted'; reason: string };
|
||||
| { phase: 'aborted'; reason: string }
|
||||
// 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트.
|
||||
// 예: resume state 저장 실패, optional feature 미설치 안내 등.
|
||||
| { phase: 'log'; level: 'info' | 'warn' | 'error'; message: string };
|
||||
|
||||
export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
|
||||
|
||||
@@ -248,7 +260,7 @@ export async function runCompanyTurn(
|
||||
abortReason?: string;
|
||||
},
|
||||
): void => {
|
||||
writeResumeState(sessionDir, {
|
||||
const result = writeResumeState(sessionDir, {
|
||||
version: 1,
|
||||
timestamp,
|
||||
userPrompt,
|
||||
@@ -262,6 +274,15 @@ export async function runCompanyTurn(
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
startedAt: startedAtIso,
|
||||
});
|
||||
// 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실*
|
||||
// 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지.
|
||||
if (!result.ok) {
|
||||
emit({
|
||||
phase: 'log',
|
||||
level: 'warn',
|
||||
message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (reason: string, ctx?: {
|
||||
@@ -672,13 +693,9 @@ async function _dispatchOne(
|
||||
let verifierIssues: string[] = [];
|
||||
let verifierSummary = '';
|
||||
try {
|
||||
// dynamic import — Phase B는 옵션이므로 미사용 시 모듈 자체를 안 로드.
|
||||
const { getConfig } = await import('../../config');
|
||||
const cfgRuntime = getConfig();
|
||||
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||||
const { verifyResponse, formatIssuesForRetry } =
|
||||
await import('../selfReflector/selfReflectorVerifier');
|
||||
const { formatContractForPrompt } = await import('./intentAlignment');
|
||||
const contractBlock = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
@@ -726,7 +743,7 @@ async function _dispatchOne(
|
||||
// appended to the response so the user sees what really happened.
|
||||
let finalResponse = rawResponse || '_(empty response)_';
|
||||
let actionReport: string[] | undefined;
|
||||
const hasTag = !!rawResponse && _hasActionTag(rawResponse);
|
||||
const hasTag = !!rawResponse && hasActionTag(rawResponse);
|
||||
if (rawResponse && deps.executeActionTags && hasTag) {
|
||||
try {
|
||||
const report = await deps.executeActionTags(rawResponse);
|
||||
@@ -736,10 +753,8 @@ async function _dispatchOne(
|
||||
// 사용자가 selfReflector.executionVerification 켰을 때만. 추가
|
||||
// report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
|
||||
try {
|
||||
const { getConfig } = await import('../../config');
|
||||
const cfgRuntime = getConfig();
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
|
||||
const { verifyCreatedFiles } = await import('../selfReflector/selfReflectorExecution');
|
||||
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (projectRoot) {
|
||||
const extra = await verifyCreatedFiles(actionReport, projectRoot);
|
||||
@@ -761,10 +776,8 @@ async function _dispatchOne(
|
||||
// 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라
|
||||
// selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*.
|
||||
try {
|
||||
const { getConfig } = await import('../../config');
|
||||
const cfgRuntime = getConfig();
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) {
|
||||
const { verifyHollow } = await import('../selfReflector/selfReflectorHollow');
|
||||
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (projectRoot) {
|
||||
const hollowRes = verifyHollow(actionReport, projectRoot);
|
||||
@@ -778,14 +791,13 @@ async function _dispatchOne(
|
||||
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
|
||||
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
|
||||
try {
|
||||
const { formatIssuesForRetry } = await import('../selfReflector/selfReflectorVerifier');
|
||||
const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
|
||||
const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
|
||||
const retried = (retryRes.content || '').trim();
|
||||
if (retried) {
|
||||
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
|
||||
rawResponse = retried;
|
||||
if (deps.executeActionTags && _hasActionTag(retried)) {
|
||||
if (deps.executeActionTags && hasActionTag(retried)) {
|
||||
const retryReport = await deps.executeActionTags(retried);
|
||||
actionReport = retryReport;
|
||||
// 재작업 결과도 hollow 한 번 더 검사.
|
||||
@@ -826,7 +838,7 @@ async function _dispatchOne(
|
||||
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
|
||||
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
|
||||
}
|
||||
} else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) {
|
||||
} else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) {
|
||||
// Hallucination guard: small models love to *narrate* file
|
||||
// creation ("foo.py를 생성했습니다 …") without emitting the
|
||||
// <create_file> tag — so the user sees ✅ in chat but nothing
|
||||
@@ -842,7 +854,7 @@ async function _dispatchOne(
|
||||
// legitimately answer-only. But by flagging the agent output we
|
||||
// mark it as not-fully-successful so the CEO synthesis can read
|
||||
// the warning verbatim.
|
||||
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
|
||||
const claimedButDidnt = rawResponse && !hasTag && claimsFileCreation(rawResponse);
|
||||
// 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지*
|
||||
// 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출.
|
||||
if (verifierSummary) {
|
||||
@@ -967,58 +979,21 @@ async function _resolveStageAgent(
|
||||
}
|
||||
return { agentId: candidates[0].id, source: 'fallback-first' };
|
||||
}
|
||||
// resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction
|
||||
// / hasActionTag / claimsFileCreation
|
||||
// → `src/features/company/dispatcherHelpers.ts`
|
||||
import {
|
||||
resolveInspector,
|
||||
parseInspectorVerdict,
|
||||
parseCeoVerdict,
|
||||
renderStageInstruction,
|
||||
hasActionTag,
|
||||
claimsFileCreation,
|
||||
} from './dispatcherHelpers';
|
||||
|
||||
|
||||
/**
|
||||
* 검수자(또는 직군)를 stage.reviewWith 값에 따라 한 명 결정.
|
||||
* - 'inspector' / 'role:<cat>' → 해당 직군 활성 후보 중 첫 번째
|
||||
* - 'agent:<id>' → 그 에이전트 (활성/비활성 무관)
|
||||
* 후보가 없으면 null — 호출자가 검수 사이클을 skip.
|
||||
*/
|
||||
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')으로 폴백.
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-way 합의 검수 사이클. 작업자 산출물(latestOutput)을 받고:
|
||||
@@ -1047,7 +1022,7 @@ async function _runReviewCycle(args: {
|
||||
const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args;
|
||||
const reviewWith = stage.reviewWith || '';
|
||||
if (!reviewWith) return { verdict: 'pass', rounds: 0 };
|
||||
const inspector = _resolveInspector(reviewWith, state);
|
||||
const inspector = resolveInspector(reviewWith, state);
|
||||
if (!inspector) {
|
||||
// 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지
|
||||
// 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할
|
||||
@@ -1097,7 +1072,7 @@ async function _runReviewCycle(args: {
|
||||
inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`;
|
||||
}
|
||||
lastInspectorText = inspectorText;
|
||||
lastInspectorVerdict = _parseInspectorVerdict(inspectorText);
|
||||
lastInspectorVerdict = parseInspectorVerdict(inspectorText);
|
||||
|
||||
if (isAborted()) {
|
||||
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
|
||||
@@ -1121,7 +1096,7 @@ async function _runReviewCycle(args: {
|
||||
ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완';
|
||||
}
|
||||
lastCeoText = ceoText;
|
||||
lastCeoVerdict = _parseCeoVerdict(ceoText);
|
||||
lastCeoVerdict = parseCeoVerdict(ceoText);
|
||||
|
||||
emit({
|
||||
phase: 'review-round',
|
||||
@@ -1246,7 +1221,7 @@ async function _runPipeline(
|
||||
while (i < pipeline.stages.length) {
|
||||
if (isAborted()) return abortReturn('aborted-mid-pipeline');
|
||||
const stage = pipeline.stages[i];
|
||||
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
|
||||
const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage);
|
||||
const note = revisionNotes[stage.id];
|
||||
const task = note
|
||||
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
|
||||
@@ -1385,58 +1360,5 @@ async function _runPipeline(
|
||||
return { outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute template tokens in a stage's instruction. Falls back to the
|
||||
* raw user prompt when the template is empty so the user doesn't have to
|
||||
* fill every stage with a long template just to forward the original ask.
|
||||
*/
|
||||
function _renderStageInstruction(
|
||||
stage: PipelineStage,
|
||||
userPrompt: string,
|
||||
brief: string,
|
||||
latestByStage: Record<string, AgentTurnOutput>,
|
||||
): 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 so we don't fire up the action-tag executor for every
|
||||
* specialist response — only the ones that actually contain a recognised
|
||||
* tag. Saves a workspace lookup + transaction-manager spin-up on the common
|
||||
* case (the agent just talks).
|
||||
*/
|
||||
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: does the response *narrate* having created files/folders?
|
||||
*
|
||||
* We look for the combination of (a) a Korean / English creation verb and
|
||||
* (b) a filename-like or "folder" mention. The intent is to catch the
|
||||
* hallucination pattern where an agent writes "foo.py 파일을 생성했습니다"
|
||||
* or "Created `bar/` directory" without emitting the corresponding
|
||||
* `<create_file>` tag, so the dispatcher can flag it back to the CEO and
|
||||
* the user instead of silently reporting success.
|
||||
*
|
||||
* Kept narrow on purpose — a *plan* like "다음에는 X를 만들어야 합니다"
|
||||
* shouldn't trigger this. We require past-tense / completion phrasing.
|
||||
*/
|
||||
function _claimsFileCreation(text: string): boolean {
|
||||
// Past-tense creation verbs (Korean + English).
|
||||
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
|
||||
if (!claimRe.test(text)) return false;
|
||||
// Combined with either an explicit filename (something.ext) or the word
|
||||
// "폴더" / "directory" / "folder" near the verb.
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user