feat(reliability): bound + fail over + surface LLM-optional ops #82

Merged
michal merged 1 commits from feat/llm-resilience into main 2026-07-19 15:29:00 +00:00
12 changed files with 241 additions and 34 deletions
Showing only changes of commit 485b8f613f - Show all commits

37
docs/reliability.md Normal file
View File

@@ -0,0 +1,37 @@
# Reliability: don't let a bad LLM take mcpctl down
The homelab model changes often (fast ↔ thinking, model swaps, backends that
drift or go down). mcpctl must stay responsive and honest through all of it.
## Principle
**LLM-*optional* operations must be time-bounded, fall back deterministically,
and report the degradation — never hang and never degrade silently.**
- **Bounded:** every optional LLM call is wrapped in
[`withTimeout`](../src/mcplocal/src/util/with-timeout.ts) (Promise.race + an
`AbortSignal` so fetch-based providers actually cancel). A thinking model that
streams for minutes can never block the caller.
- **Deterministic fallback:** when the LLM times out or errors, use the
non-LLM path (priority/keyword ordering, byte-range pages).
- **Loud, not silent:** log the reason (`[gate] …`, `[pagination] …`) and tell
the user. `begin_session` prepends `⚠ Smart prompt-selection unavailable
(<reason>)…` and sets `degraded: true` + `degradedReason` on the audit
`gate_decision` event.
Applied in: the gate's `begin_session` prompt selection
(`proxymodel/plugins/gate.ts`, cap `MCPCTL_GATE_LLM_TIMEOUT_MS`, default 8s) and
pagination's smart index (`llm/pagination.ts`, `MCPCTL_PAGINATION_LLM_TIMEOUT_MS`,
default 10s). `read_prompts` is LLM-free by design.
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
every selection fail silently when the model wasn't anthropic-servable).
## LLM-*essential* operations
Chat needs the LLM — it can't fall back. It must **fail fast with a clear,
actionable message** instead of a vague one. Chat surfaces the upstream status +
body (which names the model + reason), e.g.
`LLM returned no completion (HTTP 400): {"error":"model deepseek-v4-flash not found"}`,
rather than "Adapter returned no choice".

View File

