v2.2.256: 코어 채팅 큰 입력 청킹·통합 + 실제 컨텍스트 창 정렬 + 모델 핸들 race 수정
큰 입력 시 "Failed to acquire LM Studio model handle … Operation canceled" 로 턴 전체가 죽던 문제를 3계층으로 해결. 일반 채팅(코어 경로)은 그동안 단일 예산 호출이라 약한 모델·큰 입력에서 무너졌다 — 그 갭을 메움. - 핸들 race 수정: getModelHandle 을 재시도 루프 안으로 이동. 취소/죽은-핸들 류 에러는 SDK 재생성 후 1회 자동 재시도(실제 사용자 취소는 존중). 라이프 사이클의 동시 로드가 abort 되며 SDK 가 coalesce 한 JIT 조회까지 죽던 것. - Phase 1 실제 창 정렬: llm.getContextLength()(캐시)로 실측 창에 예산 클램프. 설정값보다 작은 창으로 로드된 경우 서버 truncation/빈 답변 차단. 배지에 표시. - Phase 2 코어 Map-Reduce: 단일 입력이 (유효 창 × ratio) 초과 시 청크→질의 인지형 추출→통합. 부분/전체 폴백, 무관 시 정직 신호. 동시성 기본 2. - Phase 3 메타 노출: 진행/결과 배지 표시, [조각 k] 출처 옵트인. 신규 설정 5종. /meet·/review 전용 경로는 불변. 테스트 +25건, 전체 684 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.2.256 (2026-06-19)
|
||||||
|
### 🧩 코어 채팅 경로 — 큰 입력 청킹·통합 + 실제 컨텍스트 창 정렬 + 모델 핸들 race 수정
|
||||||
|
큰 입력을 넣으면 `Failed to acquire LM Studio model handle … Operation canceled` 로 턴 전체가 죽던 문제를 3계층으로 해결. `/meet`·`/review` 와 달리 **일반 채팅(코어 경로)** 은 그동안 단일 예산 호출이라 약한 모델·큰 입력에서 무너졌다 — 그 갭을 메움.
|
||||||
|
|
||||||
|
- **모델 핸들 race 수정**: 핸들 획득(`getModelHandle`→`llm.model()`)이 재시도 try/catch **바깥**에 있어, 라이프사이클의 동시 로드가 superseded/abort 되며 SDK 가 합쳐버린(coalesce) 우리 JIT 조회까지 "Operation canceled" 로 떨어지면 **재시도 없이 크래시**했다. 핸들 획득을 재시도 루프 안으로 넣고, 취소/죽은-핸들 류 에러는 SDK 재생성 후 1회 자동 재시도(실제 사용자 취소는 그대로 존중). 큰 입력일수록 26B 로드가 느려 race 창이 넓어져 잘 터지던 것. ([streamer.ts](src/lmstudio/streamer.ts))
|
||||||
|
- **Phase 1 — 실제 창 정렬**: 예산을 설정값(`g1nation.contextLength`)이 아니라 모델이 **실제 로드된 창**(`llm.getContextLength()`, 캐시)에 맞춰 둘 중 작은 쪽으로 클램프. 설정 32768 인데 모델이 8192/16384 로 떠 있으면 서버가 조용히 잘라 빈 답변이 나던 문제를 실측으로 차단. 불일치 시 컨텍스트 배지에 `⚠ 실제 창 N↓` 노출. ([client.ts](src/lmstudio/client.ts) · [computeBudgetedRequest.ts](src/agent/handlePrompt/computeBudgetedRequest.ts))
|
||||||
|
- **Phase 2 — 코어 채팅 Map-Reduce**: 단일 사용자 입력이 (유효 창 × `mapReduceTriggerRatio`, 기본 0.6) 을 넘으면 청크→**질의 인지형 추출**(요약 아님, 원문 사실만·추측 금지)→통합 후, 압축된 컨텍스트로 정상 스트리밍 답변. 합본이 또 넘치면 계층적 통합(`mapReduceMaxDepth`). 한 조각 실패는 부분 폴백, 전체 실패는 단발 경로로 폴백. 모두 무관하면 정직하게 "관련 내용 없음" 신호. 동시성은 로컬 GPU 보호로 기본 2. ([largeInputMapReduce.ts](src/agent/handlePrompt/largeInputMapReduce.ts))
|
||||||
|
- **Phase 3 — 메타 노출**: map-reduce 진행/결과(`N조각 → M추출`, 무관·실패)를 컨텍스트 배지에 표시. 출처 추적용 `[조각 k]` 태깅은 `g1nation.mapReduceShowProvenance` 로 옵트인.
|
||||||
|
- 신규 설정: `largeInputMapReduce`(기본 on) · `mapReduceTriggerRatio` · `mapReduceConcurrency` · `mapReduceMaxDepth` · `mapReduceShowProvenance`. 코어 경로만 변경, `/meet`·`/review` 전용 경로는 불변. 테스트 +25건(streamer·budget·map-reduce 코어), 전체 684 통과.
|
||||||
|
|
||||||
## v2.2.255 (2026-06-18)
|
## v2.2.255 (2026-06-18)
|
||||||
### 🧩 `/review` — 코드 리뷰 map-reduce 청킹 (약한 모델도 큰 코드베이스 처리)
|
### 🧩 `/review` — 코드 리뷰 map-reduce 청킹 (약한 모델도 큰 코드베이스 처리)
|
||||||
- 일반 에이전트 채팅은 코드 리뷰처럼 입력이 큰 작업을 단일 호출로 처리하다 약한 로컬 모델에서 빈 응답(첫 토큰 EOS)으로 무너진다. `/meet` 의 검증된 map-reduce 를 코드 리뷰에 적용한 **`/review <디렉터리|파일> [초점]`** 명령 신설. 코어 채팅 경로는 건드리지 않음.
|
- 일반 에이전트 채팅은 코드 리뷰처럼 입력이 큰 작업을 단일 호출로 처리하다 약한 로컬 모델에서 빈 응답(첫 토큰 EOS)으로 무너진다. `/meet` 의 검증된 map-reduce 를 코드 리뷰에 적용한 **`/review <디렉터리|파일> [초점]`** 명령 신설. 코어 채팅 경로는 건드리지 않음.
|
||||||
|
|||||||
+30
-2
@@ -444,13 +444,38 @@
|
|||||||
if (b.droppedHistory > 0) parts.push(`기록 −${b.droppedHistory}`);
|
if (b.droppedHistory > 0) parts.push(`기록 −${b.droppedHistory}`);
|
||||||
if (b.systemTruncated) parts.push('컨텍스트 일부 생략');
|
if (b.systemTruncated) parts.push('컨텍스트 일부 생략');
|
||||||
if (b.cappedForSmallModel) parts.push('🔻 작은 모델 모드');
|
if (b.cappedForSmallModel) parts.push('🔻 작은 모델 모드');
|
||||||
|
if (b.windowMismatch && typeof b.actualContextLength === 'number') parts.push('⚠ 실제 창 ' + fmtK(b.actualContextLength) + '↓');
|
||||||
if (b.tight) parts.push('⚠ 컨텍스트 거의 가득');
|
if (b.tight) parts.push('⚠ 컨텍스트 거의 가득');
|
||||||
const warn = b.tight || b.systemTruncated;
|
const warn = b.tight || b.systemTruncated || b.windowMismatch;
|
||||||
ctxBadge.textContent = parts.join(' · ');
|
ctxBadge.textContent = parts.join(' · ');
|
||||||
ctxBadge.className = 'ctx-badge' + (warn ? ' warn' : ' ok');
|
ctxBadge.className = 'ctx-badge' + (warn ? ' warn' : ' ok');
|
||||||
// New turn starts → drop stale stats from the previous answer.
|
// New turn starts → drop stale stats from the previous answer.
|
||||||
lastLmStats = null;
|
lastLmStats = null;
|
||||||
ctxBadge.title = `model: ${b.model || ''}${b.paramB != null ? ' (~' + b.paramB + 'B)' : ''}\n입력 ≈ ${b.inputTokens} tokens (시스템 ${b.systemTokens}, 기록 ${b.historyKept}개)\n출력 상한 ${b.maxOutputTokens} tokens / 유효 context window ${b.contextLength} tokens${b.cappedForSmallModel ? ' (작은 모델용 축소; 설정값 ' + b.nominalContextLength + ')' : ''}`;
|
const mismatchNote = (b.windowMismatch && typeof b.actualContextLength === 'number')
|
||||||
|
? `\n⚠ 모델이 실제로는 ${b.actualContextLength} 토큰 창으로 로드됨 (설정 ${b.nominalContextLength}). 그 한도에 맞춰 예산함.`
|
||||||
|
: '';
|
||||||
|
ctxBadge.title = `model: ${b.model || ''}${b.paramB != null ? ' (~' + b.paramB + 'B)' : ''}\n입력 ≈ ${b.inputTokens} tokens (시스템 ${b.systemTokens}, 기록 ${b.historyKept}개)\n출력 상한 ${b.maxOutputTokens} tokens / 유효 context window ${b.contextLength} tokens${b.cappedForSmallModel ? ' (작은 모델용 축소; 설정값 ' + b.nominalContextLength + ')' : ''}${mismatchNote}`;
|
||||||
|
}
|
||||||
|
function renderMapReduceStatus(v) {
|
||||||
|
if (!ctxBadge || !v) return;
|
||||||
|
if (v.phase === 'start') {
|
||||||
|
ctxBadge.textContent = '🧩 큰 입력을 조각으로 나눠 관련 내용 추출 중…';
|
||||||
|
ctxBadge.className = 'ctx-badge warn';
|
||||||
|
ctxBadge.title = '입력이 컨텍스트 창보다 커서 청크→추출→통합(map-reduce)으로 처리 중입니다.';
|
||||||
|
} else if (v.phase === 'done') {
|
||||||
|
if (v.allIrrelevant) {
|
||||||
|
ctxBadge.textContent = '🧩 추출 결과: 요청 관련 내용 없음';
|
||||||
|
ctxBadge.className = 'ctx-badge warn';
|
||||||
|
ctxBadge.title = '긴 입력의 모든 조각에서 요청과 직접 관련된 내용을 찾지 못했습니다. 원본을 그대로(예산 내에서 잘라) 전달합니다.';
|
||||||
|
} else {
|
||||||
|
ctxBadge.textContent = `🧩 ${v.chunkCount}조각 → ${v.relevantCount}조각 추출·통합`;
|
||||||
|
ctxBadge.className = 'ctx-badge ok';
|
||||||
|
ctxBadge.title = `큰 입력을 ${v.chunkCount}개 조각으로 나눠 그중 ${v.relevantCount}개에서 관련 내용을 추출·통합했습니다.`;
|
||||||
|
}
|
||||||
|
} else if (v.phase === 'error') {
|
||||||
|
ctxBadge.textContent = '🧩 분할 처리 실패 — 단발 처리로 진행';
|
||||||
|
ctxBadge.className = 'ctx-badge warn';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function renderLmStudioStats(s) {
|
function renderLmStudioStats(s) {
|
||||||
if (!ctxBadge || !s) return;
|
if (!ctxBadge || !s) return;
|
||||||
@@ -995,6 +1020,9 @@
|
|||||||
case 'lmStudioStats':
|
case 'lmStudioStats':
|
||||||
renderLmStudioStats(msg.value);
|
renderLmStudioStats(msg.value);
|
||||||
break;
|
break;
|
||||||
|
case 'mapReduceStatus':
|
||||||
|
renderMapReduceStatus(msg.value);
|
||||||
|
break;
|
||||||
case 'usedScope': {
|
case 'usedScope': {
|
||||||
let target = streamBody && streamBody._parent;
|
let target = streamBody && streamBody._parent;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
+32
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "Astra",
|
"displayName": "Astra",
|
||||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||||
"version": "2.2.255",
|
"version": "2.2.256",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -441,6 +441,37 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"description": "Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with a tiny model, set this to e.g. 8192–16384: for ≤3B models only, Astra then budgets the prompt against this smaller effective window instead of g1nation.contextLength. Never applies to 4B+ models. Leave 0 unless you actually hit the issue — it reduces the output-token budget. Default: 0 (disabled)"
|
"description": "Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with a tiny model, set this to e.g. 8192–16384: for ≤3B models only, Astra then budgets the prompt against this smaller effective window instead of g1nation.contextLength. Never applies to 4B+ models. Leave 0 unless you actually hit the issue — it reduces the output-token budget. Default: 0 (disabled)"
|
||||||
},
|
},
|
||||||
|
"g1nation.largeInputMapReduce": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "When a single message is too large to fit the model's context window, split it into chunks, extract only the request-relevant facts from each (no hallucination/summary), integrate them, and answer from the condensed result. Turn off to send oversized input in one shot (the server may then truncate it). Default: true"
|
||||||
|
},
|
||||||
|
"g1nation.mapReduceTriggerRatio": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0.6,
|
||||||
|
"minimum": 0.3,
|
||||||
|
"maximum": 0.95,
|
||||||
|
"description": "Map-reduce kicks in when a single message exceeds (effective context window × this ratio). Lower = engages sooner (safer for big inputs, more LLM calls). Default: 0.6"
|
||||||
|
},
|
||||||
|
"g1nation.mapReduceConcurrency": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 2,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 8,
|
||||||
|
"description": "How many chunk extractions run in parallel. Keep low on a single local GPU (one model serves them sequentially anyway). Default: 2"
|
||||||
|
},
|
||||||
|
"g1nation.mapReduceMaxDepth": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 3,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 6,
|
||||||
|
"description": "Maximum hierarchical-integration depth when the combined extractions still overflow the window. Default: 3"
|
||||||
|
},
|
||||||
|
"g1nation.mapReduceShowProvenance": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Tag each extracted block with its source chunk ([조각 k]) so the final answer can be traced back to the part of the input it came from. Default: false"
|
||||||
|
},
|
||||||
"g1nation.autoContinueOnOutputLimit": {
|
"g1nation.autoContinueOnOutputLimit": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export { _parseTaskAttrs, _parseSheetAttrs, _parseCalEventAttrs };
|
|||||||
// 8 method bodies extracted to dedicated modules. AgentExecutor 의 동명 메서드는
|
// 8 method bodies extracted to dedicated modules. AgentExecutor 의 동명 메서드는
|
||||||
// 이제 thin wrapper — deps 객체를 묶어서 free function 으로 위임.
|
// 이제 thin wrapper — deps 객체를 묶어서 free function 으로 위임.
|
||||||
import { callNonStreaming as callNonStreamingFn } from './agent/llm/callNonStreaming';
|
import { callNonStreaming as callNonStreamingFn } from './agent/llm/callNonStreaming';
|
||||||
|
import { runMapReduce, shouldMapReduce } from './agent/handlePrompt/largeInputMapReduce';
|
||||||
import { createStreamingRequest as createStreamingRequestFn } from './agent/llm/createStreamingRequest';
|
import { createStreamingRequest as createStreamingRequestFn } from './agent/llm/createStreamingRequest';
|
||||||
import { streamChatOnce as streamChatOnceFn } from './agent/llm/streamChatOnce';
|
import { streamChatOnce as streamChatOnceFn } from './agent/llm/streamChatOnce';
|
||||||
import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from './agent/llm/devilRebuttal';
|
import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from './agent/llm/devilRebuttal';
|
||||||
@@ -768,12 +769,103 @@ export class AgentExecutor {
|
|||||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||||
const imageCount = (reqMessages as any[])
|
const imageCount = (reqMessages as any[])
|
||||||
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
||||||
|
// Budget against the model's REAL loaded window, not just the user's
|
||||||
|
// contextLength setting. Best-effort + cached; only for the LM Studio
|
||||||
|
// SDK path (REST/Ollama/cloud expose no such query → undefined → prior behavior).
|
||||||
|
let actualContextLength: number | undefined;
|
||||||
|
try {
|
||||||
|
const _isCloud = (() => {
|
||||||
|
try {
|
||||||
|
const { parseModelPrefix } = require('./features/providers') as typeof import('./features/providers');
|
||||||
|
return !!parseModelPrefix(actualModel);
|
||||||
|
} catch { return false; }
|
||||||
|
})();
|
||||||
|
if (!_isCloud
|
||||||
|
&& resolveEngine(ollamaUrl) === 'lmstudio'
|
||||||
|
&& this.options.lmStudioStreamer?.getModelContextLength) {
|
||||||
|
actualContextLength = await this.options.lmStudioStreamer.getModelContextLength(actualModel);
|
||||||
|
}
|
||||||
|
} catch { /* best-effort — fall back to configured window */ }
|
||||||
|
|
||||||
|
// ── Large-input Map-Reduce ────────────────────────────────────────
|
||||||
|
// When a SINGLE user message is too big to fit the (real) window,
|
||||||
|
// history-trimming can't help — you can't drop the current question.
|
||||||
|
// Chunk it, extract only the request-relevant facts per chunk, and
|
||||||
|
// integrate, then let the normal streaming path answer from the
|
||||||
|
// condensed context. Only the user-visible turn; casual chat skipped.
|
||||||
|
if (loopDepth === 0 && !isCasualConversation && config.largeInputMapReduce) {
|
||||||
|
try {
|
||||||
|
const effWindow = (typeof actualContextLength === 'number' && actualContextLength > 0)
|
||||||
|
? Math.min(config.contextLength, actualContextLength)
|
||||||
|
: config.contextLength;
|
||||||
|
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
|
||||||
|
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
|
||||||
|
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
||||||
|
const sysTokens = estimateTokens(fullSystemPrompt) + 4;
|
||||||
|
const mrCfg = {
|
||||||
|
enabled: true,
|
||||||
|
triggerRatio: config.mapReduceTriggerRatio,
|
||||||
|
concurrency: config.mapReduceConcurrency,
|
||||||
|
maxDepth: config.mapReduceMaxDepth,
|
||||||
|
showProvenance: config.mapReduceShowProvenance,
|
||||||
|
};
|
||||||
|
if (lastUser && shouldMapReduce(estimateTokens(content), effWindow, mrCfg)) {
|
||||||
|
const intent = content.length > 1400
|
||||||
|
? `${content.slice(0, 800)}\n…\n${content.slice(-400)}`
|
||||||
|
: content;
|
||||||
|
const mrEngine = resolveEngine(ollamaUrl);
|
||||||
|
this.webview?.postMessage({ type: 'mapReduceStatus', value: { phase: 'start' } });
|
||||||
|
const mr = await runMapReduce(
|
||||||
|
{
|
||||||
|
callLLM: async (messages, maxTokens) => {
|
||||||
|
const r = await this.callNonStreaming({
|
||||||
|
baseUrl: ollamaUrl,
|
||||||
|
modelName: actualModel,
|
||||||
|
engine: mrEngine,
|
||||||
|
messages,
|
||||||
|
temperature: 0.1,
|
||||||
|
maxTokens,
|
||||||
|
contextLength: effWindow,
|
||||||
|
signal: this.abortController?.signal,
|
||||||
|
});
|
||||||
|
return r.text;
|
||||||
|
},
|
||||||
|
estimateTokens,
|
||||||
|
log: (msg, meta) => logInfo(msg, meta),
|
||||||
|
signal: this.abortController?.signal,
|
||||||
|
},
|
||||||
|
{ intent, largeContent: content, windowTokens: effWindow, systemTokens: sysTokens, safetyMargin: config.contextSafetyMargin, cfg: mrCfg },
|
||||||
|
);
|
||||||
|
// allIrrelevant → keep original (budgeter truncates) rather than forcing an empty context.
|
||||||
|
if (!mr.allIrrelevant && mr.condensedContext.trim()) {
|
||||||
|
reqMessages[lastUserIdx] = {
|
||||||
|
...lastUser,
|
||||||
|
content: `${intent}\n\n──────── 추출된 관련 자료 (원본 ${mr.chunkCount}조각 중 ${mr.relevantCount}조각, 통합 ${mr.reduceDepth}단계) ────────\n${mr.condensedContext}`,
|
||||||
|
} as any;
|
||||||
|
logInfo('Large input condensed via map-reduce.', {
|
||||||
|
model: actualModel, chunkCount: mr.chunkCount, relevantCount: mr.relevantCount, reduceDepth: mr.reduceDepth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.webview?.postMessage({
|
||||||
|
type: 'mapReduceStatus',
|
||||||
|
value: { phase: 'done', chunkCount: mr.chunkCount, relevantCount: mr.relevantCount, allIrrelevant: mr.allIrrelevant },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Any failure → fall through to the normal (single-shot) path. Worst case the
|
||||||
|
// budgeter truncates the oversized input, which is the prior behavior.
|
||||||
|
logError('Large-input map-reduce failed — falling back to single-shot path.', { error: e?.message ?? String(e) });
|
||||||
|
this.webview?.postMessage({ type: 'mapReduceStatus', value: { phase: 'error' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const _budget = computeBudgetedRequest({
|
const _budget = computeBudgetedRequest({
|
||||||
fullSystemPrompt,
|
fullSystemPrompt,
|
||||||
reqMessages,
|
reqMessages,
|
||||||
actualModel,
|
actualModel,
|
||||||
config,
|
config,
|
||||||
imageCount,
|
imageCount,
|
||||||
|
actualContextLength,
|
||||||
});
|
});
|
||||||
const messagesForRequest = _budget.messagesForRequest;
|
const messagesForRequest = _budget.messagesForRequest;
|
||||||
const ctxLimits = _budget.ctxLimits;
|
const ctxLimits = _budget.ctxLimits;
|
||||||
@@ -819,6 +911,8 @@ export class AgentExecutor {
|
|||||||
paramB: modelParamB,
|
paramB: modelParamB,
|
||||||
contextLength: ctxLimits.contextLength,
|
contextLength: ctxLimits.contextLength,
|
||||||
nominalContextLength: config.contextLength,
|
nominalContextLength: config.contextLength,
|
||||||
|
actualContextLength,
|
||||||
|
windowMismatch: _budget.windowMismatch,
|
||||||
cappedForSmallModel,
|
cappedForSmallModel,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export interface ComputeBudgetedRequestInput {
|
|||||||
/** Result of `getConfig()` — reads contextLength, maxOutputTokens, contextSafetyMargin, smallModelContextCap, autoCompactHistory. */
|
/** Result of `getConfig()` — reads contextLength, maxOutputTokens, contextSafetyMargin, smallModelContextCap, autoCompactHistory. */
|
||||||
config: any;
|
config: any;
|
||||||
imageCount: number;
|
imageCount: number;
|
||||||
|
/**
|
||||||
|
* The model's *actually-loaded* context window (LM Studio `getContextLength()`),
|
||||||
|
* when known. Budgeting uses the smaller of this and `config.contextLength` so we
|
||||||
|
* never overflow a model loaded with a smaller window than the user's setting.
|
||||||
|
* Omit (undefined) to budget against the configured value alone (prior behavior).
|
||||||
|
*/
|
||||||
|
actualContextLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputeBudgetedRequestResult {
|
export interface ComputeBudgetedRequestResult {
|
||||||
@@ -34,6 +41,10 @@ export interface ComputeBudgetedRequestResult {
|
|||||||
outputBudget: { maxOutputTokens: number; available: number; tight: boolean };
|
outputBudget: { maxOutputTokens: number; available: number; tight: boolean };
|
||||||
modelParamB: number | null;
|
modelParamB: number | null;
|
||||||
cappedForSmallModel: boolean;
|
cappedForSmallModel: boolean;
|
||||||
|
/** True when the model's real loaded window is smaller than `config.contextLength` (we clamped to the real one). */
|
||||||
|
windowMismatch: boolean;
|
||||||
|
/** The window actually used for budgeting (after real-window clamp + small-model cap). */
|
||||||
|
effectiveContextLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,15 +71,34 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
|||||||
// smaller effective window. Never applied to 4B+ models, and never when the setting is 0 —
|
// smaller effective window. Never applied to 4B+ models, and never when the setting is 0 —
|
||||||
// capping squeezes the output-token budget, so it's a knob, not a default.
|
// capping squeezes the output-token budget, so it's a knob, not a default.
|
||||||
const modelParamB = estimateModelParamsB(actualModel);
|
const modelParamB = estimateModelParamsB(actualModel);
|
||||||
|
|
||||||
|
// The real ceiling is whatever window the model was actually loaded with — the
|
||||||
|
// server truncates anything past it. When known, clamp the configured setting
|
||||||
|
// down to it so we budget against the smaller of the two. (When unknown, keep
|
||||||
|
// the configured value — prior behavior.)
|
||||||
|
const actualWindow = (typeof input.actualContextLength === 'number'
|
||||||
|
&& Number.isFinite(input.actualContextLength)
|
||||||
|
&& input.actualContextLength > 0)
|
||||||
|
? input.actualContextLength
|
||||||
|
: undefined;
|
||||||
|
const configuredWindow = config.contextLength;
|
||||||
|
const windowMismatch = actualWindow !== undefined && actualWindow < configuredWindow;
|
||||||
|
const realWindow = actualWindow !== undefined ? Math.min(configuredWindow, actualWindow) : configuredWindow;
|
||||||
|
if (windowMismatch) {
|
||||||
|
logInfo('Model loaded with a smaller context window than the setting — clamping budget to the real window.', {
|
||||||
|
model: actualModel, configuredWindow, actualWindow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const smallModelCap = config.smallModelContextCap; // 0 = disabled (default)
|
const smallModelCap = config.smallModelContextCap; // 0 = disabled (default)
|
||||||
const cappedForSmallModel = smallModelCap > 0
|
const cappedForSmallModel = smallModelCap > 0
|
||||||
&& modelParamB !== null && modelParamB <= 3
|
&& modelParamB !== null && modelParamB <= 3
|
||||||
&& config.contextLength > smallModelCap;
|
&& realWindow > smallModelCap;
|
||||||
const effectiveContextLength = cappedForSmallModel ? smallModelCap : config.contextLength;
|
const effectiveContextLength = cappedForSmallModel ? smallModelCap : realWindow;
|
||||||
if (cappedForSmallModel) {
|
if (cappedForSmallModel) {
|
||||||
logInfo('Small model detected — capping effective context window for budgeting.', {
|
logInfo('Small model detected — capping effective context window for budgeting.', {
|
||||||
model: actualModel, paramB: modelParamB,
|
model: actualModel, paramB: modelParamB,
|
||||||
nominalContext: config.contextLength, effectiveContext: effectiveContextLength,
|
nominalContext: realWindow, effectiveContext: effectiveContextLength,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const ctxLimits: ContextLimits = {
|
const ctxLimits: ContextLimits = {
|
||||||
@@ -157,5 +187,7 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
|||||||
outputBudget,
|
outputBudget,
|
||||||
modelParamB,
|
modelParamB,
|
||||||
cappedForSmallModel,
|
cappedForSmallModel,
|
||||||
|
windowMismatch,
|
||||||
|
effectiveContextLength,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* Large-Input Map-Reduce (큰 입력 청킹 + 통합)
|
||||||
|
*
|
||||||
|
* 한 번에 컨텍스트 창에 안 들어가는 단일 사용자 입력(긴 회의록·리서치 덤프 등)을
|
||||||
|
* 1) 청크로 분할(Map 대상)
|
||||||
|
* 2) 각 청크에서 "요청과 관련된 사실만" 발췌 (질의 인지형 추출 — 일반 요약 X)
|
||||||
|
* 3) 발췌들을 통합(Reduce). 합본이 또 창을 넘으면 계층적으로 재통합.
|
||||||
|
* 한 뒤, 압축된 컨텍스트를 돌려줘 정상 스트리밍 경로가 최종 답변을 생성하게 한다.
|
||||||
|
*
|
||||||
|
* 신뢰성 원칙(ASTRA): 추측·창작 금지, 원문 표현 보존, 출처(`[조각 k]`) 태깅,
|
||||||
|
* 전부 무관하면 정직하게 "관련 내용 없음" 신호.
|
||||||
|
*
|
||||||
|
* LLM 호출은 `callLLM` 으로 주입 → 코어 로직은 네트워크 의존 없이 단위 테스트 가능.
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatMessage } from '../../agent';
|
||||||
|
import { splitIntoSections } from '../../retrieval/chunker';
|
||||||
|
|
||||||
|
export interface MapReduceConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** 단일 입력 토큰 > (유효 창 × triggerRatio) 이면 발동. */
|
||||||
|
triggerRatio: number;
|
||||||
|
concurrency: number;
|
||||||
|
maxDepth: number;
|
||||||
|
showProvenance: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapReduceDeps {
|
||||||
|
/** 메시지 배열 → 모델 응답 텍스트. (callNonStreaming 래퍼) */
|
||||||
|
callLLM: (messages: ChatMessage[], maxTokens: number) => Promise<string>;
|
||||||
|
estimateTokens: (text: string) => number;
|
||||||
|
log?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapReduceParams {
|
||||||
|
/** 사용자 요청 의도 힌트 (보통 원본 입력의 머리/꼬리 발췌 — 지시문이 거기 있음). */
|
||||||
|
intent: string;
|
||||||
|
/** 청킹 대상이 되는 큰 본문. */
|
||||||
|
largeContent: string;
|
||||||
|
/** 유효 컨텍스트 창(토큰) — Phase 1 의 effectiveContextLength. */
|
||||||
|
windowTokens: number;
|
||||||
|
/** 시스템 프롬프트가 이미 차지한 토큰. */
|
||||||
|
systemTokens: number;
|
||||||
|
safetyMargin: number;
|
||||||
|
cfg: MapReduceConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapReduceResult {
|
||||||
|
/** 통합된 관련 자료. 정상 경로에서 사용자 메시지 본문을 이걸로 대체. */
|
||||||
|
condensedContext: string;
|
||||||
|
chunkCount: number;
|
||||||
|
relevantCount: number;
|
||||||
|
reduceDepth: number;
|
||||||
|
/** 모든 청크가 무관 → 호출 측에서 정직한 에스컬레이션. */
|
||||||
|
allIrrelevant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IRRELEVANT_MARKER = '(관련 없음)';
|
||||||
|
/** 추출/통합 호출이 쓸 출력 토큰 상한 — 발췌는 원문보다 짧으므로 보수적으로. */
|
||||||
|
const EXTRACT_OUTPUT_TOKENS = 1024;
|
||||||
|
const REDUCE_OUTPUT_TOKENS = 2048;
|
||||||
|
/** 토큰→문자 환산(한국어 보수치 ~2자/토큰). 청크 크기 산정용. */
|
||||||
|
const CHARS_PER_TOKEN = 2;
|
||||||
|
|
||||||
|
/** 유효 창에서 입력에 쓸 수 있는 토큰 예산. computeBudgetedRequest 와 같은 공식. */
|
||||||
|
export function inputBudgetTokens(windowTokens: number, systemTokens: number, safetyMargin: number): number {
|
||||||
|
const outputReserve = Math.max(2048, Math.floor(windowTokens * 0.1));
|
||||||
|
return Math.max(256, windowTokens - systemTokens - outputReserve - safetyMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 입력이 map-reduce 대상인지. (cfg.enabled + 입력이 창의 triggerRatio 초과) */
|
||||||
|
export function shouldMapReduce(latestUserTokens: number, windowTokens: number, cfg: MapReduceConfig): boolean {
|
||||||
|
if (!cfg.enabled) return false;
|
||||||
|
if (windowTokens <= 0) return false;
|
||||||
|
return latestUserTokens > windowTokens * cfg.triggerRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한 청크가 (자기 + 추출 프롬프트 오버헤드 + 출력 예약)으로 창에 들어가도록 문자 상한 산정. */
|
||||||
|
export function chunkCharBudget(windowTokens: number, systemTokens: number, safetyMargin: number): number {
|
||||||
|
// 추출 프롬프트 자체 오버헤드(지시문 + intent) ~800 토큰 가정.
|
||||||
|
const promptOverhead = 800;
|
||||||
|
const perChunkTokenBudget = Math.max(
|
||||||
|
512,
|
||||||
|
windowTokens - systemTokens - safetyMargin - EXTRACT_OUTPUT_TOKENS - promptOverhead
|
||||||
|
);
|
||||||
|
// 보수적으로 70% 만 사용 (추정 오차 흡수).
|
||||||
|
return Math.floor(perChunkTokenBudget * CHARS_PER_TOKEN * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractPrompt(intent: string, chunkText: string, idx: number, total: number): ChatMessage[] {
|
||||||
|
const system = [
|
||||||
|
'너는 긴 자료에서 사용자 요청에 필요한 사실만 정확히 발췌하는 추출기다.',
|
||||||
|
'규칙:',
|
||||||
|
'1) 사용자 요청과 직접 관련된 사실·수치·발언·결정사항만 원문 표현 그대로 발췌한다.',
|
||||||
|
'2) 요약·추측·창작·일반화 금지. 자료에 없는 내용은 절대 만들지 않는다.',
|
||||||
|
`3) 이 조각에 관련 내용이 전혀 없으면 정확히 "${IRRELEVANT_MARKER}" 한 줄만 출력한다.`,
|
||||||
|
'4) 불릿(-)으로 간결하게. 각 항목은 자료에 근거해야 한다.',
|
||||||
|
].join('\n');
|
||||||
|
const user = [
|
||||||
|
`[사용자 요청 의도]\n${intent}`,
|
||||||
|
`\n[자료 조각 ${idx}/${total}]\n${chunkText}`,
|
||||||
|
`\n위 조각에서 요청 수행에 필요한 사실만 발췌하라. 없으면 "${IRRELEVANT_MARKER}".`,
|
||||||
|
].join('\n');
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReducePrompt(intent: string, extractions: string): ChatMessage[] {
|
||||||
|
const system = [
|
||||||
|
'너는 여러 발췌를 중복 없이 하나로 통합하는 통합기다.',
|
||||||
|
'규칙: 발췌에 있는 사실만 유지하고, 중복은 병합한다. 추측·창작 금지.',
|
||||||
|
'원문 사실과 (있다면) [조각 k] 출처 표기를 보존한다.',
|
||||||
|
].join('\n');
|
||||||
|
const user = `[사용자 요청 의도]\n${intent}\n\n[발췌 모음]\n${extractions}\n\n위 발췌들을 요청 관점에서 중복 없이 통합하라.`;
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 동시성 제한 map. 순서 보존. */
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const n = Math.max(1, Math.min(limit, items.length));
|
||||||
|
const workers = Array.from({ length: n }, async () => {
|
||||||
|
while (true) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
const i = next++;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
results[i] = await fn(items[i], i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIrrelevant(text: string): boolean {
|
||||||
|
const t = (text || '').trim();
|
||||||
|
return t.length === 0 || t === IRRELEVANT_MARKER || /^\(?\s*관련\s*없음\s*\)?$/.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 큰 입력을 청크→추출→통합한다. 호출 측은 trigger 를 이미 통과시킨 뒤 호출한다고 가정하지만,
|
||||||
|
* 방어적으로 단일 청크면 추출만 하고 통합은 건너뛴다.
|
||||||
|
*/
|
||||||
|
export async function runMapReduce(deps: MapReduceDeps, params: MapReduceParams): Promise<MapReduceResult> {
|
||||||
|
const { intent, largeContent, windowTokens, systemTokens, safetyMargin, cfg } = params;
|
||||||
|
const log = deps.log ?? (() => {});
|
||||||
|
|
||||||
|
const targetChars = chunkCharBudget(windowTokens, systemTokens, safetyMargin);
|
||||||
|
const sections = splitIntoSections(largeContent, {
|
||||||
|
targetChars,
|
||||||
|
maxChars: targetChars * 2,
|
||||||
|
});
|
||||||
|
const chunks = sections.map((s) => s.text);
|
||||||
|
log('Map-reduce: split large input into chunks.', { chunkCount: chunks.length, targetChars });
|
||||||
|
|
||||||
|
// ── Map: 각 청크 → 질의 인지형 추출 ──────────────────────────────────
|
||||||
|
const extracted = await mapWithConcurrency(
|
||||||
|
chunks,
|
||||||
|
cfg.concurrency,
|
||||||
|
async (chunk, i) => {
|
||||||
|
if (deps.signal?.aborted) return '';
|
||||||
|
try {
|
||||||
|
const text = await deps.callLLM(
|
||||||
|
buildExtractPrompt(intent, chunk, i + 1, chunks.length),
|
||||||
|
EXTRACT_OUTPUT_TOKENS,
|
||||||
|
);
|
||||||
|
return text ?? '';
|
||||||
|
} catch (e: any) {
|
||||||
|
// 한 청크 실패가 전체를 막지 않게 — 원문 일부로 폴백(빈손보다 낫다).
|
||||||
|
log('Map-reduce: chunk extraction failed — falling back to truncated raw.', { chunk: i + 1, error: e?.message ?? String(e) });
|
||||||
|
return chunk.slice(0, targetChars);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deps.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
const relevant: string[] = [];
|
||||||
|
extracted.forEach((text, i) => {
|
||||||
|
if (isIrrelevant(text)) return;
|
||||||
|
relevant.push(cfg.showProvenance ? `[조각 ${i + 1}]\n${text.trim()}` : text.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (relevant.length === 0) {
|
||||||
|
log('Map-reduce: every chunk was irrelevant.', { chunkCount: chunks.length });
|
||||||
|
return { condensedContext: '', chunkCount: chunks.length, relevantCount: 0, reduceDepth: 0, allIrrelevant: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reduce: 합본이 입력 예산에 들어갈 때까지 계층적으로 통합 ──────────
|
||||||
|
const budget = inputBudgetTokens(windowTokens, systemTokens, safetyMargin);
|
||||||
|
// intent 분량 + 헤더 여유를 위해 예산의 80% 를 컨텍스트 상한으로.
|
||||||
|
const contextCeiling = Math.floor(budget * 0.8);
|
||||||
|
|
||||||
|
let current = relevant;
|
||||||
|
let depth = 0;
|
||||||
|
while (depth < cfg.maxDepth) {
|
||||||
|
const joined = current.join('\n\n');
|
||||||
|
if (deps.estimateTokens(joined) <= contextCeiling) break;
|
||||||
|
// 그룹으로 묶어 각 그룹을 통합 → 개수 감소.
|
||||||
|
const groups = groupToFit(current, deps.estimateTokens, contextCeiling);
|
||||||
|
if (groups.length >= current.length) break; // 더 못 줄임 — 마지막에 잘림 처리
|
||||||
|
log('Map-reduce: hierarchical reduce round.', { depth: depth + 1, from: current.length, to: groups.length });
|
||||||
|
current = await mapWithConcurrency(
|
||||||
|
groups,
|
||||||
|
cfg.concurrency,
|
||||||
|
async (group) => {
|
||||||
|
if (deps.signal?.aborted) return group.join('\n\n');
|
||||||
|
try {
|
||||||
|
return await deps.callLLM(buildReducePrompt(intent, group.join('\n\n')), REDUCE_OUTPUT_TOKENS);
|
||||||
|
} catch {
|
||||||
|
return group.join('\n\n'); // 통합 실패 → 원본 그룹 유지
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deps.signal,
|
||||||
|
);
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let condensed = current.join('\n\n');
|
||||||
|
// maxDepth 도달했는데도 넘치면 하드 트렁케이트(서버 overflow 방지) + 경고는 호출 측에서.
|
||||||
|
if (deps.estimateTokens(condensed) > contextCeiling) {
|
||||||
|
const charCeiling = contextCeiling * CHARS_PER_TOKEN;
|
||||||
|
condensed = condensed.slice(0, charCeiling) + '\n\n[…자료가 많아 일부 생략됨]';
|
||||||
|
log('Map-reduce: reduce hit max depth and was hard-truncated.', { maxDepth: cfg.maxDepth });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
condensedContext: condensed,
|
||||||
|
chunkCount: chunks.length,
|
||||||
|
relevantCount: relevant.length,
|
||||||
|
reduceDepth: depth,
|
||||||
|
allIrrelevant: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 항목들을 순서대로 누적해 ceiling 을 넘기 직전까지 한 그룹으로 묶는다. */
|
||||||
|
function groupToFit(items: string[], estimate: (s: string) => number, ceiling: number): string[][] {
|
||||||
|
const groups: string[][] = [];
|
||||||
|
let cur: string[] = [];
|
||||||
|
let curTokens = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const t = estimate(item);
|
||||||
|
if (cur.length > 0 && curTokens + t > ceiling) {
|
||||||
|
groups.push(cur);
|
||||||
|
cur = [];
|
||||||
|
curTokens = 0;
|
||||||
|
}
|
||||||
|
cur.push(item);
|
||||||
|
curTokens += t;
|
||||||
|
}
|
||||||
|
if (cur.length > 0) groups.push(cur);
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -40,6 +40,17 @@ export interface IAgentConfig {
|
|||||||
autoCompactHistory: boolean;
|
autoCompactHistory: boolean;
|
||||||
/** 작은 모델(≤4B) 감지 시 예산 계산에 쓸 유효 context window 상한. 0 = 비활성화. */
|
/** 작은 모델(≤4B) 감지 시 예산 계산에 쓸 유효 context window 상한. 0 = 비활성화. */
|
||||||
smallModelContextCap: number;
|
smallModelContextCap: number;
|
||||||
|
// ─── 큰 입력 Map-Reduce (긴 회의록/리서치 덤프 청킹·통합) ───
|
||||||
|
/** 단일 사용자 입력이 창을 넘으면 청크→추출→통합으로 처리. 끄면 기존 단발 경로(잘릴 수 있음). */
|
||||||
|
largeInputMapReduce: boolean;
|
||||||
|
/** 단일 입력 토큰이 (유효 창 × 이 비율)을 넘으면 map-reduce 발동. 기본 0.6. */
|
||||||
|
mapReduceTriggerRatio: number;
|
||||||
|
/** 청크 추출 동시성. 로컬 단일 GPU 보호용으로 낮게. 기본 2. */
|
||||||
|
mapReduceConcurrency: number;
|
||||||
|
/** 추출 합본이 창을 넘을 때 계층적 통합 최대 깊이. 기본 3. */
|
||||||
|
mapReduceMaxDepth: number;
|
||||||
|
/** 최종 답변에 `[조각 k]` 출처 태그를 노출. 기본 false. */
|
||||||
|
mapReduceShowProvenance: boolean;
|
||||||
// ─── 응답 복구 (Thought Quarantine / Auto-Continuation) ───
|
// ─── 응답 복구 (Thought Quarantine / Auto-Continuation) ───
|
||||||
/** 답변이 출력 토큰 한계에 걸리면 사용자 개입 없이 내부적으로 이어서 생성. */
|
/** 답변이 출력 토큰 한계에 걸리면 사용자 개입 없이 내부적으로 이어서 생성. */
|
||||||
autoContinueOnOutputLimit: boolean;
|
autoContinueOnOutputLimit: boolean;
|
||||||
@@ -500,6 +511,11 @@ export function getConfig(): IAgentConfig {
|
|||||||
})(),
|
})(),
|
||||||
autoCompactHistory: cfg.get<boolean>('autoCompactHistory', true),
|
autoCompactHistory: cfg.get<boolean>('autoCompactHistory', true),
|
||||||
smallModelContextCap: Math.max(0, cfg.get<number>('smallModelContextCap', 0)),
|
smallModelContextCap: Math.max(0, cfg.get<number>('smallModelContextCap', 0)),
|
||||||
|
largeInputMapReduce: cfg.get<boolean>('largeInputMapReduce', true),
|
||||||
|
mapReduceTriggerRatio: Math.min(0.95, Math.max(0.3, cfg.get<number>('mapReduceTriggerRatio', 0.6))),
|
||||||
|
mapReduceConcurrency: Math.min(8, Math.max(1, cfg.get<number>('mapReduceConcurrency', 2))),
|
||||||
|
mapReduceMaxDepth: Math.min(6, Math.max(1, cfg.get<number>('mapReduceMaxDepth', 3))),
|
||||||
|
mapReduceShowProvenance: cfg.get<boolean>('mapReduceShowProvenance', false),
|
||||||
autoContinueOnOutputLimit: cfg.get<boolean>('autoContinueOnOutputLimit', true),
|
autoContinueOnOutputLimit: cfg.get<boolean>('autoContinueOnOutputLimit', true),
|
||||||
maxAutoContinuations: Math.max(0, Math.min(10, cfg.get<number>('maxAutoContinuations', 4))),
|
maxAutoContinuations: Math.max(0, Math.min(10, cfg.get<number>('maxAutoContinuations', 4))),
|
||||||
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export interface ILMStudioClient {
|
|||||||
* "Model is disposed!" or "lock() request could not be registered" error.
|
* "Model is disposed!" or "lock() request could not be registered" error.
|
||||||
*/
|
*/
|
||||||
getModelHandle(modelKey: string, options?: { refresh?: boolean }): Promise<LLM>;
|
getModelHandle(modelKey: string, options?: { refresh?: boolean }): Promise<LLM>;
|
||||||
|
/**
|
||||||
|
* The model's *actually-loaded* context window in tokens (LM Studio's
|
||||||
|
* `llm.getContextLength()`), or `undefined` if it can't be determined.
|
||||||
|
*
|
||||||
|
* The user-facing `g1nation.contextLength` setting is only a budgeting
|
||||||
|
* intent — the real ceiling is whatever window the model was loaded with.
|
||||||
|
* Budgeting against the larger of the two silently overflows the server,
|
||||||
|
* which then truncates the prompt or emits EOS as the first token (empty
|
||||||
|
* answer). Cached per-key because it only changes on reload.
|
||||||
|
*/
|
||||||
|
getModelContextLength(modelKey: string): Promise<number | undefined>;
|
||||||
isReachable(): Promise<boolean>;
|
isReachable(): Promise<boolean>;
|
||||||
setBaseUrl(httpBaseUrl: string): void;
|
setBaseUrl(httpBaseUrl: string): void;
|
||||||
}
|
}
|
||||||
@@ -84,8 +95,10 @@ export class LMStudioClient implements ILMStudioClient {
|
|||||||
private _wsUrl: string | undefined;
|
private _wsUrl: string | undefined;
|
||||||
private _loadedCache: { value: string[]; expiresAt: number } | undefined;
|
private _loadedCache: { value: string[]; expiresAt: number } | undefined;
|
||||||
private _downloadedCache: { value: string[]; expiresAt: number } | undefined;
|
private _downloadedCache: { value: string[]; expiresAt: number } | undefined;
|
||||||
|
private _contextLengthCache = new Map<string, { value: number; expiresAt: number }>();
|
||||||
private static readonly DEFAULT_LOADED_CACHE_TTL_MS = 5000;
|
private static readonly DEFAULT_LOADED_CACHE_TTL_MS = 5000;
|
||||||
private static readonly DEFAULT_DOWNLOADED_CACHE_TTL_MS = 60_000;
|
private static readonly DEFAULT_DOWNLOADED_CACHE_TTL_MS = 60_000;
|
||||||
|
private static readonly DEFAULT_CONTEXT_LENGTH_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
constructor(httpBaseUrl: string) {
|
constructor(httpBaseUrl: string) {
|
||||||
this.setBaseUrl(httpBaseUrl);
|
this.setBaseUrl(httpBaseUrl);
|
||||||
@@ -98,6 +111,7 @@ export class LMStudioClient implements ILMStudioClient {
|
|||||||
this._sdk = undefined;
|
this._sdk = undefined;
|
||||||
this._loadedCache = undefined;
|
this._loadedCache = undefined;
|
||||||
this._downloadedCache = undefined;
|
this._downloadedCache = undefined;
|
||||||
|
this._contextLengthCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +184,7 @@ export class LMStudioClient implements ILMStudioClient {
|
|||||||
invalidateCaches(): void {
|
invalidateCaches(): void {
|
||||||
this._loadedCache = undefined;
|
this._loadedCache = undefined;
|
||||||
this._downloadedCache = undefined;
|
this._downloadedCache = undefined;
|
||||||
|
this._contextLengthCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listLoaded(): Promise<string[]> {
|
async listLoaded(): Promise<string[]> {
|
||||||
@@ -243,6 +258,36 @@ export class LMStudioClient implements ILMStudioClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelContextLength(modelKey: string): Promise<number | undefined> {
|
||||||
|
const key = (modelKey || '').trim();
|
||||||
|
if (!key) return undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this._contextLengthCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > now) return cached.value;
|
||||||
|
try {
|
||||||
|
// Reuses the same handle the stream will use. If the model isn't
|
||||||
|
// loaded yet this forces a JIT load — acceptable since the very next
|
||||||
|
// step streams from it anyway. Best-effort: any failure (incl. the
|
||||||
|
// load-coalescing "Operation canceled" race) falls back to undefined
|
||||||
|
// so the caller keeps the configured window.
|
||||||
|
const handle: any = await this.getSdk().llm.model(key);
|
||||||
|
const len = typeof handle?.getContextLength === 'function'
|
||||||
|
? await handle.getContextLength()
|
||||||
|
: undefined;
|
||||||
|
if (typeof len === 'number' && Number.isFinite(len) && len > 0) {
|
||||||
|
this._contextLengthCache.set(key, {
|
||||||
|
value: len,
|
||||||
|
expiresAt: now + LMStudioClient.DEFAULT_CONTEXT_LENGTH_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('Failed to query LM Studio model context length.', { modelKey: key, error: e?.message ?? String(e) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async isReachable(): Promise<boolean> {
|
async isReachable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.getSdk().llm.listLoaded();
|
await this.getSdk().llm.listLoaded();
|
||||||
|
|||||||
+61
-12
@@ -83,6 +83,12 @@ export interface IChatStreamer {
|
|||||||
* silently-disposed handle that needs a fresh WebSocket round-trip.
|
* silently-disposed handle that needs a fresh WebSocket round-trip.
|
||||||
*/
|
*/
|
||||||
resetHandle?(modelName: string): Promise<void>;
|
resetHandle?(modelName: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* The model's actually-loaded context window in tokens, or `undefined` if
|
||||||
|
* unavailable. Callers use this to budget against the real ceiling instead
|
||||||
|
* of the user's `contextLength` setting. Best-effort — never throws.
|
||||||
|
*/
|
||||||
|
getModelContextLength?(modelName: string): Promise<number | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +121,28 @@ export class LMStudioStreamer implements IChatStreamer {
|
|||||||
// would duplicate tokens.
|
// would duplicate tokens.
|
||||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
const refresh = attempt > 1;
|
const refresh = attempt > 1;
|
||||||
const model = await this.client.getModelHandle(trimmedModel, refresh ? { refresh: true } : undefined);
|
// Handle acquisition is guarded on its own: it happens BEFORE the
|
||||||
|
// stream try/catch below, so without this an "Operation canceled"
|
||||||
|
// (the lifecycle manager's concurrent load for this same model was
|
||||||
|
// superseded/aborted and the SDK coalesced our JIT lookup into that
|
||||||
|
// dead load), a disposed handle, or a dropped WebSocket would crash
|
||||||
|
// the whole turn with no retry. Large inputs make this far more
|
||||||
|
// likely: loading a big model to hold a large prompt is slow, which
|
||||||
|
// widens the window for a concurrent switch/abort to land mid-load.
|
||||||
|
let model: Awaited<ReturnType<ILMStudioClient['getModelHandle']>>;
|
||||||
|
try {
|
||||||
|
model = await this.client.getModelHandle(trimmedModel, refresh ? { refresh: true } : undefined);
|
||||||
|
} catch (acqErr: any) {
|
||||||
|
// Genuine user cancel — don't retry, just stop quietly.
|
||||||
|
if (req.signal?.aborted || acqErr?.name === 'AbortError') return;
|
||||||
|
const acqMsg = String(acqErr?.message ?? acqErr);
|
||||||
|
if (this.isTransientHandleError(acqMsg) && attempt === 1) {
|
||||||
|
logInfo('LM Studio model handle acquisition hit a transient error — retrying with a fresh SDK.', { model: trimmedModel, error: acqMsg });
|
||||||
|
continue; // attempt 2 passes { refresh: true } → recreates the SDK client
|
||||||
|
}
|
||||||
|
logError('LM Studio model handle acquisition failed.', { model: trimmedModel, error: acqMsg, attempt });
|
||||||
|
throw acqErr;
|
||||||
|
}
|
||||||
logInfo('LM Studio SDK chat stream started.', { model: trimmedModel, messageCount: req.messages.length, attempt });
|
logInfo('LM Studio SDK chat stream started.', { model: trimmedModel, messageCount: req.messages.length, attempt });
|
||||||
|
|
||||||
// Sampling defaults match the historical glitch-suppression preset for small /
|
// Sampling defaults match the historical glitch-suppression preset for small /
|
||||||
@@ -216,17 +243,7 @@ export class LMStudioStreamer implements IChatStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errMsg = String(caught?.message ?? caught);
|
const errMsg = String(caught?.message ?? caught);
|
||||||
// Broaden the "handle is bound to a dead WebSocket binding" detection. All of
|
const handleDead = this.isTransientHandleError(errMsg);
|
||||||
// these resolve with the same fix (recreate the SDK client so the next
|
|
||||||
// llm.model() lookup mints a fresh handle).
|
|
||||||
const handleDead =
|
|
||||||
/\bdisposed\b/i.test(errMsg)
|
|
||||||
|| /lock\(\) request could not be registered/i.test(errMsg)
|
|
||||||
|| /channel\s+closed/i.test(errMsg)
|
|
||||||
|| /WebSocket\s+(?:is\s+not\s+open|closed|disconnected)/i.test(errMsg)
|
|
||||||
|| /Connection\s+(?:lost|reset|closed)/i.test(errMsg)
|
|
||||||
|| /\bECONNRESET\b/i.test(errMsg)
|
|
||||||
|| /socket\s+hang\s*up/i.test(errMsg);
|
|
||||||
|
|
||||||
if (handleDead && yielded === 0 && attempt === 1) {
|
if (handleDead && yielded === 0 && attempt === 1) {
|
||||||
logInfo('Dead LM Studio handle detected — retrying with a fresh SDK.', { model: trimmedModel, error: errMsg });
|
logInfo('Dead LM Studio handle detected — retrying with a fresh SDK.', { model: trimmedModel, error: errMsg });
|
||||||
@@ -238,6 +255,38 @@ export class LMStudioStreamer implements IChatStreamer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an error message indicates the SDK handle / WebSocket binding is
|
||||||
|
* dead, or its in-flight (coalesced) load was canceled out from under us —
|
||||||
|
* all fixable by recreating the SDK client so the next `llm.model()` lookup
|
||||||
|
* mints a fresh handle. Deliberately excludes genuine user aborts, which are
|
||||||
|
* caught earlier via `req.signal.aborted` / `AbortError` before reaching here.
|
||||||
|
*/
|
||||||
|
private isTransientHandleError(errMsg: string): boolean {
|
||||||
|
return (
|
||||||
|
/\bdisposed\b/i.test(errMsg)
|
||||||
|
|| /lock\(\) request could not be registered/i.test(errMsg)
|
||||||
|
|| /channel\s+closed/i.test(errMsg)
|
||||||
|
|| /WebSocket\s+(?:is\s+not\s+open|closed|disconnected)/i.test(errMsg)
|
||||||
|
|| /Connection\s+(?:lost|reset|closed)/i.test(errMsg)
|
||||||
|
|| /\bECONNRESET\b/i.test(errMsg)
|
||||||
|
|| /socket\s+hang\s*up/i.test(errMsg)
|
||||||
|
// The lifecycle manager's load got superseded/aborted and the SDK
|
||||||
|
// coalesced our JIT model() lookup into that canceled load.
|
||||||
|
|| /\boperation\s+cancell?ed\b/i.test(errMsg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModelContextLength(modelName: string): Promise<number | undefined> {
|
||||||
|
const trimmed = (modelName || '').trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
try {
|
||||||
|
return await this.client.getModelContextLength(trimmed);
|
||||||
|
} catch {
|
||||||
|
return undefined; // best-effort — caller falls back to the configured window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async resetHandle(modelName: string): Promise<void> {
|
async resetHandle(modelName: string): Promise<void> {
|
||||||
const trimmed = (modelName || '').trim();
|
const trimmed = (modelName || '').trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Phase 1 — context-window alignment.
|
||||||
|
*
|
||||||
|
* The budgeter must clamp to the model's ACTUALLY-loaded window when it's
|
||||||
|
* smaller than the user's `contextLength` setting, so a model loaded with a
|
||||||
|
* smaller window than the setting never silently overflows the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computeBudgetedRequest } from '../src/agent/handlePrompt/computeBudgetedRequest';
|
||||||
|
import type { ChatMessage } from '../src/agent';
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
contextLength: 32768,
|
||||||
|
maxOutputTokens: 4096,
|
||||||
|
contextSafetyMargin: 512,
|
||||||
|
smallModelContextCap: 0, // disabled
|
||||||
|
autoCompactHistory: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(overrides: { actualContextLength?: number; config?: Partial<typeof baseConfig> } = {}) {
|
||||||
|
const reqMessages: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||||
|
return computeBudgetedRequest({
|
||||||
|
fullSystemPrompt: 'You are a helpful assistant.',
|
||||||
|
reqMessages,
|
||||||
|
actualModel: 'some-13b-model',
|
||||||
|
config: { ...baseConfig, ...overrides.config },
|
||||||
|
imageCount: 0,
|
||||||
|
actualContextLength: overrides.actualContextLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeBudgetedRequest — real-window alignment', () => {
|
||||||
|
test('clamps to the actual loaded window when it is smaller than the setting', () => {
|
||||||
|
const r = run({ actualContextLength: 8192 });
|
||||||
|
expect(r.windowMismatch).toBe(true);
|
||||||
|
expect(r.effectiveContextLength).toBe(8192);
|
||||||
|
expect(r.ctxLimits.contextLength).toBe(8192);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the configured window when the actual window is unknown', () => {
|
||||||
|
const r = run({ actualContextLength: undefined });
|
||||||
|
expect(r.windowMismatch).toBe(false);
|
||||||
|
expect(r.effectiveContextLength).toBe(32768);
|
||||||
|
expect(r.ctxLimits.contextLength).toBe(32768);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not raise the window when the actual window is larger than the setting', () => {
|
||||||
|
const r = run({ actualContextLength: 131072 });
|
||||||
|
expect(r.windowMismatch).toBe(false);
|
||||||
|
expect(r.effectiveContextLength).toBe(32768); // setting is the lower bound here
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a non-positive / non-finite actual window (falls back to setting)', () => {
|
||||||
|
expect(run({ actualContextLength: 0 }).effectiveContextLength).toBe(32768);
|
||||||
|
expect(run({ actualContextLength: -5 }).effectiveContextLength).toBe(32768);
|
||||||
|
expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Phase 2 — large-input map-reduce core.
|
||||||
|
*
|
||||||
|
* Pure orchestration with an injected `callLLM`, so no network / SDK is touched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
runMapReduce,
|
||||||
|
shouldMapReduce,
|
||||||
|
chunkCharBudget,
|
||||||
|
inputBudgetTokens,
|
||||||
|
type MapReduceConfig,
|
||||||
|
type MapReduceDeps,
|
||||||
|
} from '../src/agent/handlePrompt/largeInputMapReduce';
|
||||||
|
import type { ChatMessage } from '../src/agent';
|
||||||
|
|
||||||
|
const estimateTokens = (s: string) => Math.ceil((s || '').length / 4);
|
||||||
|
|
||||||
|
const cfg: MapReduceConfig = {
|
||||||
|
enabled: true,
|
||||||
|
triggerRatio: 0.6,
|
||||||
|
concurrency: 2,
|
||||||
|
maxDepth: 3,
|
||||||
|
showProvenance: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isExtract(messages: ChatMessage[]): boolean {
|
||||||
|
return /추출기/.test(messages[0]?.content ?? '');
|
||||||
|
}
|
||||||
|
function chunkLabel(messages: ChatMessage[]): string {
|
||||||
|
const m = (messages[1]?.content ?? '').match(/자료 조각 (\d+)\/(\d+)/);
|
||||||
|
return m ? m[1] : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~12 short markdown sections → forces multiple chunks under a small window.
|
||||||
|
const bigContent = Array.from({ length: 12 }, (_, i) =>
|
||||||
|
`## 섹션 ${i + 1}\n안건 ${i + 1}: 결정사항과 수치 ${i * 10}. ` + '내용 '.repeat(40)
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
describe('shouldMapReduce', () => {
|
||||||
|
test('triggers only above window * triggerRatio and when enabled', () => {
|
||||||
|
expect(shouldMapReduce(6200, 10000, cfg)).toBe(true); // > 6000
|
||||||
|
expect(shouldMapReduce(5000, 10000, cfg)).toBe(false); // < 6000
|
||||||
|
expect(shouldMapReduce(99999, 10000, { ...cfg, enabled: false })).toBe(false);
|
||||||
|
expect(shouldMapReduce(100, 0, cfg)).toBe(false); // unknown window
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('budget helpers', () => {
|
||||||
|
test('inputBudgetTokens reserves output + safety', () => {
|
||||||
|
// 10000 - sys(500) - max(2048, 1000)=2048 - safety(512) = 6940
|
||||||
|
expect(inputBudgetTokens(10000, 500, 512)).toBe(6940);
|
||||||
|
});
|
||||||
|
test('chunkCharBudget is positive and scales with the window', () => {
|
||||||
|
const small = chunkCharBudget(4000, 200, 512);
|
||||||
|
const big = chunkCharBudget(16000, 200, 512);
|
||||||
|
expect(small).toBeGreaterThan(0);
|
||||||
|
expect(big).toBeGreaterThan(small);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runMapReduce', () => {
|
||||||
|
function deps(callLLM: MapReduceDeps['callLLM']): MapReduceDeps {
|
||||||
|
return { callLLM, estimateTokens };
|
||||||
|
}
|
||||||
|
const params = {
|
||||||
|
intent: '회의록을 안건별로 정리해줘',
|
||||||
|
largeContent: bigContent,
|
||||||
|
windowTokens: 4000,
|
||||||
|
systemTokens: 200,
|
||||||
|
safetyMargin: 512,
|
||||||
|
cfg,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('extracts relevant facts per chunk and condenses them', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const r = await runMapReduce(
|
||||||
|
deps(async (messages) => {
|
||||||
|
expect(isExtract(messages)).toBe(true);
|
||||||
|
const k = chunkLabel(messages);
|
||||||
|
seen.push(k);
|
||||||
|
return `추출-${k}`;
|
||||||
|
}),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
expect(r.allIrrelevant).toBe(false);
|
||||||
|
expect(r.chunkCount).toBeGreaterThan(1);
|
||||||
|
expect(r.relevantCount).toBe(r.chunkCount);
|
||||||
|
expect(r.condensedContext).toContain('추출-1');
|
||||||
|
// every chunk was visited
|
||||||
|
expect(seen.length).toBe(r.chunkCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all-irrelevant chunks → allIrrelevant with empty context', async () => {
|
||||||
|
const r = await runMapReduce(
|
||||||
|
deps(async () => '(관련 없음)'),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
expect(r.allIrrelevant).toBe(true);
|
||||||
|
expect(r.relevantCount).toBe(0);
|
||||||
|
expect(r.condensedContext).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects concurrency limit', async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
await runMapReduce(
|
||||||
|
deps(async (messages) => {
|
||||||
|
active++;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((res) => setTimeout(res, 5));
|
||||||
|
active--;
|
||||||
|
return `x-${chunkLabel(messages)}`;
|
||||||
|
}),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
expect(peak).toBeLessThanOrEqual(cfg.concurrency);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing chunk extraction falls back to truncated raw (not a crash)', async () => {
|
||||||
|
let call = 0;
|
||||||
|
const r = await runMapReduce(
|
||||||
|
deps(async (messages) => {
|
||||||
|
if (isExtract(messages) && ++call === 1) throw new Error('boom');
|
||||||
|
return `ok-${chunkLabel(messages)}`;
|
||||||
|
}),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
expect(r.allIrrelevant).toBe(false);
|
||||||
|
// The failed chunk still contributed (raw fallback), so relevantCount === chunkCount.
|
||||||
|
expect(r.relevantCount).toBe(r.chunkCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags provenance when showProvenance is on', async () => {
|
||||||
|
const r = await runMapReduce(
|
||||||
|
deps(async (messages) => `발췌-${chunkLabel(messages)}`),
|
||||||
|
{ ...params, cfg: { ...cfg, showProvenance: true } },
|
||||||
|
);
|
||||||
|
expect(r.condensedContext).toMatch(/\[조각 \d+\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hierarchical reduce kicks in when extractions overflow the context ceiling', async () => {
|
||||||
|
// Tiny window so even a few extractions exceed the ceiling → reduce rounds run.
|
||||||
|
let reduceCalls = 0;
|
||||||
|
const r = await runMapReduce(
|
||||||
|
deps(async (messages) => {
|
||||||
|
if (isExtract(messages)) {
|
||||||
|
return '관련 사실 '.repeat(60); // big extraction per chunk
|
||||||
|
}
|
||||||
|
reduceCalls++;
|
||||||
|
return '통합본'; // reduce collapses to something small
|
||||||
|
}),
|
||||||
|
{ ...params, windowTokens: 2200 },
|
||||||
|
);
|
||||||
|
expect(reduceCalls).toBeGreaterThan(0);
|
||||||
|
expect(r.reduceDepth).toBeGreaterThan(0);
|
||||||
|
expect(r.allIrrelevant).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,6 +78,10 @@ class FakeLMStudioClient implements ILMStudioClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelContextLength(_modelKey: string): Promise<number | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async listLoadedCached(): Promise<string[]> {
|
async listLoadedCached(): Promise<string[]> {
|
||||||
return [...this.loaded];
|
return [...this.loaded];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class FakeModel {
|
|||||||
class FakeClient implements ILMStudioClient {
|
class FakeClient implements ILMStudioClient {
|
||||||
public model: FakeModel;
|
public model: FakeModel;
|
||||||
public getModelHandleCalls: string[] = [];
|
public getModelHandleCalls: string[] = [];
|
||||||
|
public getModelHandleOpts: Array<{ refresh?: boolean } | undefined> = [];
|
||||||
|
/** Errors to throw on successive getModelHandle calls before returning the model. */
|
||||||
|
public handleAcqFailures: Error[] = [];
|
||||||
|
|
||||||
constructor(model: FakeModel = new FakeModel()) {
|
constructor(model: FakeModel = new FakeModel()) {
|
||||||
this.model = model;
|
this.model = model;
|
||||||
@@ -83,10 +86,18 @@ class FakeClient implements ILMStudioClient {
|
|||||||
async listDownloadedCached(): Promise<string[]> { return []; }
|
async listDownloadedCached(): Promise<string[]> { return []; }
|
||||||
async isReachable(): Promise<boolean> { return true; }
|
async isReachable(): Promise<boolean> { return true; }
|
||||||
|
|
||||||
async getModelHandle(modelKey: string): Promise<any> {
|
async getModelHandle(modelKey: string, options?: { refresh?: boolean }): Promise<any> {
|
||||||
this.getModelHandleCalls.push(modelKey);
|
this.getModelHandleCalls.push(modelKey);
|
||||||
|
this.getModelHandleOpts.push(options);
|
||||||
|
const failure = this.handleAcqFailures.shift();
|
||||||
|
if (failure) throw failure;
|
||||||
return this.model;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public contextLength: number | undefined = undefined;
|
||||||
|
async getModelContextLength(_modelKey: string): Promise<number | undefined> {
|
||||||
|
return this.contextLength;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The streamer emits a trailing { token: '', stopReason } event on normal completion;
|
// The streamer emits a trailing { token: '', stopReason } event on normal completion;
|
||||||
@@ -209,6 +220,68 @@ describe('LMStudioStreamer', () => {
|
|||||||
expect(out).toEqual(['a']);
|
expect(out).toEqual(['a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transient "Operation canceled" on handle acquisition is retried with a fresh SDK', async () => {
|
||||||
|
// The lifecycle manager's concurrent load for this model got superseded;
|
||||||
|
// the SDK coalesced our JIT model() lookup into that aborted load. The
|
||||||
|
// first getModelHandle throws — the streamer must recreate the SDK
|
||||||
|
// (refresh) and retry rather than crashing the whole turn.
|
||||||
|
const client = new FakeClient(new FakeModel({ chunks: ['ok'] }));
|
||||||
|
client.handleAcqFailures = [new Error('Failed to acquire LM Studio model handle "m1": Operation canceled.')];
|
||||||
|
const streamer = new LMStudioStreamer(client);
|
||||||
|
const tokens = await collect(streamer.stream({
|
||||||
|
modelName: 'm1',
|
||||||
|
messages: [{ role: 'user', content: 'hi' }],
|
||||||
|
temperature: 0.2,
|
||||||
|
}));
|
||||||
|
expect(tokens).toEqual(['ok']);
|
||||||
|
expect(client.getModelHandleCalls).toEqual(['m1', 'm1']);
|
||||||
|
// First attempt: no refresh. Retry: refresh=true so the SDK is recreated.
|
||||||
|
expect(client.getModelHandleOpts[0]).toBeUndefined();
|
||||||
|
expect(client.getModelHandleOpts[1]).toEqual({ refresh: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-transient handle acquisition error is thrown without retry', async () => {
|
||||||
|
const client = new FakeClient();
|
||||||
|
client.handleAcqFailures = [new Error('Failed to acquire LM Studio model handle "m1": model not found')];
|
||||||
|
const streamer = new LMStudioStreamer(client);
|
||||||
|
await expect(collect(streamer.stream({
|
||||||
|
modelName: 'm1',
|
||||||
|
messages: [{ role: 'user', content: 'hi' }],
|
||||||
|
temperature: 0.2,
|
||||||
|
}))).rejects.toThrow(/model not found/);
|
||||||
|
expect(client.getModelHandleCalls).toEqual(['m1']); // no retry
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle acquisition failure is swallowed when the user already aborted', async () => {
|
||||||
|
const client = new FakeClient();
|
||||||
|
client.handleAcqFailures = [new Error('Operation canceled')];
|
||||||
|
const streamer = new LMStudioStreamer(client);
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
const out = await collect(streamer.stream({
|
||||||
|
modelName: 'm1',
|
||||||
|
messages: [{ role: 'user', content: 'hi' }],
|
||||||
|
temperature: 0.2,
|
||||||
|
signal: ac.signal,
|
||||||
|
}));
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
expect(client.getModelHandleCalls).toEqual(['m1']); // no retry — genuine cancel
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getModelContextLength delegates to the client (and survives a throwing client)', async () => {
|
||||||
|
const client = new FakeClient();
|
||||||
|
client.contextLength = 8192;
|
||||||
|
const streamer = new LMStudioStreamer(client);
|
||||||
|
expect(await streamer.getModelContextLength('m1')).toBe(8192);
|
||||||
|
expect(await streamer.getModelContextLength('')).toBeUndefined();
|
||||||
|
|
||||||
|
// A throwing client must degrade to undefined, never reject.
|
||||||
|
const throwing = new FakeClient();
|
||||||
|
throwing.getModelContextLength = async () => { throw new Error('ws down'); };
|
||||||
|
const s2 = new LMStudioStreamer(throwing);
|
||||||
|
expect(await s2.getModelContextLength('m1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test('passes messages through to model.respond', async () => {
|
test('passes messages through to model.respond', async () => {
|
||||||
const client = new FakeClient();
|
const client = new FakeClient();
|
||||||
const streamer = new LMStudioStreamer(client);
|
const streamer = new LMStudioStreamer(client);
|
||||||
|
|||||||
Reference in New Issue
Block a user