Skip to content

Commit 7c0a7c8

Browse files
steipetevincentkoc
andauthored
fix(agents): keep exec visible for lean local models (#101607)
Co-authored-by: Vincent Koc <[email protected]>
1 parent bd7da9d commit 7c0a7c8

9 files changed

Lines changed: 103 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **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.
2627
- **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.
2728
- **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.
2829
- **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.

docs/concepts/experimental-features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Experimental features are opt-in preview surfaces behind explicit flags. They ne
2929

3030
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.
3131

32+
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.
33+
3234
### Why these tools
3335

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

docs/gateway/local-models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ If the model loads cleanly but full agent turns misbehave, work top-down: confir
263263
openclaw infer model run --gateway --model <provider/model> --prompt "Reply with exactly: pong" --json
264264
```
265265

266-
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.
266+
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.
267267

268268
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.
269269

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ import {
186186
filterLocalModelLeanTools,
187187
isLocalModelLeanEnabled,
188188
resolveLocalModelLeanPreserveToolNames,
189+
shouldCatalogToolForLocalModelLean,
189190
} from "../../local-model-lean.js";
190191
import { resolveModelAuthMode } from "../../model-auth.js";
191192
import { resolveDefaultModelForAgent } from "../../model-selection.js";
@@ -1319,6 +1320,11 @@ export async function runEmbeddedAttempt(
13191320
sandboxToolPolicy: sandbox?.tools,
13201321
runtimeToolAllowlist: effectiveToolsAllow,
13211322
});
1323+
const localModelLeanEnabled = isLocalModelLeanEnabled({
1324+
config: params.config,
1325+
agentId: sessionAgentId,
1326+
sessionKey: params.sessionKey,
1327+
});
13221328
const localModelLeanPreserveToolNames = resolveLocalModelLeanPreserveToolNames({
13231329
toolNames: runtimeCapabilityProfile.policy.explicitToolOverrideAllowlist,
13241330
forceMessageTool: params.forceMessageTool,
@@ -1813,6 +1819,10 @@ export async function runEmbeddedAttempt(
18131819
runId: params.runId,
18141820
catalogRef: toolSearchCatalogRef,
18151821
toolHookContext: catalogToolHookContext,
1822+
shouldCatalogTool:
1823+
localModelLeanEnabled && toolSearchConfig.mode === "tools"
1824+
? shouldCatalogToolForLocalModelLean
1825+
: undefined,
18161826
});
18171827
const projectedToolSearchTools = filterLocalModelLeanTools({
18181828
tools: toolSearch.tools,
@@ -2841,10 +2851,7 @@ export async function runEmbeddedAttempt(
28412851
agentId: sessionAgentId,
28422852
messageProvider: params.messageProvider,
28432853
messageChannel: params.messageChannel,
2844-
localModelLean: isLocalModelLeanEnabled({
2845-
config: params.config,
2846-
agentId: sessionAgentId,
2847-
}),
2854+
localModelLean: localModelLeanEnabled,
28482855
toolCount: effectiveTools.length,
28492856
clientToolCount: clientToolDefs.length,
28502857
});

src/agents/harness/tool-surface-bridge.test.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
import { describe, expect, it } from "vitest";
22
import type { OpenClawConfig } from "../../config/types.openclaw.js";
33
import { createStubTool } from "../test-helpers/agent-tool-stubs.js";
4+
import {
5+
testing,
6+
TOOL_CALL_RAW_TOOL_NAME,
7+
TOOL_DESCRIBE_RAW_TOOL_NAME,
8+
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
9+
TOOL_SEARCH_RAW_TOOL_NAME,
10+
} from "../tool-search.js";
411
import { createAgentHarnessToolSurfaceRuntime } from "./tool-surface-bridge.js";
512

613
function tools(names: string[]) {
714
return names.map(createStubTool);
815
}
916

17+
function createRuntime(config: OpenClawConfig) {
18+
return createAgentHarnessToolSurfaceRuntime({
19+
config,
20+
executeTool: async () => ({ content: [], details: {} }),
21+
modelToolsEnabled: true,
22+
});
23+
}
24+
1025
describe("createAgentHarnessToolSurfaceRuntime", () => {
1126
it("filters raw SDK tools but does not refilter prepared constructor output", () => {
1227
const config: OpenClawConfig = {
1328
agents: { defaults: { experimental: { localModelLean: true } } },
1429
tools: { alsoAllow: ["image_generate"], toolSearch: { enabled: false } },
1530
};
16-
const runtime = createAgentHarnessToolSurfaceRuntime({
17-
config,
18-
executeTool: async () => ({ content: [], details: {} }),
19-
modelToolsEnabled: true,
20-
});
31+
const runtime = createRuntime(config);
2132

2233
expect(
2334
runtime
@@ -31,4 +42,51 @@ describe("createAgentHarnessToolSurfaceRuntime", () => {
3142
).toEqual(["read", "browser"]);
3243
runtime.cleanup();
3344
});
45+
46+
it("keeps exec direct in lean structured Tool Search mode", () => {
47+
const config: OpenClawConfig = {
48+
agents: { defaults: { experimental: { localModelLean: true } } },
49+
};
50+
const runtime = createRuntime(config);
51+
52+
expect(
53+
runtime
54+
.compactTools(
55+
tools([
56+
TOOL_SEARCH_RAW_TOOL_NAME,
57+
TOOL_DESCRIBE_RAW_TOOL_NAME,
58+
TOOL_CALL_RAW_TOOL_NAME,
59+
"exec",
60+
"read",
61+
]),
62+
)
63+
.tools.map((tool) => tool.name),
64+
).toEqual([
65+
TOOL_SEARCH_RAW_TOOL_NAME,
66+
TOOL_DESCRIBE_RAW_TOOL_NAME,
67+
TOOL_CALL_RAW_TOOL_NAME,
68+
"exec",
69+
]);
70+
runtime.cleanup();
71+
});
72+
73+
it("preserves explicit code-mode compaction for lean runs", () => {
74+
testing.setToolSearchCodeModeSupportedForTest(true);
75+
try {
76+
const config: OpenClawConfig = {
77+
agents: { defaults: { experimental: { localModelLean: true } } },
78+
tools: { toolSearch: { mode: "code" } },
79+
};
80+
const runtime = createRuntime(config);
81+
82+
expect(
83+
runtime
84+
.compactTools(tools([TOOL_SEARCH_CODE_MODE_TOOL_NAME, "exec", "read"]))
85+
.tools.map((tool) => tool.name),
86+
).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]);
87+
runtime.cleanup();
88+
} finally {
89+
testing.setToolSearchCodeModeSupportedForTest(undefined);
90+
}
91+
});
3492
});

src/agents/harness/tool-surface-bridge.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { resolveConversationCapabilityProfile } from "../conversation-capability
1111
import {
1212
applyLocalModelLeanToolSearchDefaults,
1313
filterLocalModelLeanTools,
14+
isLocalModelLeanEnabled,
1415
resolveLocalModelLeanPreserveToolNames,
16+
shouldCatalogToolForLocalModelLean,
1517
} from "../local-model-lean.js";
1618
import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js";
1719
import {
@@ -76,6 +78,11 @@ export function createAgentHarnessToolSurfaceRuntime(params: {
7678
}): AgentHarnessToolSurfaceRuntime {
7779
const forceDirectMessageTool =
7880
params.forceMessageTool === true || params.sourceReplyDeliveryMode === "message_tool_only";
81+
const localModelLeanEnabled = isLocalModelLeanEnabled({
82+
config: params.config,
83+
agentId: params.agentId,
84+
sessionKey: params.sessionKey,
85+
});
7986
const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId);
8087
const toolSearchRuntimeConfig = forceDirectMessageTool
8188
? params.config
@@ -200,6 +207,10 @@ export function createAgentHarnessToolSurfaceRuntime(params: {
200207
runId: params.runId,
201208
catalogRef: toolSearchCatalogRef,
202209
toolHookContext: options.hookContext,
210+
shouldCatalogTool:
211+
localModelLeanEnabled && toolSearchConfig.mode === "tools"
212+
? shouldCatalogToolForLocalModelLean
213+
: undefined,
203214
});
204215
const projectedCompactedTools = options.localModelLeanApplied
205216
? compacted.tools

src/agents/local-model-lean.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
filterLocalModelLeanTools,
1111
isLocalModelLeanEnabled,
1212
resolveLocalModelLeanPreserveToolNames,
13+
shouldCatalogToolForLocalModelLean,
1314
} from "./local-model-lean.js";
1415

1516
function tools(names: string[]): AnyAgentTool[] {
@@ -335,4 +336,9 @@ describe("local model lean tool filtering", () => {
335336

336337
expect(applyLocalModelLeanToolSearchDefaults({ config: cfg, agentId: "main" })).toBe(cfg);
337338
});
339+
340+
it("keeps exec outside the lean Tool Search catalog", () => {
341+
expect(shouldCatalogToolForLocalModelLean({ name: "exec" } as AnyAgentTool)).toBe(false);
342+
expect(shouldCatalogToolForLocalModelLean({ name: "read" } as AnyAgentTool)).toBe(true);
343+
});
338344
});

src/agents/local-model-lean.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const LOCAL_MODEL_LEAN_DENY_TOOL_NAMES = new Set([
2020
"tts",
2121
"video_generate",
2222
]);
23+
const LOCAL_MODEL_LEAN_DIRECT_TOOL_NAMES = new Set(["exec"]);
2324
const LOCAL_MODEL_LEAN_TOOL_SEARCH_DEFAULTS = {
2425
enabled: true,
2526
mode: "tools",
@@ -107,6 +108,12 @@ export function filterLocalModelLeanTools(params: {
107108
});
108109
}
109110

111+
// Lean mode targets coding-tuned local models; keep their familiar shell
112+
// primitive visible instead of requiring a catalog search to rediscover it.
113+
export function shouldCatalogToolForLocalModelLean(tool: AnyAgentTool): boolean {
114+
return !LOCAL_MODEL_LEAN_DIRECT_TOOL_NAMES.has(normalizeToolName(tool.name));
115+
}
116+
110117
export function applyLocalModelLeanToolSearchDefaults(params: {
111118
config?: OpenClawConfig;
112119
agentId?: string;

src/agents/tool-search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,7 @@ export function applyToolSearchCatalog(params: {
897897
runId?: string;
898898
catalogRef?: ToolSearchCatalogRef;
899899
toolHookContext?: HookContext;
900+
shouldCatalogTool?: (tool: AnyAgentTool) => boolean;
900901
}): {
901902
tools: AnyAgentTool[];
902903
compacted: boolean;

0 commit comments

Comments
 (0)