diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5b7fc1d99b87..025a6e632b97 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11956,6 +11956,7 @@ public struct ModelChoice: Codable, Sendable { public let available: Bool? public let contextwindow: Int? public let reasoning: Bool? + public let supportstools: Bool? public let agentruntime: [String: AnyCodable]? public let apikeysupported: Bool? public let input: [AnyCodable]? @@ -11968,6 +11969,7 @@ public struct ModelChoice: Codable, Sendable { available: Bool? = nil, contextwindow: Int? = nil, reasoning: Bool? = nil, + supportstools: Bool? = nil, agentruntime: [String: AnyCodable]? = nil, apikeysupported: Bool? = nil, input: [AnyCodable]? = nil) @@ -11979,6 +11981,7 @@ public struct ModelChoice: Codable, Sendable { self.available = available self.contextwindow = contextwindow self.reasoning = reasoning + self.supportstools = supportstools self.agentruntime = agentruntime self.apikeysupported = apikeysupported self.input = input @@ -11992,6 +11995,7 @@ public struct ModelChoice: Codable, Sendable { case available case contextwindow = "contextWindow" case reasoning + case supportstools = "supportsTools" case agentruntime = "agentRuntime" case apikeysupported = "apiKeySupported" case input diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index f5e2f5a9e45c..46a03dc47173 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -37,6 +37,7 @@ export const ModelChoiceSchema = closedObject({ available: Type.Optional(Type.Boolean()), contextWindow: Type.Optional(Type.Integer({ minimum: 1 })), reasoning: Type.Optional(Type.Boolean()), + supportsTools: Type.Optional(Type.Boolean()), agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), apiKeySupported: Type.Optional(Type.Boolean()), input: Type.Optional( diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index af866fe7ac50..b08aea516277 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -25,6 +25,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; +import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js"; import { buildProjectMemoryWriteInstruction, prepareProjectMemoryBootstrap, @@ -65,6 +66,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { getProviderRuntimeHandle: () => ProviderRuntimePluginHandle; isRawModelRun: boolean; markStage: (name: string) => void; + modelToolsEnabled: boolean; proactiveSubagentOrchestration: boolean; sandbox?: SandboxContext; sandboxSessionKey: string; @@ -273,6 +275,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { const projectMemoryWriteInstruction = buildProjectMemoryWriteInstruction( attempt.preparedModelRuntime?.projectKey, ); + const extraSystemPrompt = + [ + attempt.extraSystemPrompt, + projectMemoryWriteInstruction, + buildModelToolsUnavailablePrompt(params.modelToolsEnabled), + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n") || undefined; const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun: params.isRawModelRun, @@ -287,9 +297,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { workspaceDir: params.effectiveWorkspace, defaultThinkLevel: attempt.thinkLevel, reasoningLevel: attempt.reasoningLevel ?? "off", - extraSystemPrompt: projectMemoryWriteInstruction - ? [attempt.extraSystemPrompt, projectMemoryWriteInstruction].filter(Boolean).join("\n\n") - : attempt.extraSystemPrompt, + extraSystemPrompt, ownerNumbers: attempt.ownerNumbers, reasoningTagHint, heartbeatPrompt, diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index c30d400f03e9..06f4e1a7c4cc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -744,7 +744,8 @@ vi.mock("../../model-auth.js", () => ({ resolveModelAuthMode: () => undefined, })); -vi.mock("../../model-tool-support.js", () => ({ +vi.mock("../../model-tool-support.js", async (importOriginal) => ({ + ...(await importOriginal()), supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args), })); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1155d41528bd..96d512902376 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -292,6 +292,7 @@ export async function runEmbeddedAttempt( getProviderRuntimeHandle, isRawModelRun, markStage: (name) => prepStages.mark(name), + modelToolsEnabled: toolsEnabled, proactiveSubagentOrchestration, sandbox: sandbox ?? undefined, sandboxSessionKey, diff --git a/src/agents/model-tool-support.test.ts b/src/agents/model-tool-support.test.ts index d7f7b2db6e7b..1e3c39f4b5c0 100644 --- a/src/agents/model-tool-support.test.ts +++ b/src/agents/model-tool-support.test.ts @@ -1,6 +1,6 @@ // Documents model tool-support compatibility defaults. import { describe, expect, it } from "vitest"; -import { supportsModelTools } from "./model-tool-support.js"; +import { buildModelToolsUnavailablePrompt, supportsModelTools } from "./model-tool-support.js"; describe("supportsModelTools", () => { it("defaults to true when the model has no compat override", () => { @@ -15,3 +15,13 @@ describe("supportsModelTools", () => { expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false); }); }); + +describe("buildModelToolsUnavailablePrompt", () => { + it("tells chat-only models not to invent tool-backed work", () => { + expect(buildModelToolsUnavailablePrompt(true)).toBeUndefined(); + expect(buildModelToolsUnavailablePrompt(false)).toContain( + "Do not claim that you ran commands, read or wrote files, browsed the web, generated media", + ); + expect(buildModelToolsUnavailablePrompt(false)).toContain("switch to a tool-capable model"); + }); +}); diff --git a/src/agents/model-tool-support.ts b/src/agents/model-tool-support.ts index 742907d8cf38..2f0b57da3e2a 100644 --- a/src/agents/model-tool-support.ts +++ b/src/agents/model-tool-support.ts @@ -4,6 +4,9 @@ * Provider catalogs can opt a model out via `compat.supportsTools === false`; * absent metadata remains permissive for older catalog entries. */ +const MODEL_TOOLS_UNAVAILABLE_PROMPT = + "## Tool availability\n\nThis model cannot use tools in this run. Do not claim that you ran commands, read or wrote files, browsed the web, generated media, or performed any other tool-backed action. If a request requires tools, say they are unavailable in this chat and ask the user to switch to a tool-capable model."; + /** Returns whether a catalog model should be offered tool calls. */ export function supportsModelTools(model: { compat?: unknown }): boolean { const compat = @@ -12,3 +15,8 @@ export function supportsModelTools(model: { compat?: unknown }): boolean { : undefined; return compat?.supportsTools !== false; } + +/** Builds the bounded honesty guard for models that explicitly disable tools. */ +export function buildModelToolsUnavailablePrompt(modelToolsEnabled: boolean): string | undefined { + return modelToolsEnabled ? undefined : MODEL_TOOLS_UNAVAILABLE_PROMPT; +} diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 9b5941d76c9d..ecd75339299b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -60,7 +60,7 @@ type ModelsListView = ModelCatalogBrowseView; type ModelsListEntry = Pick< ModelCatalogEntry, "alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning" -> & { available?: boolean }; +> & { available?: boolean; supportsTools?: boolean }; type ModelsListEntryWithCapabilities = ModelsListEntry & { agentRuntime?: GatewayAgentRuntime; apiKeySupported?: boolean; @@ -96,6 +96,9 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { ...(entry.alias ? { alias: entry.alias } : {}), ...(contextWindow ? { contextWindow } : {}), ...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}), + ...(typeof entry.compat?.supportsTools === "boolean" + ? { supportsTools: entry.compat.supportsTools } + : {}), }; } diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index f6396a97f59b..90aed1058314 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -92,6 +92,7 @@ type ModelCatalogRpcEntry = { contextWindow?: number; input?: string[]; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: GatewayAgentRuntime; }; @@ -179,6 +180,7 @@ type ConfiguredProviderModelFixture = { name: string; alias: string; contextWindow: number; + supportsTools?: boolean; }; const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) => ({ @@ -200,6 +202,9 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) = id: params.modelId, name: params.name, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined + ? {} + : { compat: { supportsTools: params.supportsTools } }), }, ], }, @@ -213,6 +218,7 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture) alias: params.alias, provider: params.provider, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined ? {} : { supportsTools: params.supportsTools }), }); describe("gateway server models + voicewake", () => { @@ -362,6 +368,9 @@ describe("gateway server models + voicewake", () => { if (expected.contextWindow !== undefined) { expect(models[0]?.contextWindow).toBe(expected.contextWindow); } + if (expected.supportsTools !== undefined) { + expect(models[0]?.supportsTools).toBe(expected.supportsTools); + } }; test( @@ -757,6 +766,7 @@ describe("gateway server models + voicewake", () => { name: "Kimi K2.5 (Configured)", alias: "Kimi K2.5 (NVIDIA)", contextWindow: 32_000, + supportsTools: false, }, }, { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 6e64deaf133f..a958e9d65392 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -935,6 +935,7 @@ export type ModelCatalogEntry = { available?: boolean; contextWindow?: number; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime; input?: Array<"text" | "image" | "document">; apiKeySupported?: boolean; diff --git a/ui/src/e2e/chat-only-model.e2e.test.ts b/ui/src/e2e/chat-only-model.e2e.test.ts new file mode 100644 index 000000000000..8e45decdaff6 --- /dev/null +++ b/ui/src/e2e/chat-only-model.e2e.test.ts @@ -0,0 +1,168 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const sessionKey = "agent:main:main"; +const proofDir = + process.env.OPENCLAW_CAPTURE_UI_PROOF === "1" + ? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model") + : null; + +const models = [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 200_000, + supportsTools: true, + }, +]; + +function sessionsList(model: string, modelProvider: string) { + return { + count: 1, + defaults: { + contextTokens: 32_768, + model: "qwen3-8b", + modelProvider: "lmstudio", + thinkingDefault: "off", + thinkingLevels: [{ id: "off", label: "off" }], + }, + path: "", + sessions: [ + { + contextTokens: 32_768, + displayName: "Local chat", + hasActiveRun: false, + key: sessionKey, + kind: "direct", + label: "Local chat", + model, + modelProvider, + status: "done", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +suite.define(() => { + it("explains chat-only models and keeps model switching as the recovery path", async () => { + if (proofDir) { + await mkdir(proofDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + agentModel: "lmstudio/qwen3-8b", + models, + sessionKey, + methodResponses: { + "sessions.list": sessionsList("qwen3-8b", "lmstudio"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + + const main = page.getByRole("main"); + const composer = main.locator(".agent-chat__composer-shell"); + const picker = composer.locator('[data-chat-model-select="true"]'); + const badge = picker.locator(".chat-controls__model-capability-badge"); + + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + await expect.poll(async () => (await badge.textContent())?.trim()).toBe("Chat only"); + await expect.poll(() => picker.getAttribute("aria-label")).toContain("Chat only"); + + if (proofDir) { + await composer.screenshot({ + animations: "disabled", + path: path.join(proofDir, "01-desktop-chat-only-composer.png"), + }); + } + + await picker.click(); + const localOption = composer.locator('[data-chat-model-option="lmstudio/qwen3-8b"]'); + const openAiOption = composer.locator('[data-chat-model-option="openai/gpt-5.5"]'); + await expect + .poll(async () => (await localOption.textContent())?.replace(/\s+/g, " ").trim()) + .toContain("32.8k context · Chat only"); + await expect + .poll(async () => (await openAiOption.textContent())?.includes("Chat only")) + .toBe(false); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "02-desktop-model-picker.png"), + }); + } + + await composer.locator('[data-chat-model-provider="openai"]').click(); + await openAiOption.click(); + const patch = await gateway.waitForRequest("sessions.patch"); + expect(patch.params).toMatchObject({ key: sessionKey, model: "openai/gpt-5.5" }); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("available"); + await expect.poll(() => badge.count()).toBe(0); + + const pickerDetails = composer.locator("details.chat-controls__model"); + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + await composer.locator('[data-chat-model-provider="lmstudio"]').click(); + await localOption.click(); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + if (await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open)) { + await picker.click(); + } + await page.setViewportSize({ height: 844, width: 390 }); + await expect.poll(() => picker.isVisible()).toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "03-mobile-chat-only-model.png"), + }); + } + + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + const menu = composer.locator(".chat-controls__inline-select-menu--combined"); + await expect + .poll(async () => { + const box = await menu.boundingBox(); + return box !== null && box.x >= 0 && box.x + box.width <= 390; + }) + .toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "04-mobile-model-picker.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 544ff72a38ca..117a25cfc816 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4902,6 +4902,9 @@ export const en: TranslationMap = { fastHelp: "Fast responses finish sooner and can use more of your usage limits.", speedUnsupported: "Speed control is not supported for this model.", contextWindow: "{count} context", + chatOnly: "Chat only", + chatOnlyHelp: + "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", providerModels: "{provider} models", resetReasoning: "Reset to default ({level})", useDefaultReasoning: "Use default reasoning ({level})", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 17fd51fc7b3b..4374dadd83ea 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -5352,6 +5352,45 @@ describe("chat model controls", () => { expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); }); + it("marks chat-only models in the active control and picker", () => { + const { state } = createChatHeaderState({ + model: "qwen3-8b", + modelProvider: "lmstudio", + models: [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + supportsTools: true, + }, + ], + }); + const container = renderModelControls(state); + const trigger = getChatModelSelect(container); + + expect(trigger.dataset.chatModelTools).toBe("unavailable"); + expect( + trigger.querySelector(".chat-controls__model-capability-badge")?.textContent?.trim(), + ).toBe("Chat only"); + expect(trigger.getAttribute("aria-label")).toContain("Chat only"); + expect( + container + .querySelector('[data-chat-model-option="lmstudio/qwen3-8b"]') + ?.querySelector(".chat-controls__model-option-meta") + ?.textContent?.trim(), + ).toBe("32.8k context · Chat only"); + expect( + container.querySelector('[data-chat-model-option="openai/gpt-5.5"]')?.textContent, + ).not.toContain("Chat only"); + }); + it("shows canonical OpenAI model names instead of command aliases", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 406eda8e8160..838ca4164a76 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & { contextWindow?: number; isDefault: boolean; provider: string; + supportsTools?: boolean; }; const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly> = { @@ -210,6 +211,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) { return { commitValue: isDefault ? "" : option.value, ...(catalogEntry?.contextWindow ? { contextWindow: catalogEntry.contextWindow } : {}), + ...(typeof catalogEntry?.supportsTools === "boolean" + ? { supportsTools: catalogEntry.supportsTools } + : {}), isDefault, value: option.value, label: resolveChatModelPickerLabel(option.value, option.label, props.modelCatalog), @@ -403,8 +407,21 @@ function renderChatModelReasoningSelect(params: { } = params; const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel); const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel); - const triggerTitle = `${triggerModel} · ${triggerThinking}`; - const triggerLabel = triggerTitle; + const defaultModelOption = modelOptions.find((option) => option.isDefault); + const activeModelOption = + selectedModelValue === "" + ? defaultModelOption + : modelOptions.find((option) => option.value === selectedModelValue); + const selectedModelOption = activeModelOption ?? modelOptions[0]; + const modelToolsUnavailable = activeModelOption?.supportsTools === false; + const triggerTitle = [ + triggerModel, + triggerThinking, + modelToolsUnavailable ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); + const triggerLabel = `${triggerModel} · ${triggerThinking}`; const sliderStops = thinkingOptions.filter((option) => option.value !== ""); const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); const hasThinkingOverride = selectedThinkingValue !== ""; @@ -530,7 +547,6 @@ function renderChatModelReasoningSelect(params: { providerGroups.set(option.provider, [option]); } } - const defaultModelOption = modelOptions.find((option) => option.isDefault); const orderedProviderGroups = [...providerGroups]; const defaultProviderIndex = orderedProviderGroups.findIndex( ([provider]) => provider === defaultModelOption?.provider, @@ -541,21 +557,22 @@ function renderChatModelReasoningSelect(params: { orderedProviderGroups.unshift(defaultProviderGroup); } } - const selectedModelOption = - (selectedModelValue === "" - ? defaultModelOption - : modelOptions.find((option) => option.value === selectedModelValue)) ?? modelOptions[0]; const selectedProvider = selectedModelOption?.provider ?? orderedProviderGroups[0]?.[0] ?? "other"; const renderModelOption = (entry: ChatModelProviderOption) => { const selected = entry.value === selectedModelValue || (entry.isDefault && selectedModelValue === ""); const modelLabel = formatCombinedPickerModelOptionLabel(entry); - const contextLabel = entry.contextWindow - ? t("chat.modelControls.contextWindow", { - count: formatCompactTokenCount(entry.contextWindow), - }) - : ""; + const modelMeta = [ + entry.contextWindow + ? t("chat.modelControls.contextWindow", { + count: formatCompactTokenCount(entry.contextWindow), + }) + : "", + entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); return html`