fix(agents): keep exec visible for lean local models (#101607)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-07-07 12:46:26 +01:00
committed by GitHub
parent bd7da9decd
commit 7c0a7c8be2
9 changed files with 103 additions and 10 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -263,7 +263,7 @@ If the model loads cleanly but full agent turns misbehave, work top-down: confir
openclaw infer model run --gateway --model <provider/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.<provider>.models[].compat.supportsTools: false` for that model - the agent then runs without tool calls.

View File

@@ -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,
});

View File

@@ -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);
}
});
});

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -897,6 +897,7 @@ export function applyToolSearchCatalog(params: {
runId?: string;
catalogRef?: ToolSearchCatalogRef;
toolHookContext?: HookContext;
shouldCatalogTool?: (tool: AnyAgentTool) => boolean;
}): {
tools: AnyAgentTool[];
compacted: boolean;