Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 136 additions & 1 deletion src/lib/local-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,19 @@ export function validateOllamaModel(
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
const errText = parsed.error.trim();
if (/does not support tools/i.test(errText)) {
return {
ok: false,
message:
`Selected Ollama model '${model}' does not support tool calling, which ` +
`NemoClaw agents require. Switch to a tools-capable model: ` +
`${TOOLS_CAPABLE_OLLAMA_MODELS.join(", ")}.`,
Comment thread
TonyLuo-NV marked this conversation as resolved.
Outdated
};
}
return {
ok: false,
message: `Selected Ollama model '${model}' failed the local probe: ${parsed.error.trim()}`,
message: `Selected Ollama model '${model}' failed the local probe: ${errText}`,
};
}
} catch {
Expand All @@ -556,3 +566,128 @@ export function validateOllamaModel(

return { ok: true };
}

// ─── Tools-capability probe (issue #2667) ─────────────────────────
//
// Ollama exposes a model's declared capabilities via /api/show. Tool calling
// is gated on a "tools" entry in that array. Models without it raise
// "400 ... does not support tools" the first time the agent issues a tool
// call — too late to recover gracefully. The onboard flow probes this up
// front and warns or blocks before the user wastes a long pull.

/**
* Tools-capable Ollama starter models, surfaced in user-facing warnings and
* error messages. Order matches the bootstrap selector order (smallest first).
*/
export const TOOLS_CAPABLE_OLLAMA_MODELS = [
"qwen2.5:7b",
"llama3.1",
"mistral-nemo",
"nemotron-3-nano:30b",
];

export interface OllamaCapabilities {
source: "api" | "unknown";
capabilities: string[];
supportsTools: boolean | null;
rawError?: string;
}

/**
* Probe `/api/show` for a model's declared capabilities. Returns
* `{source:"api", supportsTools: bool}` when the response is well-formed,
* or `{source:"unknown", supportsTools: null, rawError}` on any failure
* (network, HTTP error, malformed JSON, missing field, unexpected shape).
*
* Defensive parsing is intentional: older Ollama daemons and custom registries
* may omit the `capabilities` field. We never block on probe failure.
*/
export function probeOllamaModelCapabilities(
model: string,
runCaptureImpl?: RunCaptureFn,
): OllamaCapabilities {
const capture = runCaptureImpl ?? runCapture;
const host = getResolvedOllamaHost();
const body = JSON.stringify({ model });
let output: string;
try {
output = capture(
[
"curl",
"-sS",
"--connect-timeout",
"3",
"--max-time",
"5",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
body,
`http://${host}:${OLLAMA_PORT}/api/show`,
],
{ ignoreError: true },
);
} catch (err) {
return {
source: "unknown",
capabilities: [],
supportsTools: null,
rawError: err instanceof Error ? err.message : String(err),
};
}

if (!output || !String(output).trim()) {
return {
source: "unknown",
capabilities: [],
supportsTools: null,
rawError: "empty response from /api/show",
};
}

let parsed: unknown;
try {
parsed = JSON.parse(String(output));
} catch (err) {
return {
source: "unknown",
capabilities: [],
supportsTools: null,
rawError: err instanceof Error ? err.message : "JSON parse error",
};
}

if (!parsed || typeof parsed !== "object") {
return {
source: "unknown",
capabilities: [],
supportsTools: null,
rawError: "unexpected /api/show payload shape",
};
}

const capsRaw = (parsed as { capabilities?: unknown }).capabilities;
if (!Array.isArray(capsRaw)) {
// Ollama returned a body but no capabilities array (older version,
// custom registry, or shape change). Degrade to unknown.
const errText =
typeof (parsed as { error?: unknown }).error === "string"
? String((parsed as { error?: unknown }).error)
: "missing capabilities field";
return {
source: "unknown",
capabilities: [],
supportsTools: null,
rawError: errText,
};
}

const capabilities = capsRaw.filter((c: unknown): c is string => typeof c === "string");
return {
source: "api",
capabilities,
supportsTools: capabilities.includes("tools"),
};
}
122 changes: 122 additions & 0 deletions src/lib/onboard-ollama-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const {
getOllamaWarmupCommand,
getResolvedOllamaHost,
OLLAMA_HOST_DOCKER_INTERNAL,
probeOllamaModelCapabilities,
TOOLS_CAPABLE_OLLAMA_MODELS,
validateOllamaModel,
} = require("./local-inference");
const { buildSubprocessEnv } = require("./subprocess-env");
Expand Down Expand Up @@ -490,6 +492,120 @@ async function pullOllamaModel(model) {
return pullOllamaModelViaCli(model);
}

// ── Tools-capability gate (issue #2667) ─────────────────────────
//
// Ollama models without the "tools" capability fail at first agent prompt
// with "400 ... does not support tools" — too late to recover gracefully.
// We probe /api/show right after the pull completes (and before warmup) to
// warn the user up front and either prompt for confirmation, accept an
// override env var in non-interactive mode, or block. Probe failures
// degrade to "unknown" and never block onboarding.

function isProxyNonInteractive(): boolean {
// Lazy-require to avoid a circular import (onboard.ts requires this file
// at module-load time). isNonInteractive is exported from onboard.ts;
// fall back to the env var if onboard hasn't fully loaded yet.
try {
const onboardMod = require("./onboard");
if (typeof onboardMod.isNonInteractive === "function") {
return Boolean(onboardMod.isNonInteractive());
}
} catch {
/* fall through to env-var check */
}
return process.env.NEMOCLAW_NON_INTERACTIVE === "1";
}

function isProxyAutoYes(): boolean {
// isAutoYes is not exported from onboard.ts, so fall back to the env var.
// The interactive override prompt path still covers --yes-only invocations
// because non-interactive mode is the gate that matters here.
try {
const onboardMod = require("./onboard");
if (typeof onboardMod.isAutoYes === "function") {
return Boolean(onboardMod.isAutoYes());
}
} catch {
/* fall through to env-var check */
}
return process.env.NEMOCLAW_YES === "1";
}

async function promptProxyYesNo(question: string, defaultIsYes: boolean): Promise<boolean> {
// Prefer onboard's promptYesNoOrDefault so we get the same indicator
// formatting and non-interactive note. Lazy-require to avoid the cycle.
try {
const onboardMod = require("./onboard");
if (typeof onboardMod.promptYesNoOrDefault === "function") {
return Boolean(await onboardMod.promptYesNoOrDefault(question, null, defaultIsYes));
}
} catch {
/* fall through */
}
const reply = await prompt(`${question} ${defaultIsYes ? "[Y/n]" : "[y/N]"}: `);
const v = String(reply ?? "").trim().toLowerCase();
if (v === "y" || v === "yes") return true;
if (v === "n" || v === "no") return false;
return defaultIsYes;
}

function printToolsIncompatibleWarning(model: string): void {
console.log("");
console.log(` ⚠ Ollama model '${model}' does not advertise the 'tools' capability.`);
console.log(" NemoClaw agents need tool-calling for file operations, web search, and");
console.log(" running commands. This model will likely fail with \"400 ... does not");
console.log(" support tools\" at first prompt.");
console.log(
` Tools-capable starter models: ${TOOLS_CAPABLE_OLLAMA_MODELS.join(", ")}`,
);
}

async function checkOllamaModelToolSupport(
model: string,
): Promise<{ ok: boolean; message?: string }> {
const caps = probeOllamaModelCapabilities(model);

if (caps.supportsTools === true) {
return { ok: true };
}

if (caps.supportsTools === null) {
// Graceful degradation — never block on probe failure.
console.log(
` \x1b[2mCould not verify 'tools' capability for '${model}' — Ollama did ` +
`not return capability metadata; continuing.\x1b[0m`,
);
return { ok: true };
}

// supportsTools === false — model is on disk but advertises no tools support.
printToolsIncompatibleWarning(model);

if (isProxyAutoYes()) {
console.log(" Continuing because --yes was passed.");
return { ok: true };
}

if (isProxyNonInteractive()) {
if (process.env.NEMOCLAW_OLLAMA_REQUIRE_TOOLS === "0") {
console.error(
` NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 set — proceeding with '${model}' despite missing 'tools'.`,
);
return { ok: true };
}
console.error(
" Re-run with NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 to override, or pick a tools-capable model.",
);
return { ok: false, message: "Tools-incompatible model in non-interactive mode." };
}

const proceed = await promptProxyYesNo(" Use this model anyway?", false);
if (!proceed) {
return { ok: false, message: "Choose a tools-capable model." };
}
return { ok: true };
}

async function prepareOllamaModel(model, installedModels = []) {
const alreadyInstalled = installedModels.includes(model);
if (!alreadyInstalled) {
Expand All @@ -504,6 +620,11 @@ async function prepareOllamaModel(model, installedModels = []) {
}
}

const capCheck = await checkOllamaModelToolSupport(model);
if (!capCheck.ok) {
return { ok: false, message: capCheck.message };
}

console.log(` Loading Ollama model: ${model}`);
run(getOllamaWarmupCommand(model), { ignoreError: true });
return validateOllamaModel(model);
Expand Down Expand Up @@ -568,6 +689,7 @@ function unloadOllamaModels() {
}

export {
checkOllamaModelToolSupport,
ensureOllamaAuthProxy,
getOllamaProxyToken,
getOllamaPullTimeoutMs,
Expand Down
Loading
Loading