From 7c0a7c8be210c7d95b0b4e6b4cf0126e5c2e7e44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 12:46:26 +0100 Subject: [PATCH] fix(agents): keep exec visible for lean local models (#101607) Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + docs/concepts/experimental-features.md | 2 + docs/gateway/local-models.md | 2 +- .../embedded-agent-runner/run/attempt.ts | 15 ++-- .../harness/tool-surface-bridge.test.ts | 68 +++++++++++++++++-- src/agents/harness/tool-surface-bridge.ts | 11 +++ src/agents/local-model-lean.test.ts | 6 ++ src/agents/local-model-lean.ts | 7 ++ src/agents/tool-search.ts | 1 + 9 files changed, 103 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02dfa4ecd8a5..979397c2a3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc. - **OAuth refresh contention diagnostics:** keep local lock paths out of user-facing refresh failures and avoid duplicate failure prefixes while preserving structured provider and profile classification. (#83383) Thanks @vincentkoc. - **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc. - **Direct poll delivery:** route direct and hybrid channel polls through the owning outbound adapter while preserving gateway-mode routing and channel option checks. (#99950) Thanks @NianJiuZst. diff --git a/docs/concepts/experimental-features.md b/docs/concepts/experimental-features.md index 629d4e4d5e53..9eb757805027 100644 --- a/docs/concepts/experimental-features.md +++ b/docs/concepts/experimental-features.md @@ -29,6 +29,8 @@ Experimental features are opt-in preview surfaces behind explicit flags. They ne If you already tune Tool Search globally, OpenClaw leaves that config alone. Set `tools.toolSearch: false` to opt out of the lean-mode Tool Search default. +In structured `tools` mode, lean runs keep `exec` directly visible beside the Tool Search controls so coding-tuned local models can still choose their familiar shell path. This changes schema visibility only: normal tool policy, sandboxing, and exec approvals still apply. Explicit `code` and `directory` modes keep their normal compaction behavior. + ### Why these tools These tools have the largest descriptions, broadest parameter shapes, or highest chance of distracting a small model from the normal coding and conversation path. On a small-context or stricter OpenAI-compatible backend that is the difference between: diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index f1efce0cc876..d6c0b7a7792b 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -263,7 +263,7 @@ If the model loads cleanly but full agent turns misbehave, work top-down: confir openclaw infer model run --gateway --model --prompt "Reply with exactly: pong" --json ``` -3. **Try lean mode** if both probes pass but real agent turns fail with malformed tool calls or oversized prompts: set `agents.defaults.experimental.localModelLean: true`. It drops heavyweight browser, cron, message, media-generation, voice, and PDF tools unless explicitly required, and defaults larger tool catalogs behind structured Tool Search controls. See [Experimental Features -> Local model lean mode](/concepts/experimental-features#local-model-lean-mode) for details and how to confirm it's on. +3. **Try lean mode** if both probes pass but real agent turns fail with malformed tool calls or oversized prompts: set `agents.defaults.experimental.localModelLean: true`. It drops heavyweight browser, cron, message, media-generation, voice, and PDF tools unless explicitly required, and defaults larger tool catalogs behind structured Tool Search controls while keeping `exec` directly visible. See [Experimental Features -> Local model lean mode](/concepts/experimental-features#local-model-lean-mode) for details and how to confirm it's on. 4. **Disable tools entirely as a last resort** by setting `models.providers..models[].compat.supportsTools: false` for that model - the agent then runs without tool calls. diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1b1ef82f1100..5ebcf50aef78 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -186,6 +186,7 @@ import { filterLocalModelLeanTools, isLocalModelLeanEnabled, resolveLocalModelLeanPreserveToolNames, + shouldCatalogToolForLocalModelLean, } from "../../local-model-lean.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; @@ -1319,6 +1320,11 @@ export async function runEmbeddedAttempt( sandboxToolPolicy: sandbox?.tools, runtimeToolAllowlist: effectiveToolsAllow, }); + const localModelLeanEnabled = isLocalModelLeanEnabled({ + config: params.config, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + }); const localModelLeanPreserveToolNames = resolveLocalModelLeanPreserveToolNames({ toolNames: runtimeCapabilityProfile.policy.explicitToolOverrideAllowlist, forceMessageTool: params.forceMessageTool, @@ -1813,6 +1819,10 @@ export async function runEmbeddedAttempt( runId: params.runId, catalogRef: toolSearchCatalogRef, toolHookContext: catalogToolHookContext, + shouldCatalogTool: + localModelLeanEnabled && toolSearchConfig.mode === "tools" + ? shouldCatalogToolForLocalModelLean + : undefined, }); const projectedToolSearchTools = filterLocalModelLeanTools({ tools: toolSearch.tools, @@ -2841,10 +2851,7 @@ export async function runEmbeddedAttempt( agentId: sessionAgentId, messageProvider: params.messageProvider, messageChannel: params.messageChannel, - localModelLean: isLocalModelLeanEnabled({ - config: params.config, - agentId: sessionAgentId, - }), + localModelLean: localModelLeanEnabled, toolCount: effectiveTools.length, clientToolCount: clientToolDefs.length, }); diff --git a/src/agents/harness/tool-surface-bridge.test.ts b/src/agents/harness/tool-surface-bridge.test.ts index 4cf1205d68dd..1d45cee61a23 100644 --- a/src/agents/harness/tool-surface-bridge.test.ts +++ b/src/agents/harness/tool-surface-bridge.test.ts @@ -1,23 +1,34 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createStubTool } from "../test-helpers/agent-tool-stubs.js"; +import { + testing, + TOOL_CALL_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_SEARCH_CODE_MODE_TOOL_NAME, + TOOL_SEARCH_RAW_TOOL_NAME, +} from "../tool-search.js"; import { createAgentHarnessToolSurfaceRuntime } from "./tool-surface-bridge.js"; function tools(names: string[]) { return names.map(createStubTool); } +function createRuntime(config: OpenClawConfig) { + return createAgentHarnessToolSurfaceRuntime({ + config, + executeTool: async () => ({ content: [], details: {} }), + modelToolsEnabled: true, + }); +} + describe("createAgentHarnessToolSurfaceRuntime", () => { it("filters raw SDK tools but does not refilter prepared constructor output", () => { const config: OpenClawConfig = { agents: { defaults: { experimental: { localModelLean: true } } }, tools: { alsoAllow: ["image_generate"], toolSearch: { enabled: false } }, }; - const runtime = createAgentHarnessToolSurfaceRuntime({ - config, - executeTool: async () => ({ content: [], details: {} }), - modelToolsEnabled: true, - }); + const runtime = createRuntime(config); expect( runtime @@ -31,4 +42,51 @@ describe("createAgentHarnessToolSurfaceRuntime", () => { ).toEqual(["read", "browser"]); runtime.cleanup(); }); + + it("keeps exec direct in lean structured Tool Search mode", () => { + const config: OpenClawConfig = { + agents: { defaults: { experimental: { localModelLean: true } } }, + }; + const runtime = createRuntime(config); + + expect( + runtime + .compactTools( + tools([ + TOOL_SEARCH_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_CALL_RAW_TOOL_NAME, + "exec", + "read", + ]), + ) + .tools.map((tool) => tool.name), + ).toEqual([ + TOOL_SEARCH_RAW_TOOL_NAME, + TOOL_DESCRIBE_RAW_TOOL_NAME, + TOOL_CALL_RAW_TOOL_NAME, + "exec", + ]); + runtime.cleanup(); + }); + + it("preserves explicit code-mode compaction for lean runs", () => { + testing.setToolSearchCodeModeSupportedForTest(true); + try { + const config: OpenClawConfig = { + agents: { defaults: { experimental: { localModelLean: true } } }, + tools: { toolSearch: { mode: "code" } }, + }; + const runtime = createRuntime(config); + + expect( + runtime + .compactTools(tools([TOOL_SEARCH_CODE_MODE_TOOL_NAME, "exec", "read"])) + .tools.map((tool) => tool.name), + ).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]); + runtime.cleanup(); + } finally { + testing.setToolSearchCodeModeSupportedForTest(undefined); + } + }); }); diff --git a/src/agents/harness/tool-surface-bridge.ts b/src/agents/harness/tool-surface-bridge.ts index 5c021adab899..22cfc545db18 100644 --- a/src/agents/harness/tool-surface-bridge.ts +++ b/src/agents/harness/tool-surface-bridge.ts @@ -11,7 +11,9 @@ import { resolveConversationCapabilityProfile } from "../conversation-capability import { applyLocalModelLeanToolSearchDefaults, filterLocalModelLeanTools, + isLocalModelLeanEnabled, resolveLocalModelLeanPreserveToolNames, + shouldCatalogToolForLocalModelLean, } from "../local-model-lean.js"; import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js"; import { @@ -76,6 +78,11 @@ export function createAgentHarnessToolSurfaceRuntime(params: { }): AgentHarnessToolSurfaceRuntime { const forceDirectMessageTool = params.forceMessageTool === true || params.sourceReplyDeliveryMode === "message_tool_only"; + const localModelLeanEnabled = isLocalModelLeanEnabled({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId); const toolSearchRuntimeConfig = forceDirectMessageTool ? params.config @@ -200,6 +207,10 @@ export function createAgentHarnessToolSurfaceRuntime(params: { runId: params.runId, catalogRef: toolSearchCatalogRef, toolHookContext: options.hookContext, + shouldCatalogTool: + localModelLeanEnabled && toolSearchConfig.mode === "tools" + ? shouldCatalogToolForLocalModelLean + : undefined, }); const projectedCompactedTools = options.localModelLeanApplied ? compacted.tools diff --git a/src/agents/local-model-lean.test.ts b/src/agents/local-model-lean.test.ts index 4bce62511f1d..6b2df684ea08 100644 --- a/src/agents/local-model-lean.test.ts +++ b/src/agents/local-model-lean.test.ts @@ -10,6 +10,7 @@ import { filterLocalModelLeanTools, isLocalModelLeanEnabled, resolveLocalModelLeanPreserveToolNames, + shouldCatalogToolForLocalModelLean, } from "./local-model-lean.js"; function tools(names: string[]): AnyAgentTool[] { @@ -335,4 +336,9 @@ describe("local model lean tool filtering", () => { expect(applyLocalModelLeanToolSearchDefaults({ config: cfg, agentId: "main" })).toBe(cfg); }); + + it("keeps exec outside the lean Tool Search catalog", () => { + expect(shouldCatalogToolForLocalModelLean({ name: "exec" } as AnyAgentTool)).toBe(false); + expect(shouldCatalogToolForLocalModelLean({ name: "read" } as AnyAgentTool)).toBe(true); + }); }); diff --git a/src/agents/local-model-lean.ts b/src/agents/local-model-lean.ts index ea4cf230e293..c0e8c6290f3a 100644 --- a/src/agents/local-model-lean.ts +++ b/src/agents/local-model-lean.ts @@ -20,6 +20,7 @@ const LOCAL_MODEL_LEAN_DENY_TOOL_NAMES = new Set([ "tts", "video_generate", ]); +const LOCAL_MODEL_LEAN_DIRECT_TOOL_NAMES = new Set(["exec"]); const LOCAL_MODEL_LEAN_TOOL_SEARCH_DEFAULTS = { enabled: true, mode: "tools", @@ -107,6 +108,12 @@ export function filterLocalModelLeanTools(params: { }); } +// Lean mode targets coding-tuned local models; keep their familiar shell +// primitive visible instead of requiring a catalog search to rediscover it. +export function shouldCatalogToolForLocalModelLean(tool: AnyAgentTool): boolean { + return !LOCAL_MODEL_LEAN_DIRECT_TOOL_NAMES.has(normalizeToolName(tool.name)); +} + export function applyLocalModelLeanToolSearchDefaults(params: { config?: OpenClawConfig; agentId?: string; diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 91fbc32b4603..eddd90955381 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -897,6 +897,7 @@ export function applyToolSearchCatalog(params: { runId?: string; catalogRef?: ToolSearchCatalogRef; toolHookContext?: HookContext; + shouldCatalogTool?: (tool: AnyAgentTool) => boolean; }): { tools: AnyAgentTool[]; compacted: boolean;