Merge pull request #116730 from openclaw/fix/lmstudio-no-tools-guard

* origin/fix/lmstudio-no-tools-guard:
  fix(chat): explain chat-only model limits
  feat(models): expose tool support to clients
This commit is contained in:
Vincent Koc
2026-07-31 18:56:17 +08:00
16 changed files with 328 additions and 20 deletions
@@ -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
@@ -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(
@@ -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,
@@ -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<typeof import("../../model-tool-support.js")>()),
supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args),
}));
@@ -292,6 +292,7 @@ export async function runEmbeddedAttempt(
getProviderRuntimeHandle,
isRawModelRun,
markStage: (name) => prepStages.mark(name),
modelToolsEnabled: toolsEnabled,
proactiveSubagentOrchestration,
sandbox: sandbox ?? undefined,
sandboxSessionKey,
+11 -1
View File
@@ -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");
});
});
+8
View File
@@ -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;
}
@@ -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 }
: {}),
};
}
@@ -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,
},
},
{
+1
View File
@@ -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;
+168
View File
@@ -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);
}
});
});
+3
View File
@@ -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})",
+39
View File
@@ -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",
@@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & {
contextWindow?: number;
isDefault: boolean;
provider: string;
supportsTools?: boolean;
};
const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly<Record<string, string>> = {
@@ -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`
<div class="chat-controls__combined-model">
<button
@@ -592,8 +609,8 @@ function renderChatModelReasoningSelect(params: {
>`
: ""}
</span>
${contextLabel
? html`<span class="chat-controls__model-option-meta">${contextLabel}</span>`
${modelMeta
? html`<span class="chat-controls__model-option-meta">${modelMeta}</span>`
: ""}
</span>
${selected
@@ -619,6 +636,7 @@ function renderChatModelReasoningSelect(params: {
data-chat-select-value=${selectedModelValue}
data-chat-thinking-value=${selectedThinkingValue}
data-chat-thinking-disabled=${thinkingDisabled ? "true" : "false"}
data-chat-model-tools=${modelToolsUnavailable ? "unavailable" : "available"}
aria-label="${t("chat.selectors.model")}, ${t(
"chat.selectors.thinkingLevel",
)}: ${triggerTitle}"
@@ -629,6 +647,16 @@ function renderChatModelReasoningSelect(params: {
}
}}
>
${modelToolsUnavailable
? html`
<openclaw-tooltip .content=${t("chat.modelControls.chatOnlyHelp")}>
<span class="chat-controls__model-capability-badge" aria-hidden="true">
${icons.alertTriangle}
<span>${t("chat.modelControls.chatOnly")}</span>
</span>
</openclaw-tooltip>
`
: nothing}
<span class="chat-controls__inline-select-label">${triggerLabel}</span>
<span class="chat-controls__inline-select-icon" aria-hidden="true">
${icons.chevronDown}
+21
View File
@@ -4287,6 +4287,27 @@ openclaw-chat-video-player {
white-space: nowrap;
}
.chat-controls__model-capability-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 5px;
border: 1px solid color-mix(in srgb, var(--warn) 34%, var(--border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--warn) 10%, transparent);
color: color-mix(in srgb, var(--warn) 82%, var(--text));
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.chat-controls__model-capability-badge svg {
width: 12px;
height: 12px;
}
/* Keep the chevron pinned right when the mobile trigger stretches. */
.chat-controls__inline-select-icon {
display: inline-flex;
+2
View File
@@ -207,6 +207,8 @@ export type ControlUiMockGatewayScenario = {
name: string;
provider: string;
available?: boolean;
contextWindow?: number;
supportsTools?: boolean;
}>;
/** Operator scopes returned by the mocked connect handshake. */
operatorScopes?: string[];