@@ -254,7 +254,11 @@ export class ChatService {
const result = await this.runOneInference(ctx); const result = await this.runOneInference(ctx);
const choice = extractChoice(result.body); const choice = extractChoice(result.body);
if (choice === null) { if (choice === null) {
throw new Error(`Adapter returned no choice (status ${String(result.status)})`); // Surface the upstream body (LiteLLM/vLLM names the model + reason,
// e.g. "model deepseek-v4-flash not found") instead of a vague
// "no choice" — the caller needs to know WHICH model failed and why.
const detail = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
throw new Error(`LLM returned no completion (HTTP ${String(result.status)}): ${detail.slice(0, 400)}`);
} }
if (choice.tool_calls !== undefined && choice.tool_calls.length > 0) { if (choice.tool_calls !== undefined && choice.tool_calls.length > 0) {
// Tool turns: keep `content` literal — even if empty — because the // Tool turns: keep `content` literal — even if empty — because the

View File

@@ -23,13 +23,13 @@ export interface LlmSelectionResult {
export class LlmPromptSelector { export class LlmPromptSelector {
constructor( constructor(
private readonly providerRegistry: ProviderRegistry, private readonly providerRegistry: ProviderRegistry,
private readonly modelOverride?: string,
) {} ) {}
async selectPrompts( async selectPrompts(
tags: string[], tags: string[],
promptIndex: PromptIndexForLlm[], promptIndex: PromptIndexForLlm[],
getSystemPromptFn?: SystemPromptFetcher, getSystemPromptFn?: SystemPromptFetcher,
signal?: AbortSignal,
): Promise<LlmSelectionResult> { ): Promise<LlmSelectionResult> {
const DEFAULT_SYSTEM_PROMPT = `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`; const DEFAULT_SYSTEM_PROMPT = `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`;
const systemPrompt = getSystemPromptFn const systemPrompt = getSystemPromptFn
@@ -48,6 +48,9 @@ Select the relevant prompts. Return JSON: { "selectedNames": [...], "reasoning":
throw new Error('No heavy LLM provider available'); throw new Error('No heavy LLM provider available');
} }
// Deliberately NOT setting `model` here. Prompt-ranking only needs a
// working LLM; forcing the project's vLLM model onto the (anthropic) heavy
// provider made every selection fail silently. Use the provider's own model.
const completionOptions: import('../providers/types.js').CompletionOptions = { const completionOptions: import('../providers/types.js').CompletionOptions = {
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -55,10 +58,8 @@ Select the relevant prompts. Return JSON: { "selectedNames": [...], "reasoning":
], ],
temperature: 0, temperature: 0,
maxTokens: 1024, maxTokens: 1024,
...(signal ? { signal } : {}),
}; };
if (this.modelOverride) {
completionOptions.model = this.modelOverride;
}
const result = await provider.complete(completionOptions); const result = await provider.complete(completionOptions);

View File

@@ -2,6 +2,10 @@ import { randomUUID } from 'node:crypto';
import type { ProviderRegistry } from '../providers/registry.js'; import type { ProviderRegistry } from '../providers/registry.js';
import { estimateTokens } from './token-counter.js'; import { estimateTokens } from './token-counter.js';
import type { SystemPromptFetcher } from '../proxymodel/types.js'; import type { SystemPromptFetcher } from '../proxymodel/types.js';
import { withTimeout } from '../util/with-timeout.js';
/** Cap on the LLM smart-index call; on timeout, fall back to byte-range pages. */
const PAGINATION_LLM_TIMEOUT_MS = Number(process.env['MCPCTL_PAGINATION_LLM_TIMEOUT_MS'] ?? '10000');
// --- Configuration --- // --- Configuration ---
@@ -260,15 +264,22 @@ export class ResponsePaginator {
? await this.getSystemPrompt('llm-pagination-index', PAGINATION_INDEX_SYSTEM_PROMPT) ? await this.getSystemPrompt('llm-pagination-index', PAGINATION_INDEX_SYSTEM_PROMPT)
: PAGINATION_INDEX_SYSTEM_PROMPT; : PAGINATION_INDEX_SYSTEM_PROMPT;
const result = await provider.complete({ // Time-bounded: a slow LLM must not stall pagination — the caller's catch
messages: [ // falls back to the simple byte-range index (visibly, via console.error).
{ role: 'system', content: systemPrompt }, const result = await withTimeout(
{ role: 'user', content: `Tool: ${toolName}\nTotal size: ${String(raw.length)} chars, ${String(pages.length)} pages\n\n${previews}` }, (signal) => provider.complete({
], messages: [
maxTokens: this.config.indexMaxTokens, { role: 'system', content: systemPrompt },
temperature: 0, { role: 'user', content: `Tool: ${toolName}\nTotal size: ${String(raw.length)} chars, ${String(pages.length)} pages\n\n${previews}` },
...(this.modelOverride ? { model: this.modelOverride } : {}), ],
}); maxTokens: this.config.indexMaxTokens,
temperature: 0,
signal,
...(this.modelOverride ? { model: this.modelOverride } : {}),
}),
PAGINATION_LLM_TIMEOUT_MS,
'pagination smart-index',
);
// LLMs often wrap JSON in ```json ... ``` fences — strip them // LLMs often wrap JSON in ```json ... ``` fences — strip them
const cleaned = result.content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim(); const cleaned = result.content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();

View File

@@ -45,7 +45,7 @@ export class AnthropicProvider implements LlmProvider {
})); }));
} }
const response = await this.request(body); const response = await this.request(body, options.signal);
return parseAnthropicResponse(response); return parseAnthropicResponse(response);
} }
@@ -72,8 +72,12 @@ export class AnthropicProvider implements LlmProvider {
} }
} }
private request(body: unknown): Promise<unknown> { private request(body: unknown, signal?: AbortSignal): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted === true) {
reject(new Error('request aborted'));
return;
}
const payload = JSON.stringify(body); const payload = JSON.stringify(body);
const isOAuth = this.apiKey.startsWith('sk-ant-oat'); const isOAuth = this.apiKey.startsWith('sk-ant-oat');
const opts = { const opts = {
@@ -109,6 +113,10 @@ export class AnthropicProvider implements LlmProvider {
req.destroy(); req.destroy();
reject(new Error('Request timed out')); reject(new Error('Request timed out'));
}); });
signal?.addEventListener('abort', () => {
req.destroy();
reject(new Error('request aborted'));
}, { once: true });
req.write(payload); req.write(payload);
req.end(); req.end();
}); });

