diff --git a/extensions/ollama/src/node-inference.test.ts b/extensions/ollama/src/node-inference.test.ts index 7e0e5dd35a27..b955d3bb1c26 100644 --- a/extensions/ollama/src/node-inference.test.ts +++ b/extensions/ollama/src/node-inference.test.ts @@ -27,7 +27,10 @@ async function withOllamaServer( chatRequests: Record[], showRequests: string[], ) => Promise, - options?: { models: Array> }, + options?: { + models: Array>; + loadedModels?: Array>; + }, ): Promise { const chatRequests: Record[] = []; const showRequests: string[] = []; @@ -72,7 +75,7 @@ async function withOllamaServer( return; } if (request.url === "/api/ps") { - response.end(JSON.stringify({ models: [{ name: "chat:large" }] })); + response.end(JSON.stringify({ models: options?.loadedModels ?? [{ name: "chat:large" }] })); return; } if (request.url === "/api/show") { @@ -194,6 +197,29 @@ describe("Ollama node host inference", () => { ); }); + it("discovers a loaded local model beyond the completion model limit", async () => { + const models = [ + ...Array.from({ length: 200 }, (_, index) => ({ name: `chat-${index}:latest` })), + { name: "chat:loaded", size: 500 }, + ]; + + await withOllamaServer( + async (baseUrl, _chatRequests, showRequests) => { + const result = JSON.parse(await commandByName(baseUrl, OLLAMA_MODELS_COMMAND).handle()) as { + provider: string; + models: Array<{ name: string; loaded: boolean }>; + }; + + expect(result.provider).toBe("ollama"); + expect(result.models).toHaveLength(200); + expect(result.models[0]).toMatchObject({ name: "chat:loaded", loaded: true }); + expect(showRequests[0]).toBe("chat:loaded"); + expect(showRequests).toHaveLength(200); + }, + { models, loadedModels: [{ name: "chat:loaded" }] }, + ); + }); + it("runs bounded chat and returns compact usage", async () => { await withOllamaServer(async (baseUrl, chatRequests, showRequests) => { const result = JSON.parse( diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 7fa0b07fa833..a4b09519590a 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -188,12 +188,16 @@ async function discoverOllamaNodeModels( const localModels = discovered.models.filter( (model) => !model.remote_host?.trim() && !isOllamaCloudModel(model.name), ); - const [models, loadedNames] = await Promise.all([ - // Paired nodes must positively confirm completion; unlike provider catalogs, - // failed or legacy show probes must never expose unrunnable remote commands. - enrichOllamaCompletionModels(apiBase, localModels, { requireCompletionCapability: true }), - fetchLoadedModelNames(apiBase), - ]); + const loadedNames = await fetchLoadedModelNames(apiBase); + // Probe loaded models before the bounded catalog can hide already-runnable node models. + const prioritizedModels = localModels.toSorted( + (left, right) => Number(loadedNames.has(right.name)) - Number(loadedNames.has(left.name)), + ); + // Paired nodes must positively confirm completion; unlike provider catalogs, + // failed or legacy show probes must never expose unrunnable remote commands. + const models = await enrichOllamaCompletionModels(apiBase, prioritizedModels, { + requireCompletionCapability: true, + }); const rows = models .map((model): NodeModel => { const details = model.details; diff --git a/extensions/sglang/models.ts b/extensions/sglang/models.ts index 2b5282bb84bb..f3dc35c02789 100644 --- a/extensions/sglang/models.ts +++ b/extensions/sglang/models.ts @@ -15,6 +15,7 @@ export async function buildSglangProvider(params?: { baseUrl, apiKey: params?.apiKey, label: SGLANG_PROVIDER_LABEL, + discoverRuntimeContext: false, }); return { baseUrl, diff --git a/extensions/vllm/models.ts b/extensions/vllm/models.ts index fb7b7aa10eda..b47ebf64eed5 100644 --- a/extensions/vllm/models.ts +++ b/extensions/vllm/models.ts @@ -15,6 +15,7 @@ export async function buildVllmProvider(params?: { baseUrl, apiKey: params?.apiKey, label: VLLM_PROVIDER_LABEL, + discoverRuntimeContext: false, }); return { baseUrl, diff --git a/qa/scenarios/models/ollama-paired-node-inference.yaml b/qa/scenarios/models/ollama-paired-node-inference.yaml new file mode 100644 index 000000000000..41545d3baa62 --- /dev/null +++ b/qa/scenarios/models/ollama-paired-node-inference.yaml @@ -0,0 +1,30 @@ +title: Ollama paired-node local inference regression + +scenario: + id: ollama-paired-node-inference + surface: models + category: agent-runtime.local-and-self-hosted-providers + coverage: + secondary: + - gateway.node-capabilities + - gateway.remote-host-commands + - agent-runtime.tool-capability-flags + - agent-runtime.local-smoke-checks + - agent-runtime.local-failure-handling + objective: Verify paired-node Ollama discovery and inference with the provider-owned deterministic local test server. + successCriteria: + - Embedding and cloud models are excluded from local chat discovery. + - A chat model after 200 embedding-only models remains discoverable. + - Loaded local chat models sort first. + - Only Gateway-authorized node commands are invoked. + - Bounded local chat returns the expected response and usage. + docsRefs: + - docs/providers/ollama.md + - docs/nodes/index.md + codeRefs: + - extensions/ollama/src/node-inference.ts + - extensions/ollama/src/node-inference.test.ts + execution: + kind: vitest + path: extensions/ollama/src/node-inference.test.ts + summary: Run deterministic fake-Ollama paired-node discovery, authorization, model-selection, and bounded-chat regression tests. diff --git a/src/cli/node-cli/daemon.test.ts b/src/cli/node-cli/daemon.test.ts index 00f71eb66500..699efaf07279 100644 --- a/src/cli/node-cli/daemon.test.ts +++ b/src/cli/node-cli/daemon.test.ts @@ -200,6 +200,31 @@ describe("runNodeDaemonStatus", () => { mocks.service.readRuntime.mockReset().mockResolvedValue({ status: "running" }); }); + it("reports a failed service check instead of claiming the node is not installed", async () => { + mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable")); + + await runNodeDaemonStatus(); + + expect(mocks.runtime.error).toHaveBeenCalledWith( + "Node service check failed: Error: systemd unavailable", + ); + expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(stdout()).not.toContain("not loaded"); + expect(stdout()).not.toContain("openclaw node install"); + }); + + it("reports a failed service check as JSON without inventing node status", async () => { + mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable")); + + await runNodeDaemonStatus({ json: true }); + + expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ + error: "Node service check failed: Error: systemd unavailable", + }); + expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.runtime.error).not.toHaveBeenCalled(); + }); + it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => { mocks.service.readRuntime.mockResolvedValue({ status: "stopped", missingUnit: true }); diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 741446bb4a1d..e844e3b88b3d 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -215,8 +215,20 @@ export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) { export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { const json = Boolean(opts.json); const service = resolveNodeService(); - const [loaded, command, runtime] = await Promise.all([ - service.isLoaded({ env: process.env }).catch(() => false), + let loaded: boolean; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (error) { + const message = `Node service check failed: ${String(error)}`; + if (json) { + defaultRuntime.writeJson({ error: message }); + } else { + defaultRuntime.error(message); + } + defaultRuntime.exit(1); + return; + } + const [command, runtime] = await Promise.all([ service.readCommand(process.env).catch(() => null), service .readRuntime(process.env) diff --git a/src/node-host/invoke.mcp.test.ts b/src/node-host/invoke.mcp.test.ts index 24f546b0fa63..eaf390216f07 100644 --- a/src/node-host/invoke.mcp.test.ts +++ b/src/node-host/invoke.mcp.test.ts @@ -132,6 +132,7 @@ describe("mcp.tools.call.v1", () => { { signal: controller.signal }, ); await vi.waitFor(() => expect(callMcpTool).toHaveBeenCalledOnce()); + expect(callMcpTool.mock.calls[0]?.[0].signal).toBe(controller.signal); controller.abort(); resolveTool?.({ content: [{ type: "text", text: "stale MCP result" }] }); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index d35b766b4e0f..3ede07df226e 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -720,7 +720,7 @@ async function dispatchInvoke( } if (command === NODE_MCP_TOOLS_CALL_COMMAND) { - await handleMcpToolsCall(frame, client, mcpManager); + await handleMcpToolsCall(frame, client, mcpManager, runtime.signal); return; } @@ -1009,6 +1009,7 @@ async function handleMcpToolsCall( frame: NodeInvokeRequestPayload, client: NodeHostClient, mcpManager: NodeHostMcpManager | undefined, + signal?: AbortSignal, ): Promise { if (!mcpManager) { await sendErrorResult(client, frame, "MCP_SERVER_UNAVAILABLE", "node host MCP is unavailable"); @@ -1025,6 +1026,7 @@ async function handleMcpToolsCall( const result = await mcpManager.callMcpTool({ ...params, timeoutMs: frame.timeoutMs ?? undefined, + ...(signal ? { signal } : {}), }); if (result.isError) { await sendErrorResult(client, frame, "MCP_TOOL_ERROR", mcpToolErrorMessage(result)); diff --git a/src/node-host/mcp.test.ts b/src/node-host/mcp.test.ts index 4b9c7b296c37..12844680383b 100644 --- a/src/node-host/mcp.test.ts +++ b/src/node-host/mcp.test.ts @@ -18,7 +18,7 @@ function tool(name: string, description?: string): Tool { function createClient(params?: { connectError?: Error; tools?: Tool[]; - call?: (options?: { timeout?: number }) => Promise; + call?: (options?: { timeout?: number; signal?: AbortSignal }) => Promise; }) { return { onclose: undefined as (() => void) | undefined, @@ -32,7 +32,7 @@ function createClient(params?: { async ( _input: unknown, _schema?: undefined, - options?: { timeout?: number }, + options?: { timeout?: number; signal?: AbortSignal }, ): Promise => params?.call ? await params.call(options) : { content: [{ type: "text", text: "ok" }] }, ), @@ -207,6 +207,44 @@ describe("node host MCP manager", () => { await manager.close(); }); + it("cancels an in-flight MCP tool when its node invocation is aborted", async () => { + const controller = new AbortController(); + const client = createClient({ + tools: [tool("slow")], + call: async (options) => + await new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + const reason = options.signal?.reason; + reject(reason instanceof Error ? reason : new Error("node invocation canceled")); + }, + { once: true }, + ); + }), + }); + const manager = await startNodeHostMcpManager( + { docs: { command: "docs" } }, + { createClient: () => client, resolveTransport: () => transport, warn: vi.fn() }, + ); + + const pending = manager.callMcpTool({ + server: "docs", + tool: "slow", + signal: controller.signal, + }); + await vi.waitFor(() => expect(client.callTool).toHaveBeenCalledOnce()); + expect(client.callTool).toHaveBeenCalledWith({ name: "slow", arguments: {} }, undefined, { + timeout: 120_000, + signal: controller.signal, + }); + + controller.abort(new Error("node invocation canceled")); + + await expect(pending).rejects.toMatchObject({ code: "MCP_TOOL_ERROR" }); + await manager.close(); + }); + it("returns structured timeout, unknown-server, and dead-client errors", async () => { const client = createClient({ tools: [tool("slow")], diff --git a/src/node-host/mcp.ts b/src/node-host/mcp.ts index c1e1f956f364..96718d341904 100644 --- a/src/node-host/mcp.ts +++ b/src/node-host/mcp.ts @@ -40,7 +40,7 @@ type NodeHostMcpClient = { callTool( params: { name: string; arguments?: Record }, resultSchema?: undefined, - options?: { timeout?: number }, + options?: { timeout?: number; signal?: AbortSignal }, ): Promise; close(): Promise; }; @@ -84,6 +84,7 @@ export type NodeHostMcpManager = { tool: string; arguments?: Record; timeoutMs?: number; + signal?: AbortSignal; }): Promise; close(): Promise; }; @@ -397,6 +398,7 @@ export async function startNodeHostMcpManager( undefined, { timeout: Math.min(resolveCallTimeoutMs(params.timeoutMs), session.toolCallTimeoutMs), + ...(params.signal ? { signal: params.signal } : {}), }, ); } catch (error) { diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 1eff71eb2eb1..492dea7e7d3a 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -147,6 +147,32 @@ function cancelTrackedResponse(init?: ResponseInit): { } describe("discoverOpenAICompatibleLocalModels", () => { + it("discovers a large non-llama.cpp catalog without probing per-model llama.cpp props", async () => { + const release = vi.fn(async () => undefined); + const data = Array.from({ length: 500 }, (_, index) => ({ + id: `Qwen/model-${index}`, + meta: { n_ctx_train: 32_768 }, + })); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ data }), { status: 200 }), + finalUrl: "http://127.0.0.1:8000/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8000/v1", + label: "vLLM", + discoverRuntimeContext: false, + env: {}, + }); + + expect(models).toHaveLength(500); + expect(models[0]).toMatchObject({ id: "Qwen/model-0", contextWindow: 32_768 }); + expect(models[499]).toMatchObject({ id: "Qwen/model-499", contextWindow: 32_768 }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); + }); + it("labels malformed discovery JSON in the warning", async () => { const release = vi.fn(async () => undefined); fetchWithSsrFGuardMock.mockResolvedValueOnce({ diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 2050b7455e2d..965b8d3f21d8 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -167,6 +167,7 @@ export async function discoverOpenAICompatibleLocalModels(params: { apiKey?: string; label: string; contextWindow?: number; + discoverRuntimeContext?: boolean; maxTokens?: number; env?: NodeJS.ProcessEnv; }): Promise { @@ -212,7 +213,7 @@ export async function discoverOpenAICompatibleLocalModels(params: { return [{ id: modelId, meta: model.meta }]; }); const runtimeContextTokensByModelId = new Map(); - if (params.contextWindow === undefined) { + if (params.contextWindow === undefined && params.discoverRuntimeContext !== false) { const uniqueModelIds = uniqueStrings(discoveredModels.map((model) => model.id)); const runtimeContextTokenResults = await Promise.all( uniqueModelIds.map(