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:
@@ -0,0 +1,104 @@
|
||||
import { getConfig } from '../../config';
|
||||
import { logError, resolveEngine } from '../../utils';
|
||||
import { estimateMessagesTokens, computeOutputBudget } from '../../lib/contextManager';
|
||||
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
|
||||
import { AGENT_PROMPTS, type AgentRole, type AgentExecutorOptions, type ChatMessage } from '../../agent';
|
||||
|
||||
export interface CallRoleAgentDeps {
|
||||
getAbortSignal: () => AbortSignal | undefined;
|
||||
createStreamingRequest: (params: {
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
reqMessages: ChatMessage[];
|
||||
temperature: number;
|
||||
maxTokens?: number;
|
||||
contextLength?: number;
|
||||
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
|
||||
options: AgentExecutorOptions;
|
||||
}
|
||||
|
||||
export async function callRoleAgent(deps: CallRoleAgentDeps, role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
|
||||
const persona = AGENT_PROMPTS[role];
|
||||
const { ollamaUrl, contextLength, maxOutputTokens, contextSafetyMargin, contextOverflowPolicy } = getConfig();
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
// Dynamic output cap so input + output stays within the context window.
|
||||
const inputTokens = estimateMessagesTokens(messages);
|
||||
const { maxOutputTokens: subMaxTokens } = computeOutputBudget(inputTokens, {
|
||||
contextLength, maxOutputTokens, safetyMargin: contextSafetyMargin, minOutputTokens: 512,
|
||||
});
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
let responseText = '';
|
||||
|
||||
if (engine === 'lmstudio' && deps.options.lmStudioStreamer) {
|
||||
try {
|
||||
const stream = deps.options.lmStudioStreamer.stream({
|
||||
modelName,
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature: 0.3,
|
||||
maxTokens: subMaxTokens,
|
||||
contextOverflowPolicy,
|
||||
...lmStudioSamplingFromConfig(),
|
||||
...lmStudioRespondExtrasFromConfig(),
|
||||
signal: deps.getAbortSignal(),
|
||||
});
|
||||
let subStopReason: string | undefined;
|
||||
for await (const { token, stopReason } of stream) {
|
||||
if (token) responseText += token;
|
||||
if (stopReason) subStopReason = stopReason;
|
||||
}
|
||||
// Sub-agent answers that got cut mid-sentence corrupt the pipeline silently
|
||||
// (Planner produces a half-step, Writer can't recover). Surface a warn log so
|
||||
// the operator can raise subMaxTokens or pick a less aggressive output budget.
|
||||
if (subStopReason && /maxPredicted|context|truncat/i.test(subStopReason)) {
|
||||
logError('Sub-agent answer hit a generation limit.', {
|
||||
role, model: modelName, stopReason: subStopReason,
|
||||
chars: responseText.length, maxTokens: subMaxTokens,
|
||||
});
|
||||
}
|
||||
return responseText;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || deps.getAbortSignal()?.aborted) return responseText;
|
||||
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const request = await deps.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName,
|
||||
reqMessages: messages,
|
||||
temperature: 0.3, // Use lower temperature for planning and research
|
||||
maxTokens: subMaxTokens,
|
||||
contextLength
|
||||
});
|
||||
|
||||
const reader = request.response.body?.getReader();
|
||||
if (!reader) throw new Error("Agent response body is not readable.");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const json = JSON.parse(trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed);
|
||||
const content = json.choices?.[0]?.delta?.content || json.message?.content || '';
|
||||
responseText += content;
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch { /* already released */ }
|
||||
}
|
||||
return responseText;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
|
||||
import { getConfig } from '../../config';
|
||||
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
|
||||
import { ErrorTranslator } from '../../core/errorHandler';
|
||||
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
|
||||
import { stripMarkdownFormatting } from '../../core/responseRecovery';
|
||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||
|
||||
export interface WorkflowDeps {
|
||||
emitHistoryChanged: () => void;
|
||||
chatHistory: ChatMessage[];
|
||||
options: AgentExecutorOptions;
|
||||
statusBarManager: StatusBarManager;
|
||||
getWebview: () => vscode.Webview | undefined;
|
||||
getAbortSignal: () => AbortSignal | undefined;
|
||||
}
|
||||
|
||||
export async function executeMultiAgentWorkflow(
|
||||
deps: WorkflowDeps,
|
||||
prompt: string,
|
||||
modelName: string,
|
||||
options: any
|
||||
) {
|
||||
if (!deps.getWebview()) return;
|
||||
// NOTE: 호출자 (AgentExecutor wrapper) 가 stop() + new AbortController() 를
|
||||
// *먼저* 마쳐야 한다 — extracted fn 내부에서 stop 을 부르면 호출자가 막
|
||||
// 만든 controller 가 즉시 폐기되기 때문. getAbortSignal() 은 그 새 controller 의
|
||||
// signal 을 반환해야 함.
|
||||
const signal = deps.getAbortSignal();
|
||||
if (!signal) return;
|
||||
|
||||
const webview = deps.getWebview();
|
||||
if (!webview) return;
|
||||
|
||||
deps.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running');
|
||||
webview.postMessage({ type: 'streamStart' });
|
||||
deps.options.onStreamLifecycle?.start();
|
||||
|
||||
try {
|
||||
let brainContext = 'No specific context available';
|
||||
try {
|
||||
const config = getConfig();
|
||||
const activeBrain = options.brainProfileId
|
||||
? (config.brainProfiles.find((profile) => profile.id === options.brainProfileId) || getActiveBrainProfile())
|
||||
: getActiveBrainProfile();
|
||||
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
|
||||
brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`;
|
||||
} catch (ctxErr) {
|
||||
logError('Failed to load brain context for agents', ctxErr);
|
||||
}
|
||||
|
||||
const selectedAgentContext = options.agentSkillContext
|
||||
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
|
||||
: '';
|
||||
const designerContext = options.designerContext
|
||||
? `\nProject Chronicle Guard:\n${options.designerContext}`
|
||||
: '';
|
||||
|
||||
// 워크플로우 매니저에게 설정 기반 실행 위임
|
||||
// [Clean Stream] 단계 진행 메시지는 채팅 본문(streamChunk) 이 아닌 사이드바
|
||||
// 상단의 workflowStage 인디케이터로만 표시한다 → "생각 단계가 본문에 계속 보임"
|
||||
// 답답함 제거. 채팅 버블에는 최종 답변만 한 번에 들어간다.
|
||||
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
|
||||
prompt,
|
||||
modelName,
|
||||
`${brainContext}${selectedAgentContext}${designerContext}`,
|
||||
signal,
|
||||
(step, msg) => {
|
||||
deps.getWebview()?.postMessage({
|
||||
type: 'workflowStage',
|
||||
value: { step, message: msg, done: step === '완료' || step === '오류' }
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const wv2 = deps.getWebview();
|
||||
if (signal.aborted || !wv2) return;
|
||||
|
||||
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
|
||||
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
|
||||
// 마커가 재학습되는 일을 막는다.
|
||||
const finalReport = getConfig().outputFormat === 'plain'
|
||||
? stripMarkdownFormatting(rawFinalReport)
|
||||
: rawFinalReport;
|
||||
|
||||
wv2.postMessage({ type: 'streamChunk', value: finalReport });
|
||||
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
||||
wv2.postMessage({ type: 'streamEnd' });
|
||||
|
||||
deps.chatHistory.push({ role: 'assistant', content: finalReport });
|
||||
deps.emitHistoryChanged();
|
||||
|
||||
deps.statusBarManager.updateStatus(AgentStatus.Success, 'Workflow Complete');
|
||||
wv2.postMessage({ type: 'autoContinue', value: '✅ 모든 분석이 성공적으로 완료되었습니다.' });
|
||||
|
||||
} catch (error: any) {
|
||||
// 어떤 종료 경로에서든 stage indicator 는 반드시 닫는다 — 안 닫으면 사이드바에 영원히 "③ 자기 검증..." 가 남는다.
|
||||
deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
||||
if (error.name === 'AbortError' || error.message?.includes('cancelled')) {
|
||||
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Workflow Cancelled');
|
||||
return;
|
||||
}
|
||||
const friendly = ErrorTranslator.translate(error);
|
||||
logError('Workflow failed', error);
|
||||
|
||||
const wvErr = deps.getWebview();
|
||||
wvErr?.postMessage({ type: 'autoContinue', value: '' });
|
||||
wvErr?.postMessage({
|
||||
type: 'error',
|
||||
value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}`
|
||||
});
|
||||
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred');
|
||||
} finally {
|
||||
deps.options.onStreamLifecycle?.end();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user