View File

@@ -56,7 +56,7 @@ export class OpenAiProvider implements LlmProvider {
})); }));
} }
const response = await this.request('/v1/chat/completions', body); const response = await this.request('/v1/chat/completions', body, 'POST', options.signal);
return parseResponse(response); return parseResponse(response);
} }
@@ -75,8 +75,12 @@ export class OpenAiProvider implements LlmProvider {
} }
} }
private request(path: string, body: unknown, method = 'POST'): Promise<unknown> { private request(path: string, body: unknown, method = 'POST', signal?: AbortSignal): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted === true) {
reject(new Error('request aborted'));
return;
}
const url = new URL(path, this.baseUrl); const url = new URL(path, this.baseUrl);
const isHttps = url.protocol === 'https:'; const isHttps = url.protocol === 'https:';
const transport = isHttps ? https : http; const transport = isHttps ? https : http;
@@ -112,6 +116,10 @@ export class OpenAiProvider implements LlmProvider {
req.destroy(); req.destroy();
reject(new Error('Request timed out')); reject(new Error('Request timed out'));
}); });
signal?.addEventListener('abort', () => {
req.destroy();
reject(new Error('request aborted'));
}, { once: true });
if (payload) req.write(payload); if (payload) req.write(payload);
req.end(); req.end();
}); });

View File

@@ -42,6 +42,8 @@ export interface CompletionOptions {
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
model?: string; model?: string;
/** Cancel the in-flight request (fetch-based providers forward it to fetch). */
signal?: AbortSignal;
} }
/** LLM provider tier. 'fast' = local inference, 'heavy' = cloud reasoning. */ /** LLM provider tier. 'fast' = local inference, 'heavy' = cloud reasoning. */

View File

