From d7eccc6366201f556e83f6fbdeb9c27e4a68c3a8 Mon Sep 17 00:00:00 2001 From: dsh-slopo agent Date: Tue, 22 Sep 2026 07:22:03 +0100 Subject: [PATCH] Add a real pre-flight to status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slopo reports a bad embedding configuration as the two words "Embedding failed." on stdout, with the real reason in its agent log. That is a slow way to learn that a model string has the wrong provider prefix, or that the dimensions do not match what the model actually returns — the second of which is expensive, because slopo freezes the dimensions into the index at first use. `status` now does three things instead of one: it reports the resolved settings, it runs `slopo --version` through the shell seam, and it makes one tiny embedding request against the configured endpoint and compares the returned width with the configured one. Verified against a real Ollama: a correct config reports ok, a dimension mismatch names both numbers, and a wrong model returns the endpoint's own 404 rather than a silent failure minutes into an embed. The probe is a direct fetch rather than a shell call, because it is one small HTTP request and does not belong behind a boundary that exists to confine filesystem writes. Two smaller things this needed: - the sandbox policy is now resolved once at the top of execute, so `status` can use it without a second resolution - provider-prefix stripping is an allow-list, not "drop the first segment". `unclemusclez/jina-embeddings-v2-base-code:latest` is a real model name with no prefix at all, and an unknown first segment is left alone so a wrong guess is visible in the reported `sentModel` rather than silent --- index.js | 83 ++++++++++++++++++++---- lib/probe.js | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 lib/probe.js diff --git a/index.js b/index.js index 6c072b9..843b22f 100644 --- a/index.js +++ b/index.js @@ -33,10 +33,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import Schema from '@deepseek-ai/schemastery' import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js' +import { probeEmbeddingEndpoint } from './lib/probe.js' import { - PLUGIN_NAME, resolveDshHome, - workspaceKey, workspaceStateDir, } from './lib/paths.js' import { @@ -423,6 +422,14 @@ export function apply(ctx, config) { const settings = readSettings() ?? config const paths = workspacePaths(dshHome, cwd, settings) const shell = ctx.get('shell') + // Resolved once, up front, so `status` can probe without a second + // resolution. The NO_SANDBOX_POLICY refusal stays with the actions that + // actually execute something. + const sandboxDefault = shell?.sandboxMode + const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy') + const sandboxPolicy = policyService?.resolve( + exec.agent === undefined ? {} : { session: exec.agent.session }, + ) const foreground = (value) => ({ kind: 'foreground', action: args.action, configPath: paths.configPath, logPath: paths.logFile, ...value }) @@ -431,8 +438,65 @@ export function apply(ctx, config) { const problems = [] const slopoConfig = toSlopoConfig(settings, paths, problems) const missing = missingRequired(slopoConfig) + + // Two pre-flight checks, because slopo reports a bad embedding + // configuration as the two words "Embedding failed." on stdout with + // the real reason buried in its agent log. That is a slow way to + // discover a wrong provider prefix or a dimension mismatch. + let slopoVersion + if (binary !== undefined && shell !== undefined) { + try { + const versionResult = await shell.run( + shell.resolve({ + command: commandLine([binary, '--version']), + workdir: paths.cwd, + timeoutMs: 30_000, + ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), + }), + ) + slopoVersion = + versionResult.exitCode === 0 + ? (versionResult.stdout?.text ?? '').trim().split('\n')[0] + : `exit code ${versionResult.exitCode}: ${(versionResult.stderr?.text ?? '').trim().slice(0, 200)}` + } catch (error) { + slopoVersion = `could not run: ${error?.message ?? error}` + } + } + + // A direct fetch rather than the shell seam: the probe is one small + // HTTP request and does not belong behind a sandbox boundary that + // exists to confine filesystem writes. + const endpoint = + missing.length === 0 + ? await probeEmbeddingEndpoint({ + apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '', + model: String(slopoConfig.embedding_model ?? ''), + dimensions: Number(slopoConfig.embedding_dimensions ?? 0), + apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '', + }) + : { checked: false, skipped: 'nothing is configured yet' } + + let message + if (missing.length > 0) { + message = + `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` + + 'to set embeddingModel and embeddingDimensions, then "prepare".' + } else if (endpoint.ok === false) { + message = + `Configured, but the embedding backend failed its check: ${endpoint.error}. ` + + `(probed ${endpoint.url} with model "${endpoint.sentModel}")` + } else if (endpoint.checked === true) { + message = + `Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` + + `${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.` + } else { + message = + `Configured against "${slopoConfig.embedding_model}" at ` + + `${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.` + } + return foreground({ - ok: missing.length === 0, + ok: missing.length === 0 && endpoint.ok !== false, exitCode: null, details: { dshHome, @@ -443,19 +507,17 @@ export function apply(ctx, config) { ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null, agentLog: existsSync(paths.logFile) ? paths.logFile : null, slopoBinary: binary ?? null, + slopoVersion: slopoVersion ?? null, slopoInstalledInto: binary === undefined ? null : dshHome, settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE, embeddingModel: slopoConfig.embedding_model ?? null, embeddingDimensions: slopoConfig.embedding_dimensions ?? null, apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '', + embeddingEndpoint: endpoint, missingSettings: missing, problems, }, - message: - missing.length === 0 - ? `${binary === undefined ? 'slopo is not installed yet; it will be installed on first use.' : `slopo: ${binary}`}` - : `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` + - 'to set embeddingModel and embeddingDimensions, then "prepare".', + message, }) } @@ -505,8 +567,6 @@ export function apply(ctx, config) { // preset. That confines a call the session asked to run unconfined, and // on a host with no sandbox runner it fails with SandboxUnavailableError // before slopo is ever reached. - const sandboxDefault = shell.sandboxMode - const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy') if (sandboxDefault !== undefined && policyService === undefined) { return foreground({ ok: false, @@ -516,9 +576,6 @@ export function apply(ctx, config) { remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.', }) } - const sandboxPolicy = policyService?.resolve( - exec.agent === undefined ? {} : { session: exec.agent.session }, - ) // Slopo's state is deliberately outside the workspace, which a // workspace-write sandbox forbids writing to. Say so before a run and diff --git a/lib/probe.js b/lib/probe.js new file mode 100644 index 0000000..fba3990 --- /dev/null +++ b/lib/probe.js @@ -0,0 +1,173 @@ +/** + * Pre-flight checks for the embedding backend. + * + * Slopo reports a bad embedding configuration as two words on stdout — + * `Error: Embedding failed.` — with the real reason buried in its agent log. + * That is a slow way to find out that a model string has the wrong provider + * prefix or that the configured dimensions do not match what the model returns. + * + * These checks run *before* a real embed so the answer arrives in one tool call + * instead of after a failed index. + */ + +/** + * LiteLLM provider prefixes we are confident are prefixes. + * + * This allow-list exists because the alternative — stripping the first path + * segment unconditionally — mangles a model that legitimately contains a slash. + * `unclemusclez/jina-embeddings-v2-base-code:latest` is a real Ollama model name + * with no provider prefix at all, and stripping it would produce nonsense. An + * unknown first segment is therefore left alone, which fails safe: the probe + * reports what it sent, so a wrong guess is visible rather than silent. + */ +const KNOWN_PROVIDERS = new Set([ + 'openai', + 'openai_like', + 'ollama', + 'ollama_chat', + 'hosted_vllm', + 'azure', + 'azure_ai', + 'cohere', + 'voyage', + 'jina_ai', + 'mistral', + 'gemini', + 'vertex_ai', + 'bedrock', + 'huggingface', + 'together_ai', + 'deepinfra', + 'openrouter', + 'xinference', + 'nvidia_nim', + 'sagemaker', + 'watsonx', + 'databricks', + 'custom', +]) + +/** + * The model name an OpenAI-compatible endpoint expects, without LiteLLM's + * provider prefix. + * + * @param {string} model + * @returns {string} + */ +export function bareModelName(model) { + const trimmed = String(model).trim() + const slash = trimmed.indexOf('/') + if (slash <= 0) return trimmed + const prefix = trimmed.slice(0, slash) + return KNOWN_PROVIDERS.has(prefix) ? trimmed.slice(slash + 1) : trimmed +} + +/** + * The `/embeddings` URL for a configured base, tolerating a base that already + * carries the version segment. + * + * @param {string} apiBase + * @returns {string} + */ +export function embeddingUrl(apiBase) { + const base = String(apiBase).trim().replace(/\/+$/, '') + if (base === '') return '' + return /\/v\d+$/.test(base) ? `${base}/embeddings` : `${base}/v1/embeddings` +} + +/** + * Make one embedding request and compare the returned width with the + * configured one. + * + * Never throws: every failure is a reported outcome, because this runs inside a + * status check whose whole job is to describe what is wrong. + * + * @param {{ + * apiBase: string, + * model: string, + * dimensions: number, + * apiKey?: string, + * timeoutMs?: number, + * fetchImpl?: typeof fetch, + * }} input + * @returns {Promise>} + */ +export async function probeEmbeddingEndpoint({ + apiBase, + model, + dimensions, + apiKey = '', + timeoutMs = 8_000, + fetchImpl = globalThis.fetch, +}) { + if (typeof apiBase !== 'string' || apiBase.trim() === '') { + return { checked: false, skipped: 'no embeddingApiBase configured, so there is nothing to probe' } + } + if (typeof fetchImpl !== 'function') { + return { checked: false, skipped: 'this runtime has no fetch' } + } + const url = embeddingUrl(apiBase) + const sentModel = bareModelName(model) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const headers = { 'content-type': 'application/json' } + if (apiKey !== '') headers.authorization = `Bearer ${apiKey}` + const response = await fetchImpl(url, { + method: 'POST', + headers, + body: JSON.stringify({ model: sentModel, input: ['slopo connectivity probe'] }), + signal: controller.signal, + }) + const text = await response.text() + if (!response.ok) { + return { + checked: true, + reachable: true, + ok: false, + url, + sentModel, + status: response.status, + error: text.slice(0, 400), + } + } + let parsed + try { + parsed = JSON.parse(text) + } catch { + return { checked: true, reachable: true, ok: false, url, sentModel, error: 'the endpoint returned non-JSON' } + } + const vector = parsed?.data?.[0]?.embedding + if (!Array.isArray(vector)) { + return { checked: true, reachable: true, ok: false, url, sentModel, error: 'the response carried no embedding' } + } + const actual = vector.length + const expected = Number(dimensions) + return { + checked: true, + reachable: true, + ok: actual === expected, + url, + sentModel, + dimensions: actual, + expected, + error: + actual === expected + ? undefined + : `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`, + } + } catch (error) { + const aborted = error?.name === 'AbortError' + return { + checked: true, + reachable: false, + ok: false, + url, + sentModel, + error: aborted ? `no response within ${timeoutMs}ms` : String(error?.message ?? error), + } + } finally { + clearTimeout(timer) + } +} +