fix(mcplocal): bounded, legible MCP failures — no request can hang forever #127

Merged
michal merged 9 commits from fix/bounded-mcp-failures into main 2026-08-25 23:03:51 +00:00
10 changed files with 405 additions and 48 deletions
Showing only changes of commit c2a419b4f7 - Show all commits

View File

@@ -19,10 +19,47 @@ and report the degradation — never hang and never degrade silently.**
(<reason>)…` and sets `degraded: true` + `degradedReason` on the audit (<reason>)…` and sets `degraded: true` + `degradedReason` on the audit
`gate_decision` event. `gate_decision` event.
Applied in: the gate's `begin_session` prompt selection **One implementation:** [`util/degrade.ts`](../src/mcplocal/src/util/degrade.ts)'s
(`proxymodel/plugins/gate.ts`, cap `MCPCTL_GATE_LLM_TIMEOUT_MS`, default 8s) and `bounded()`. It cannot throw — the caller always gets a value or a reason — so
pagination's smart index (`llm/pagination.ts`, `MCPCTL_PAGINATION_LLM_TIMEOUT_MS`, the deterministic fallback is unconditional rather than something a `catch`
default 10s). `read_prompts` is LLM-free by design. block has to remember. `degradationNotice()` produces the `⚠ … unavailable
(reason). hint` wording, and `degradationAudit()` the `degraded`/`degradedReason`
payload, so every degradation reads the same whichever subsystem produced it.
**Nothing optional is unbounded by construction.** The budget lives in
`LLMProviderAdapter.complete()` (`proxymodel/llm-adapter.ts`), not at each call
site, so a stage that passes no options is still bounded and a stage written
next year inherits the guarantee. One budget spans the whole failover chain —
a per-provider timeout would make the worst case N × timeout.
| Knob | Default | Bounds |
|---|---|---|
| `MCPCTL_LLM_CALL_BUDGET_MS` | 20s | one `ctx.llm.complete()`, failover included |
| `MCPCTL_LLM_PROVIDER_TIMEOUT_MS` | 10s | a single provider attempt inside that budget |
| `MCPCTL_STAGE_LLM_BUDGET_MS` | 30s | all LLM work in one stage invocation |
| `MCPCTL_GATE_LLM_TIMEOUT_MS` | 8s | the gate's `begin_session` prompt selection |
| `MCPCTL_PAGINATION_LLM_TIMEOUT_MS` | 10s | pagination's smart index (`llm/pagination.ts`) |
The **stage** budget exists because a per-call timeout multiplies rather than
bounds when a stage loops: `summarize-tree` recurses to `maxDepth` (3) and loops
per section at every level, so hundreds of sequential calls are reachable. One
budget is shared across the whole recursion; when it is gone the stage switches
to first-line excerpts and says so. A **warm cache is never budget-gated** — a
cached summary costs nothing, so an exhausted budget must not degrade a result
we already hold.
`read_prompts` is LLM-free by design.
### Why this is written down twice
This document stated the principle while `proxymodel/stages/paginate.ts` awaited
`ctx.llm.complete()` with no timeout at all. When a provider hung rather than
erroring, the promise never settled, the tool call never returned, and the
client waited out its own 1800s timeout — three such requests in production,
misdiagnosed twice as an upstream "transport fault". The doc named the gate and
`llm/pagination.ts` as the compliant sites, and the newer stages simply never
joined the list. Naming **one** helper here, rather than a list of call sites,
is what stops that drift recurring.
Note: the gate's prompt-ranking uses the **heavy client provider's own model** Note: the gate's prompt-ranking uses the **heavy client provider's own model**
it deliberately does *not* force the project's vLLM model onto it (doing so made it deliberately does *not* force the project's vLLM model onto it (doing so made

View File

@@ -6,6 +6,7 @@
import type { StageContext, StageResult, StageLogger, Section, ContentType, LLMProvider, CacheProvider, SystemPromptFetcher } from './types.js'; import type { StageContext, StageResult, StageLogger, Section, ContentType, LLMProvider, CacheProvider, SystemPromptFetcher } from './types.js';
import type { ProxyModelDefinition } from './schema.js'; import type { ProxyModelDefinition } from './schema.js';
import { getStage } from './stage-registry.js'; import { getStage } from './stage-registry.js';
import { createStageBudget, STAGE_LLM_BUDGET_MS } from './stage-budget.js';
import type { AuditCollector } from '../audit/collector.js'; import type { AuditCollector } from '../audit/collector.js';
export interface ExecuteOptions { export interface ExecuteOptions {
@@ -87,6 +88,11 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
log: consoleLogger(stageSpec.type), log: consoleLogger(stageSpec.type),
getSystemPrompt: opts.getSystemPrompt ?? defaultFetcher, getSystemPrompt: opts.getSystemPrompt ?? defaultFetcher,
config: stageSpec.config ?? {}, config: stageSpec.config ?? {},
budget: createStageBudget(
typeof stageSpec.config?.['budgetMs'] === 'number'
? stageSpec.config['budgetMs']
: STAGE_LLM_BUDGET_MS,
),
}; };
try { try {
@@ -109,6 +115,11 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
outputSize: result.content.length, outputSize: result.content.length,
sectionCount: result.sections?.length ?? 0, sectionCount: result.sections?.length ?? 0,
error: null, error: null,
// Surfaced by stages that fell back to a deterministic path (e.g. an
// exhausted LLM budget), so a degradation is visible in the trace
// rather than only in the response text.
degraded: result.metadata?.['degraded'] === true,
degradedReason: (result.metadata?.['degradedReason'] as string | undefined) ?? null,
}, },
}); });
} catch (err) { } catch (err) {
@@ -125,9 +136,15 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
outputSize: currentContent.length, outputSize: currentContent.length,
sectionCount: 0, sectionCount: 0,
error: (err as Error).message, error: (err as Error).message,
degraded: true,
degradedReason: (err as Error).message,
}, },
}); });
// Continue with previous content on error // Continue with previous content on error
} finally {
// Always clear the budget timer, on success or failure, so a long-lived
// mcplocal never accumulates them.
ctx.budget.dispose();
} }
} }