@@ -15,6 +15,11 @@ import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from '..
import type { TagMatchResult } from '../../gate/tag-matcher.js'; import type { TagMatchResult } from '../../gate/tag-matcher.js';
import { LlmPromptSelector } from '../../gate/llm-selector.js'; import { LlmPromptSelector } from '../../gate/llm-selector.js';
import type { ProviderRegistry } from '../../providers/registry.js'; import type { ProviderRegistry } from '../../providers/registry.js';
import { withTimeout, TimeoutError } from '../../util/with-timeout.js';
/** Cap on the gate's LLM prompt-selection. A slow/thinking LLM must never block
* begin_session — on timeout we fall back to deterministic tag matching. */
const GATE_LLM_TIMEOUT_MS = Number(process.env['MCPCTL_GATE_LLM_TIMEOUT_MS'] ?? '8000');
export interface GatePluginConfig { export interface GatePluginConfig {
gated?: boolean; gated?: boolean;
@@ -29,7 +34,7 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
const isGated = config.gated !== false; const isGated = config.gated !== false;
const tagMatcher = new TagMatcher(config.byteBudget); const tagMatcher = new TagMatcher(config.byteBudget);
const llmSelector = config.providerRegistry const llmSelector = config.providerRegistry
? new LlmPromptSelector(config.providerRegistry, config.modelOverride) ? new LlmPromptSelector(config.providerRegistry)
: null; : null;
// Per-session state tracking (plugin-scoped, not global SessionGate) // Per-session state tracking (plugin-scoped, not global SessionGate)
@@ -266,9 +271,12 @@ async function handleBeginSession(
const promptIndex = await ctx.fetchPromptIndex(); const promptIndex = await ctx.fetchPromptIndex();
// Primary: LLM selection. Fallback: deterministic tag matching. // Primary: LLM selection (time-bounded). Fallback: deterministic tag matching.
// A slow/thinking/down LLM must never block begin_session, and if we fall
// back the user is told (degradedReason) instead of silently degrading.
let matchResult: TagMatchResult; let matchResult: TagMatchResult;
let reasoning = ''; let reasoning = '';
let degradedReason: string | null = null;
if (llmSelector) { if (llmSelector) {
try { try {
@@ -279,7 +287,11 @@ async function handleBeginSession(
chapters: p.chapters, chapters: p.chapters,
})); }));
const getSystemPromptFn = ctx.getSystemPrompt.bind(ctx); const getSystemPromptFn = ctx.getSystemPrompt.bind(ctx);
const llmResult = await llmSelector.selectPrompts(tags, llmIndex, getSystemPromptFn); const llmResult = await withTimeout(
(signal) => llmSelector.selectPrompts(tags, llmIndex, getSystemPromptFn, signal),
GATE_LLM_TIMEOUT_MS,
'gate LLM prompt-selection',
);
reasoning = llmResult.reasoning; reasoning = llmResult.reasoning;
const selectedSet = new Set(llmResult.selectedNames); const selectedSet = new Set(llmResult.selectedNames);
@@ -291,7 +303,12 @@ async function handleBeginSession(
selected, selected,
); );
matchResult.remaining = [...matchResult.remaining, ...remaining]; matchResult.remaining = [...matchResult.remaining, ...remaining];
} catch { } catch (err) {
degradedReason = err instanceof TimeoutError
? `LLM prompt-selection timed out after ${String(GATE_LLM_TIMEOUT_MS)}ms`
: `LLM prompt-selection failed: ${(err as Error).message}`;
// Loud, not silent — visible in mcplocal logs.
console.error(`[gate] ${degradedReason} — falling back to priority-ordered prompts`);
matchResult = tagMatcher.match(tags, promptIndex); matchResult = tagMatcher.match(tags, promptIndex);
} }
} else { } else {
@@ -313,12 +330,20 @@ async function handleBeginSession(
clientIntent: { tags, description: description ?? null }, clientIntent: { tags, description: description ?? null },
matchedPrompts: matchResult.fullContent.map((p) => p.name), matchedPrompts: matchResult.fullContent.map((p) => p.name),
reasoning: reasoning || null, reasoning: reasoning || null,
degraded: degradedReason !== null,
degradedReason,
}, },
}); });
// Build response // Build response
const responseParts: string[] = []; const responseParts: string[] = [];
if (degradedReason !== null) {
responseParts.push(
`⚠ Smart prompt-selection unavailable (${degradedReason}). `
+ `Showing priority-ordered prompts — still relevant, just not LLM-ranked.\n`,
);
}
if (reasoning) { if (reasoning) {
responseParts.push(`Selection reasoning: ${reasoning}\n`); responseParts.push(`Selection reasoning: ${reasoning}\n`);
} }

View File