View File

@@ -0,0 +1,50 @@
/**
* Wall-clock budget for ONE stage invocation's LLM work.
*
* A per-call timeout is not sufficient for a stage that calls the LLM in a
* loop. `summarize-tree` recurses (buildTree calls itself up to maxDepth, 3 by
* default) and loops per section at every level, then `groupSections` loops
* again — so a per-call cap multiplies instead of bounding, and a stage can
* issue hundreds of sequential calls.
*
* The stage shares ONE budget across every call it makes. Once it is gone the
* stage must switch to its deterministic path and report the degradation.
*/
export interface StageBudget {
remainingMs(): number;
exhausted(): boolean;
/** Aborted when the budget runs out; pass into ctx.llm.complete({ signal }). */
readonly signal: AbortSignal;
/** Total budget, for degradation messages. */
readonly totalMs: number;
dispose(): void;
}
export const STAGE_LLM_BUDGET_MS = Number(process.env['MCPCTL_STAGE_LLM_BUDGET_MS'] ?? '30000');
export function createStageBudget(totalMs: number = STAGE_LLM_BUDGET_MS): StageBudget {
const controller = new AbortController();
const startedAt = Date.now();
const timer = setTimeout(() => { controller.abort(); }, totalMs);
// Node keeps the process alive for pending timers; a stage budget must not.
if (typeof timer.unref === 'function') timer.unref();
return {
totalMs,
signal: controller.signal,
remainingMs: (): number => Math.max(0, totalMs - (Date.now() - startedAt)),
exhausted: (): boolean => Date.now() - startedAt >= totalMs,
dispose: (): void => { clearTimeout(timer); },
};
}
/** Never-expiring budget, for tests and non-LLM call paths. */
export function unlimitedStageBudget(): StageBudget {
return {
totalMs: Number.POSITIVE_INFINITY,
signal: new AbortController().signal,
remainingMs: (): number => Number.POSITIVE_INFINITY,
exhausted: (): boolean => false,
dispose: (): void => { /* nothing to clean up */ },
};
}