@@ -0,0 +1,40 @@
/**
* Bound an async operation with a timeout.
*
* Reliability principle: any LLM-*optional* operation (gate prompt-selection,
* pagination summaries) must be time-bounded so a slow/hung LLM (e.g. a
* thinking model that streams for minutes) can never block the caller. On
* timeout the returned promise rejects with a {@link TimeoutError}; callers are
* expected to fall back deterministically and report the degradation.
*
* The passed `AbortSignal` is aborted on timeout so fetch-based providers can
* cancel the in-flight request. Providers that ignore the signal are still
* unblocked immediately by the race (their orphaned request settles harmlessly
* in the background).
*/
export class TimeoutError extends Error {
constructor(label: string, ms: number) {
super(`${label} timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
}
export async function withTimeout<T>(
run: (signal: AbortSignal) => Promise<T>,
ms: number,
label: string,
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new TimeoutError(label, ms));
}, ms);
});
try {
return await Promise.race([run(controller.signal), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

View File

@@ -89,17 +89,30 @@ describe('LlmPromptSelector', () => {
expect(call.temperature).toBe(0); expect(call.temperature).toBe(0);
}); });
it('passes model override to complete options', async () => { it('does not force a model override — prompt-ranking uses the heavy provider\'s own model', async () => {
const provider = makeMockProvider( // Regression: forcing the project's vLLM model (e.g. deepseek-v4-*) onto the
'{ "selectedNames": [], "reasoning": "" }', // anthropic heavy provider made every selection fail silently. The selector
); // must leave `model` unset so it uses whatever the heavy provider serves.
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "" }');
const registry = makeRegistry(provider); const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry, 'gemini-pro'); const selector = new LlmPromptSelector(registry);
await selector.selectPrompts(['test'], sampleIndex); await selector.selectPrompts(['test'], sampleIndex);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions; const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.model).toBe('gemini-pro'); expect(call.model).toBeUndefined();
});
it('forwards an abort signal into complete (for the gate timeout)', async () => {
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const ac = new AbortController();
await selector.selectPrompts(['test'], sampleIndex, undefined, ac.signal);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.signal).toBe(ac.signal);
}); });
it('throws when no heavy provider is available', async () => { it('throws when no heavy provider is available', async () => {

View File

@@ -71,6 +71,8 @@ function setupPluginRouter(opts: {
prompts?: typeof samplePrompts; prompts?: typeof samplePrompts;
withLlm?: boolean; withLlm?: boolean;
llmResponse?: string; llmResponse?: string;
/** When set, the heavy provider's complete() rejects — exercises the loud fallback. */
llmError?: boolean;
byteBudget?: number; byteBudget?: number;
} = {}): { router: McpRouter; mcpdClient: McpdClient } { } = {}): { router: McpRouter; mcpdClient: McpdClient } {
const router = new McpRouter(); const router = new McpRouter();
@@ -83,12 +85,14 @@ function setupPluginRouter(opts: {
providerRegistry = new ProviderRegistry(); providerRegistry = new ProviderRegistry();
const mockProvider: LlmProvider = { const mockProvider: LlmProvider = {
name: 'mock-heavy', name: 'mock-heavy',
complete: vi.fn().mockResolvedValue({ complete: opts.llmError
content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }', ? vi.fn().mockRejectedValue(new Error('upstream 400: model not found'))
toolCalls: [], : vi.fn().mockResolvedValue({
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }',
finishReason: 'stop', toolCalls: [],
} satisfies CompletionResult), usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
finishReason: 'stop',
} satisfies CompletionResult),
listModels: vi.fn().mockResolvedValue([]), listModels: vi.fn().mockResolvedValue([]),
isAvailable: vi.fn().mockResolvedValue(true), isAvailable: vi.fn().mockResolvedValue(true),
}; };
@@ -195,6 +199,25 @@ describe('Gate Plugin via setPlugin()', () => {
}); });
describe('begin_session', () => { describe('begin_session', () => {
it('falls back with a visible warning when the LLM selector errors (no silent degrade)', async () => {
const { router } = setupPluginRouter({ withLlm: true, llmError: true });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee', 'pairing'] } } },
{ sessionId: 's1' },
);
// Fell back — the call succeeded rather than failing/hanging …
expect(res.error).toBeUndefined();
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
// … and the degradation is visible, not silent …
expect(text).toContain('Smart prompt-selection unavailable');
// … while deterministic tag matching still returned relevant prompts.
expect(text).toContain('common-mistakes');
expect(text).toContain('zigbee-pairing');
});
it('returns matched prompts with keyword matching', async () => { it('returns matched prompts with keyword matching', async () => {
const { router } = setupPluginRouter(); const { router } = setupPluginRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { withTimeout, TimeoutError } from '../src/util/with-timeout.js';
describe('withTimeout', () => {
it('resolves when the operation finishes in time', async () => {
const out = await withTimeout(async () => 'ok', 100, 'fast op');
expect(out).toBe('ok');
});
it('rejects with TimeoutError when the operation hangs', async () => {
const start = Date.now();
await expect(
withTimeout(() => new Promise<never>(() => { /* never resolves */ }), 50, 'hang op'),
).rejects.toBeInstanceOf(TimeoutError);
// Returned promptly, not after the (never) inner promise.
expect(Date.now() - start).toBeLessThan(1000);
});
it('aborts the signal on timeout (fetch-based providers can cancel)', async () => {
let aborted = false;
await expect(
withTimeout((signal) => {
signal.addEventListener('abort', () => { aborted = true; });
return new Promise<never>(() => { /* never */ });
}, 50, 'abort op'),
).rejects.toBeInstanceOf(TimeoutError);
expect(aborted).toBe(true);
});
it('propagates the operation error (not a timeout) when it rejects fast', async () => {
await expect(
withTimeout(async () => { throw new Error('boom'); }, 100, 'err op'),
).rejects.toThrow('boom');
});
});