View File

@@ -9,6 +9,7 @@
* previewChars: number (chars per page sent to LLM for title generation, default 300) * previewChars: number (chars per page sent to LLM for title generation, default 300)
*/ */
import type { StageHandler, StageContext, Section } from '../types.js'; import type { StageHandler, StageContext, Section } from '../types.js';
import { bounded, degradationNotice } from '../../util/degrade.js';
const handler: StageHandler = async (content, ctx) => { const handler: StageHandler = async (content, ctx) => {
const pageSize = (ctx.config.pageSize as number | undefined) ?? 8000; const pageSize = (ctx.config.pageSize as number | undefined) ?? 8000;
@@ -24,7 +25,7 @@ const handler: StageHandler = async (content, ctx) => {
return { content }; return { content };
} }
const titles = await generatePageTitles(pages, ctx); const { titles, degradedReason } = await generatePageTitles(pages, ctx);
const sections: Section[] = pages.map((page, i) => ({ const sections: Section[] = pages.map((page, i) => ({
id: `page-${i + 1}`, id: `page-${i + 1}`,
@@ -36,30 +37,62 @@ const handler: StageHandler = async (content, ctx) => {
`[${s.id}] ${s.title} (${pages[i]!.length} chars)`, `[${s.id}] ${s.title} (${pages[i]!.length} chars)`,
).join('\n'); ).join('\n');
// Loud, not silent: if the titles are numbered because the LLM was slow or
// down, say so rather than letting the model assume the pages are unnamed.
const notice = degradedReason === null
? ''
: degradationNotice('Smart page titles', degradedReason, 'Pages are numbered instead.');
return { return {
// No navigation hint here — the caller (content-pipeline / router) appends // No navigation hint here — the caller (content-pipeline / router) appends
// the authoritative _resultId/_section instruction once sections are stored. // the authoritative _resultId/_section instruction once sections are stored.
content: `Content split into ${sections.length} pages (${content.length} total chars):\n${toc}`, content: `${notice}Content split into ${sections.length} pages (${content.length} total chars):\n${toc}`,
sections, sections,
...(degradedReason === null ? {} : { metadata: { degraded: true, degradedReason } }),
}; };
}; };
/** /**
* Generate descriptive titles for each page using LLM. * Generate descriptive titles for each page using the LLM.
* Falls back to generic "Page N" titles if LLM is unavailable or fails. *
* Returns a degradation reason instead of throwing: the deterministic
* "Page N" fallback is unconditional. Before this was bounded, a provider that
* hung (rather than erroring) left the awaited promise unsettled forever — the
* tool call never returned and the client waited out its own 1800s timeout.
*/ */
async function generatePageTitles(pages: string[], ctx: StageContext): Promise<string[]> { async function generatePageTitles(
pages: string[],
ctx: StageContext,
): Promise<{ titles: string[]; degradedReason: string | null }> {
const fallback = pages.map((_, i) => `Page ${i + 1}`); const fallback = pages.map((_, i) => `Page ${i + 1}`);
// No LLM configured is a deliberate choice, not a degradation — numbered
// pages are the expected output there, so don't warn about them.
if (!ctx.llm.available()) { if (!ctx.llm.available()) {
return fallback; return { titles: fallback, degradedReason: null };
} }
const previewChars = (ctx.config.previewChars as number | undefined) ?? 300; const previewChars = (ctx.config.previewChars as number | undefined) ?? 300;
const cacheKey = `paginate-titles:${ctx.cache.hash(ctx.originalContent)}:${pages.length}`; const cacheKey = `paginate-titles:${ctx.cache.hash(ctx.originalContent)}:${pages.length}`;
// Read the cache BEFORE consulting the budget — a warm cache costs nothing,
// so an exhausted budget must not degrade a result we already hold.
const cached = await ctx.cache.get(cacheKey);
if (cached !== null) {
try { try {
const cached = await ctx.cache.getOrCompute(cacheKey, async () => { return { titles: JSON.parse(cached) as string[], degradedReason: null };
} catch {
// Corrupt entry — fall through and recompute.
}
}
if (ctx.budget.exhausted()) {
return {
titles: fallback,
degradedReason: `stage LLM budget of ${String(ctx.budget.totalMs)}ms exhausted`,
};
}
const previews = pages.map((page, i) => { const previews = pages.map((page, i) => {
const preview = page.slice(0, previewChars).trim(); const preview = page.slice(0, previewChars).trim();
return `--- Page ${i + 1} (${page.length} chars) ---\n${preview}`; return `--- Page ${i + 1} (${page.length} chars) ---\n${preview}`;
@@ -69,32 +102,39 @@ async function generatePageTitles(pages: string[], ctx: StageContext): Promise<s
const template = await ctx.getSystemPrompt('llm-paginate-titles', DEFAULT_PROMPT); const template = await ctx.getSystemPrompt('llm-paginate-titles', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{pageCount}}', String(pages.length)); const prompt = template.replaceAll('{{pageCount}}', String(pages.length));
const result = await ctx.llm.complete( const outcome = await bounded(
`${prompt}\n\n${previews}`, async (signal) => {
{ maxTokens: pages.length * 30 }, const result = await ctx.llm.complete(`${prompt}\n\n${previews}`, {
); maxTokens: pages.length * 30,
budgetMs: ctx.budget.remainingMs(),
// Parse JSON array from response, pad/truncate to match page count signal,
});
const match = result.match(/\[[\s\S]*\]/); const match = result.match(/\[[\s\S]*\]/);
if (!match) throw new Error('No JSON array in response'); if (!match) throw new Error('No JSON array in response');
const raw = JSON.parse(match[0]) as string[]; const raw = JSON.parse(match[0]) as string[];
if (!Array.isArray(raw) || raw.length === 0) { if (!Array.isArray(raw) || raw.length === 0) {
throw new Error('Empty or invalid title array'); throw new Error('Empty or invalid title array');
} }
// Pad with generic titles if model returned fewer, truncate if more return pages.map((_, i) =>
const titles = pages.map((_, i) =>
(i < raw.length && typeof raw[i] === 'string' && raw[i]!.trim()) (i < raw.length && typeof raw[i] === 'string' && raw[i]!.trim())
? raw[i]!.trim().slice(0, 80) ? raw[i]!.trim().slice(0, 80)
: `Page ${i + 1}`, : `Page ${i + 1}`,
); );
return JSON.stringify(titles); },
}); {
label: 'paginate',
operation: 'Smart page titles',
timeoutMs: ctx.budget.remainingMs(),
signal: ctx.budget.signal,
fallback: 'to numbered pages',
},
);
return JSON.parse(cached) as string[]; if (outcome.degraded) {
} catch (err) { return { titles: fallback, degradedReason: outcome.reason };
ctx.log.warn(`Smart page titles failed, using generic: ${(err as Error).message}`);
return fallback;
} }
await ctx.cache.set(cacheKey, JSON.stringify(outcome.value));
return { titles: outcome.value, degradedReason: null };
} }
function splitPages(content: string, pageSize: number): string[] { function splitPages(content: string, pageSize: number): string[] {

View File

@@ -14,11 +14,13 @@
*/ */
import type { StageHandler, Section } from '../types.js'; import type { StageHandler, Section } from '../types.js';
import { detectContentType } from '../content-type.js'; import { detectContentType } from '../content-type.js';
import { bounded, degradationNotice } from '../../util/degrade.js';
const handler: StageHandler = async (content, ctx) => { const handler: StageHandler = async (content, ctx) => {
const maxTokens = (ctx.config.maxSummaryTokens as number | undefined) ?? 200; const maxTokens = (ctx.config.maxSummaryTokens as number | undefined) ?? 200;
const maxGroup = (ctx.config.maxGroupSize as number | undefined) ?? 5; const maxGroup = (ctx.config.maxGroupSize as number | undefined) ?? 5;
const maxDepth = (ctx.config.maxDepth as number | undefined) ?? 3; const maxDepth = (ctx.config.maxDepth as number | undefined) ?? 3;
const deg = createDegradeSink();
// If content is small, just return it unchanged // If content is small, just return it unchanged
if (content.length < 2000) { if (content.length < 2000) {
@@ -37,7 +39,16 @@ const handler: StageHandler = async (content, ctx) => {
return { content }; return { content };
} }
const summary = await cachedSummarize(ctx, ctx.originalContent, maxTokens); const summary = await cachedSummarize(ctx, ctx.originalContent, maxTokens, deg);
if (summary === null) {
// Budget gone or the LLM failed: return the content untouched rather
// than a summary we could not produce. Previously this path had no
// try/catch at all, so a failure escaped the stage entirely.
return {
content: degradationNotice('Smart summaries', deg.reason ?? 'unavailable', 'Content is unsummarised.') + content,
metadata: { degraded: true, degradedReason: deg.reason },
};
}
return { return {
content: summary + '\n\nSection "full" holds the complete content.', content: summary + '\n\nSection "full" holds the complete content.',
sections: [{ id: 'full', title: 'Full Content', content: ctx.originalContent }], sections: [{ id: 'full', title: 'Full Content', content: ctx.originalContent }],
@@ -45,7 +56,7 @@ const handler: StageHandler = async (content, ctx) => {
} }
// Build the summary tree // Build the summary tree
const tree = await buildTree(sections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 }); const tree = await buildTree(sections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0, deg });
// Format top-level ToC // Format top-level ToC
const toc = tree.map((s) => { const toc = tree.map((s) => {
@@ -55,9 +66,20 @@ const handler: StageHandler = async (content, ctx) => {
return `[${s.id}] ${s.title}${childHint}`; return `[${s.id}] ${s.title}${childHint}`;
}).join('\n'); }).join('\n');
// One reason, plus how many summaries it cost — the recursion can hit the
// same exhausted budget dozens of times.
const notice = deg.reason === null
? ''
: degradationNotice(
'Smart summaries',
deg.reason,
`Showing first-line excerpts for ${String(deg.count)} of ${String(tree.length)} sections.`,
);
return { return {
content: `${tree.length} sections:\n${toc}`, content: `${notice}${tree.length} sections:\n${toc}`,
sections: tree, sections: tree,
...(deg.reason === null ? {} : { metadata: { degraded: true, degradedReason: deg.reason } }),
}; };
}; };
@@ -66,6 +88,8 @@ interface TreeOpts {
maxGroup: number; maxGroup: number;
maxDepth: number; maxDepth: number;
depth: number; depth: number;
/** Shared across the whole recursion — one budget, one reported reason. */
deg: DegradeSink;
} }
async function buildTree( async function buildTree(
@@ -84,7 +108,8 @@ async function buildTree(
summary = structuralSummary(section.content, contentType); summary = structuralSummary(section.content, contentType);
} else if (ctx.llm.available()) { } else if (ctx.llm.available()) {
// LLM summary for prose/code // LLM summary for prose/code
summary = await cachedSummarize(ctx, section.content, opts.maxTokens); summary = await cachedSummarize(ctx, section.content, opts.maxTokens, opts.deg)
?? (section.content.split('\n')[0] ?? '').slice(0, 200);
} else { } else {
// No LLM — use first line as summary // No LLM — use first line as summary
summary = (section.content.split('\n')[0] ?? '').slice(0, 200); summary = (section.content.split('\n')[0] ?? '').slice(0, 200);
@@ -125,18 +150,68 @@ async function cachedSummarize(
ctx: import('../types.js').StageContext, ctx: import('../types.js').StageContext,
content: string, content: string,
maxTokens: number, maxTokens: number,
): Promise<string> { deg: DegradeSink,
): Promise<string | null> {
const key = `summary:${ctx.cache.hash(content)}:${maxTokens}`; const key = `summary:${ctx.cache.hash(content)}:${maxTokens}`;
return ctx.cache.getOrCompute(key, async () => {
// Read the cache BEFORE consulting the budget: a warm entry costs nothing,
// so an exhausted budget must never degrade a summary we already hold.
const cached = await ctx.cache.get(key);
if (cached !== null) return cached;
if (ctx.budget.exhausted()) {
deg.note(`stage LLM budget of ${String(ctx.budget.totalMs)}ms exhausted`);
return null;
}
const DEFAULT_PROMPT = `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`; const DEFAULT_PROMPT = `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`;
const template = await ctx.getSystemPrompt('llm-summarize', DEFAULT_PROMPT); const template = await ctx.getSystemPrompt('llm-summarize', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{maxTokens}}', String(maxTokens)); const prompt = template.replaceAll('{{maxTokens}}', String(maxTokens));
return ctx.llm.complete( const outcome = await bounded(
`${prompt}\n\n${content}`, (signal) => ctx.llm.complete(`${prompt}\n\n${content}`, {
{ maxTokens }, maxTokens,
budgetMs: ctx.budget.remainingMs(),
signal,
}),
{
label: 'summarize-tree',
operation: 'Smart summaries',
timeoutMs: ctx.budget.remainingMs(),
signal: ctx.budget.signal,
fallback: 'to first-line excerpts',
},
); );
});
if (outcome.degraded) {
deg.note(outcome.reason);
return null;
}
await ctx.cache.set(key, outcome.value);
return outcome.value;
}
/**
* Collects the first degradation reason and a count.
*
* buildTree recurses and loops, so one exhausted budget can produce dozens of
* identical reasons; the user needs the reason once plus how many summaries it
* cost them.
*/
export interface DegradeSink {
note(reason: string): void;
readonly reason: string | null;
readonly count: number;
}
export function createDegradeSink(): DegradeSink {
let reason: string | null = null;
let count = 0;
return {
note(r: string): void { reason ??= r; count++; },
get reason(): string | null { return reason; },
get count(): number { return count; },
};
} }
function structuralSummary(content: string, type: string): string { function structuralSummary(content: string, type: string): string {
@@ -267,9 +342,10 @@ async function groupSections(
const groupContent = chunk.map((s) => `[${s.id}] ${s.title}`).join('\n'); const groupContent = chunk.map((s) => `[${s.id}] ${s.title}`).join('\n');
const groupId = `group-${Math.floor(i / opts.maxGroup) + 1}`; const groupId = `group-${Math.floor(i / opts.maxGroup) + 1}`;
const genericTitle = `Group ${Math.floor(i / opts.maxGroup) + 1} (${chunk.length} sections)`;
const groupTitle = ctx.llm.available() const groupTitle = ctx.llm.available()
? await cachedSummarize(ctx, groupContent, 50) ? (await cachedSummarize(ctx, groupContent, 50, opts.deg) ?? genericTitle)
: `Group ${Math.floor(i / opts.maxGroup) + 1} (${chunk.length} sections)`; : genericTitle;
groups.push({ groups.push({
id: groupId, id: groupId,

View File

@@ -10,6 +10,8 @@
* SessionController — method-level hooks with per-session state * SessionController — method-level hooks with per-session state
*/ */
import type { StageBudget } from './stage-budget.js';
/** Fetches a system prompt by name, falling back to the provided default. */ /** Fetches a system prompt by name, falling back to the provided default. */
export type SystemPromptFetcher = (name: string, fallback: string) => Promise<string>; export type SystemPromptFetcher = (name: string, fallback: string) => Promise<string>;
@@ -48,6 +50,13 @@ export interface StageContext {
/** Stage-specific configuration from the proxymodel YAML */ /** Stage-specific configuration from the proxymodel YAML */
config: Record<string, unknown>; config: Record<string, unknown>;
/**
* Shared wall-clock budget for this stage's LLM work. A stage that calls the
* LLM in a loop must check this between calls and fall back deterministically
* once it is gone — a per-call timeout alone multiplies rather than bounds.
*/
budget: StageBudget;
} }
export interface StageResult { export interface StageResult {

View File

@@ -5,6 +5,7 @@ import paginate from '../src/proxymodel/stages/paginate.js';
import sectionSplit from '../src/proxymodel/stages/section-split.js'; import sectionSplit from '../src/proxymodel/stages/section-split.js';
import summarizeTree from '../src/proxymodel/stages/summarize-tree.js'; import summarizeTree from '../src/proxymodel/stages/summarize-tree.js';
import { BUILT_IN_STAGES } from '../src/proxymodel/stages/index.js'; import { BUILT_IN_STAGES } from '../src/proxymodel/stages/index.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvailable = false): StageContext { function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvailable = false): StageContext {
const llmResponses: string[] = []; const llmResponses: string[] = [];
@@ -48,6 +49,7 @@ function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvai
cache: mockCache, cache: mockCache,
log: mockLog, log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback, getSystemPrompt: async (_name: string, fallback: string) => fallback,
budget: unlimitedStageBudget(),
config, config,
}; };
} }

View File

@@ -12,6 +12,7 @@ import type {
SessionContext, SessionContext,
ContentType, ContentType,
} from '../src/proxymodel/index.js'; } from '../src/proxymodel/index.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
describe('ProxyModel type contract', () => { describe('ProxyModel type contract', () => {
it('StageHandler can be implemented as a simple function', async () => { it('StageHandler can be implemented as a simple function', async () => {
@@ -137,6 +138,7 @@ function createMockContext(original: string): StageContext {
cache: mockCache, cache: mockCache,
log: mockLog, log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback, getSystemPrompt: async (_name: string, fallback: string) => fallback,
budget: unlimitedStageBudget(),
config: {}, config: {},
}; };
} }

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createStageBudget, unlimitedStageBudget, STAGE_LLM_BUDGET_MS } from '../src/proxymodel/stage-budget.js';
import { getStage } from '../src/proxymodel/stage-registry.js';
import '../src/proxymodel/stages/index.js';
import type { StageContext, LLMProvider, CacheProvider, StageLogger } from '../src/proxymodel/types.js';
afterEach(() => { vi.restoreAllMocks(); });
function ctxWith(llm: LLMProvider, original: string, budgetMs: number): StageContext {
const store = new Map<string, string>();
const cache: CacheProvider = {
async getOrCompute(k, c) {
if (store.has(k)) return store.get(k)!;
const v = await c(); store.set(k, v); return v;
},
hash: (c) => c.slice(0, 8),
async get(k) { return store.get(k) ?? null; },
async set(k, v) { store.set(k, v); },
};
const log: StageLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
contentType: 'toolResult', sourceName: 'test/tool', projectName: 'test', sessionId: 's1',
originalContent: original, llm, cache, log,
getSystemPrompt: async (_n, f) => f,
config: {},
budget: createStageBudget(budgetMs),
};
}
/** Never settles and ignores the signal — the shape that caused the outage. */
const hangingLlm: LLMProvider = {
complete: () => new Promise<string>(() => { /* never */ }),
available: () => true,
};
describe('StageBudget', () => {
it('reports remaining time and expires', async () => {
const b = createStageBudget(60);
expect(b.exhausted()).toBe(false);
expect(b.remainingMs()).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 90));
expect(b.exhausted()).toBe(true);
expect(b.remainingMs()).toBe(0);
expect(b.signal.aborted).toBe(true);
b.dispose();
});
it('unlimited budget never expires', () => {
const b = unlimitedStageBudget();
expect(b.exhausted()).toBe(false);
expect(b.remainingMs()).toBe(Number.POSITIVE_INFINITY);
b.dispose();
});
it('defaults to the documented 30s', () => {
expect(STAGE_LLM_BUDGET_MS).toBe(30_000);
});
});
describe('paginate under a hung LLM', () => {
it('returns numbered pages within the budget instead of hanging', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const handler = getStage('paginate')!;
const content = 'x'.repeat(30_000);
const ctx = ctxWith(hangingLlm, content, 200);
const start = Date.now();
const result = await handler(content, ctx);
const elapsed = Date.now() - start;
// The whole point: this used to never return at all.
expect(elapsed).toBeLessThan(3000);
expect(result.content).toContain('⚠ Smart page titles unavailable');
expect(result.content).toContain('page-1');
expect(result.metadata?.['degraded']).toBe(true);
ctx.budget.dispose();
});
it('serves a warm cache even with an exhausted budget', async () => {
const handler = getStage('paginate')!;
const content = 'y'.repeat(30_000);
// Warm the cache with a working LLM and a generous budget.
const good: LLMProvider = {
complete: async () => '["Alpha","Beta","Gamma","Delta"]',
available: () => true,
};
const warm = ctxWith(good, content, 5000);
await handler(content, warm);
// Same cache, no budget left: the cached titles must still be used.
const cold: StageContext = { ...warm, llm: hangingLlm, budget: createStageBudget(1) };
await new Promise((r) => setTimeout(r, 10));
const result = await handler(content, cold);
expect(result.content).toContain('Alpha');
expect(result.content).not.toContain('⚠');
warm.budget.dispose();
cold.budget.dispose();
});
});
describe('summarize-tree under a hung LLM', () => {
it('bounds the whole recursion, not each call', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const handler = getStage('summarize-tree')!;
// Many sections: pre-fix, a per-call timeout would multiply across the
// sequential loop AND the recursion (maxDepth 3).
const sections = Array.from({ length: 20 }, (_, i) =>
`## Section ${i + 1}\n${'prose '.repeat(400)}`).join('\n\n');
const ctx = ctxWith(hangingLlm, sections, 300);
const start = Date.now();
const result = await handler(sections, ctx);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
expect(result.content).toContain('⚠ Smart summaries unavailable');
expect(result.metadata?.['degraded']).toBe(true);
ctx.budget.dispose();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import type { StageContext, LLMProvider, CacheProvider, StageLogger, SystemPromptFetcher } from '../src/proxymodel/types.js'; import type { StageContext, LLMProvider, CacheProvider, StageLogger, SystemPromptFetcher } from '../src/proxymodel/types.js';
import paginate from '../src/proxymodel/stages/paginate.js'; import paginate from '../src/proxymodel/stages/paginate.js';
import summarizeTree from '../src/proxymodel/stages/summarize-tree.js'; import summarizeTree from '../src/proxymodel/stages/summarize-tree.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
function mockCtx( function mockCtx(
original: string, original: string,
@@ -50,6 +51,7 @@ function mockCtx(
cache: mockCache, cache: mockCache,
log: mockLog, log: mockLog,
getSystemPrompt: opts.getSystemPrompt ?? (async (_name, fallback) => fallback), getSystemPrompt: opts.getSystemPrompt ?? (async (_name, fallback) => fallback),
budget: unlimitedStageBudget(),
config, config,
}; };
} }