Skip to content

Commit 9502f74

Browse files
committed
fix(agents): preserve runtime tools in lean mode
1 parent 75e0053 commit 9502f74

5 files changed

Lines changed: 199 additions & 4 deletions

File tree

src/agents/agent-tools.create-openclaw-coding-tools.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,81 @@ describe("createOpenClawCodingTools", () => {
371371
expect(toolNameList(tools)).toContain("message");
372372
});
373373

374+
it("preserves runtime-allowed message through local model lean filtering", () => {
375+
const tools = createOpenClawCodingTools({
376+
config: {
377+
agents: {
378+
defaults: {
379+
experimental: {
380+
localModelLean: true,
381+
},
382+
},
383+
},
384+
tools: { profile: "minimal" },
385+
},
386+
runtimeToolAllowlist: ["message"],
387+
toolConstructionPlan: {
388+
includeBaseCodingTools: false,
389+
includeShellTools: false,
390+
includeChannelTools: false,
391+
includeOpenClawTools: true,
392+
includePluginTools: false,
393+
},
394+
});
395+
396+
expect(toolNameList(tools)).toContain("message");
397+
});
398+
399+
it("preserves forced message through local model lean filtering without runtime allowlist", () => {
400+
const tools = createOpenClawCodingTools({
401+
config: {
402+
agents: {
403+
defaults: {
404+
experimental: {
405+
localModelLean: true,
406+
},
407+
},
408+
},
409+
tools: { profile: "minimal" },
410+
},
411+
forceMessageTool: true,
412+
toolConstructionPlan: {
413+
includeBaseCodingTools: false,
414+
includeShellTools: false,
415+
includeChannelTools: false,
416+
includeOpenClawTools: true,
417+
includePluginTools: false,
418+
},
419+
});
420+
421+
expect(toolNameList(tools)).toContain("message");
422+
});
423+
424+
it("preserves message-tool-only replies through local model lean filtering without runtime allowlist", () => {
425+
const tools = createOpenClawCodingTools({
426+
config: {
427+
agents: {
428+
defaults: {
429+
experimental: {
430+
localModelLean: true,
431+
},
432+
},
433+
},
434+
tools: { profile: "minimal" },
435+
},
436+
sourceReplyDeliveryMode: "message_tool_only",
437+
toolConstructionPlan: {
438+
includeBaseCodingTools: false,
439+
includeShellTools: false,
440+
includeChannelTools: false,
441+
includeOpenClawTools: true,
442+
includePluginTools: false,
443+
},
444+
});
445+
446+
expect(toolNameList(tools)).toContain("message");
447+
});
448+
374449
it("preserves runtime allowlist groups containing message through restrictive profiles", () => {
375450
for (const runtimeToolAllowlist of [["group:messaging"], ["group:openclaw"], ["*"]]) {
376451
const tools = createOpenClawCodingTools({

src/agents/agent-tools.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ import { execSchema, processSchema } from "./bash-tools.schemas.js";
6363
import { listChannelAgentTools } from "./channel-tools.js";
6464
import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js";
6565
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
66-
import { filterLocalModelLeanTools } from "./local-model-lean.js";
66+
import {
67+
filterLocalModelLeanTools,
68+
resolveLocalModelLeanPreserveToolNames,
69+
} from "./local-model-lean.js";
6770
import type { ModelAuthMode } from "./model-auth.js";
6871
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
6972
import { createOpenClawTools } from "./openclaw-tools.js";
@@ -245,6 +248,8 @@ function applyModelProviderToolPolicy(
245248
agentDir?: string;
246249
modelCompat?: ModelCompatConfig;
247250
suppressManagedWebSearch?: boolean;
251+
runtimeToolAllowlist?: string[];
252+
localModelLeanPreserveToolNames?: string[];
248253
},
249254
): AnyAgentTool[] {
250255
let tools = toolsInput;
@@ -253,6 +258,7 @@ function applyModelProviderToolPolicy(
253258
config: params?.config,
254259
agentId: params?.agentId,
255260
sessionKey: params?.sessionKey,
261+
preserveToolNames: params?.localModelLeanPreserveToolNames ?? params?.runtimeToolAllowlist,
256262
});
257263

258264
if (
@@ -593,6 +599,11 @@ export function createOpenClawCodingTools(options?: {
593599
const normalized = normalizeToolName(toolName);
594600
return normalized === "*" || normalized === "message";
595601
});
602+
const localModelLeanPreserveToolNames = resolveLocalModelLeanPreserveToolNames({
603+
toolNames: options?.runtimeToolAllowlist,
604+
forceMessageTool: options?.forceMessageTool,
605+
sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode,
606+
});
596607
const runtimeProfileAlsoAllow = [
597608
...(options?.forceMessageTool || options?.sourceReplyDeliveryMode === "message_tool_only"
598609
? ["message"]
@@ -1064,6 +1075,8 @@ export function createOpenClawCodingTools(options?: {
10641075
agentDir: options?.agentDir,
10651076
modelCompat: options?.modelCompat,
10661077
suppressManagedWebSearch: options?.suppressManagedWebSearch,
1078+
runtimeToolAllowlist: options?.runtimeToolAllowlist,
1079+
localModelLeanPreserveToolNames,
10671080
});
10681081
options?.recordToolPrepStage?.("model-provider-policy");
10691082
// Sender identity is carried for command/channel-action auth; tool visibility

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,11 @@ import { isTimeoutError } from "../../failover-error.js";
150150
import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js";
151151
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
152152
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
153-
import { filterLocalModelLeanTools, isLocalModelLeanEnabled } from "../../local-model-lean.js";
153+
import {
154+
filterLocalModelLeanTools,
155+
isLocalModelLeanEnabled,
156+
resolveLocalModelLeanPreserveToolNames,
157+
} from "../../local-model-lean.js";
154158
import { resolveModelAuthMode } from "../../model-auth.js";
155159
import { resolveDefaultModelForAgent } from "../../model-selection.js";
156160
import { supportsModelTools } from "../../model-tool-support.js";
@@ -1122,6 +1126,11 @@ export async function runEmbeddedAttempt(
11221126
]),
11231127
]
11241128
: toolsAllowWithForcedRuntimeTools;
1129+
const localModelLeanPreserveToolNames = resolveLocalModelLeanPreserveToolNames({
1130+
toolNames: effectiveToolsAllow,
1131+
forceMessageTool: params.forceMessageTool,
1132+
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
1133+
});
11251134
const shouldConstructTools =
11261135
toolConstructionPlan.constructTools ||
11271136
toolSearchControlsEnabledForRun ||
@@ -1215,6 +1224,7 @@ export async function runEmbeddedAttempt(
12151224
forceMessageTool: params.forceMessageTool,
12161225
enableHeartbeatTool: params.enableHeartbeatTool,
12171226
forceHeartbeatTool: params.forceHeartbeatTool,
1227+
runtimeToolAllowlist: effectiveToolsAllow,
12181228
authProfileStore: params.authProfileStore,
12191229
recordToolPrepStage: (name) => corePluginToolStages.mark(name),
12201230
onToolOutcome: params.onToolOutcome,
@@ -1482,6 +1492,7 @@ export async function runEmbeddedAttempt(
14821492
tools: [...tools, ...normalizedBundledTools],
14831493
config: params.config,
14841494
agentId: sessionAgentId,
1495+
preserveToolNames: localModelLeanPreserveToolNames,
14851496
});
14861497
const uncompactedToolSchemaProjection = filterRuntimeCompatibleTools(
14871498
projectedUncompactedEffectiveTools,
@@ -1553,6 +1564,7 @@ export async function runEmbeddedAttempt(
15531564
tools: toolSearch.tools,
15541565
config: params.config,
15551566
agentId: sessionAgentId,
1567+
preserveToolNames: localModelLeanPreserveToolNames,
15561568
});
15571569
const toolSearchSchemaProjection = filterRuntimeCompatibleTools(projectedToolSearchTools);
15581570
logRuntimeToolSchemaQuarantine({

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
33
import type { AnyAgentTool } from "./agent-tools.types.js";
4-
import { filterLocalModelLeanTools, isLocalModelLeanEnabled } from "./local-model-lean.js";
4+
import {
5+
filterLocalModelLeanTools,
6+
isLocalModelLeanEnabled,
7+
resolveLocalModelLeanPreserveToolNames,
8+
} from "./local-model-lean.js";
59

610
function tools(names: string[]): AnyAgentTool[] {
711
return names.map((name) => ({ name })) as AnyAgentTool[];
@@ -32,6 +36,65 @@ describe("local model lean tool filtering", () => {
3236
).toEqual(["read", "exec"]);
3337
});
3438

39+
it("keeps explicitly preserved tools when lean mode is enabled", () => {
40+
const cfg: OpenClawConfig = {
41+
agents: {
42+
defaults: {
43+
experimental: {
44+
localModelLean: true,
45+
},
46+
},
47+
},
48+
};
49+
50+
expect(
51+
filterLocalModelLeanTools({
52+
tools: tools(["read", "browser", "cron", "message", "exec"]),
53+
config: cfg,
54+
preserveToolNames: ["browser", "cron", "group:messaging"],
55+
}).map((tool) => tool.name),
56+
).toEqual(["read", "browser", "cron", "message", "exec"]);
57+
});
58+
59+
it("adds reply-required message tools to lean preservation", () => {
60+
expect(
61+
resolveLocalModelLeanPreserveToolNames({
62+
forceMessageTool: true,
63+
}),
64+
).toEqual(["message"]);
65+
expect(
66+
resolveLocalModelLeanPreserveToolNames({
67+
sourceReplyDeliveryMode: "message_tool_only",
68+
}),
69+
).toEqual(["message"]);
70+
expect(
71+
resolveLocalModelLeanPreserveToolNames({
72+
toolNames: ["group:messaging"],
73+
forceMessageTool: true,
74+
}),
75+
).toEqual(["group:messaging", "message"]);
76+
});
77+
78+
it("does not treat wildcard preservation as disabling lean mode", () => {
79+
const cfg: OpenClawConfig = {
80+
agents: {
81+
defaults: {
82+
experimental: {
83+
localModelLean: true,
84+
},
85+
},
86+
},
87+
};
88+
89+
expect(
90+
filterLocalModelLeanTools({
91+
tools: tools(["read", "browser", "cron", "message", "exec"]),
92+
config: cfg,
93+
preserveToolNames: ["*"],
94+
}).map((tool) => tool.name),
95+
).toEqual(["read", "exec"]);
96+
});
97+
3598
it("lets an agent opt out of an inherited global lean setting", () => {
3699
const cfg: OpenClawConfig = {
37100
agents: {

src/agents/local-model-lean.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,33 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
22
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
33
import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope-config.js";
44
import type { AnyAgentTool } from "./agent-tools.types.js";
5+
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
56

67
const LOCAL_MODEL_LEAN_DENY_TOOL_NAMES = new Set(["browser", "cron", "message"]);
78

9+
function resolvePreservedLocalModelLeanToolNames(names?: Iterable<string>): Set<string> {
10+
if (!names) {
11+
return new Set();
12+
}
13+
return new Set(
14+
expandToolGroups([...names])
15+
.map(normalizeToolName)
16+
.filter((name) => name && name !== "*"),
17+
);
18+
}
19+
20+
export function resolveLocalModelLeanPreserveToolNames(params?: {
21+
toolNames?: Iterable<string>;
22+
forceMessageTool?: boolean;
23+
sourceReplyDeliveryMode?: string;
24+
}): string[] {
25+
const names = [...(params?.toolNames ?? [])];
26+
if (params?.forceMessageTool || params?.sourceReplyDeliveryMode === "message_tool_only") {
27+
names.push("message");
28+
}
29+
return [...new Set(names)];
30+
}
31+
832
function resolveLocalModelLeanAgentId(params: {
933
config?: OpenClawConfig;
1034
agentId?: string;
@@ -43,9 +67,17 @@ export function filterLocalModelLeanTools(params: {
4367
config?: OpenClawConfig;
4468
agentId?: string;
4569
sessionKey?: string;
70+
preserveToolNames?: Iterable<string>;
4671
}): AnyAgentTool[] {
4772
if (!isLocalModelLeanEnabled(params)) {
4873
return params.tools;
4974
}
50-
return params.tools.filter((tool) => !LOCAL_MODEL_LEAN_DENY_TOOL_NAMES.has(tool.name));
75+
const preservedToolNames = resolvePreservedLocalModelLeanToolNames(params.preserveToolNames);
76+
return params.tools.filter((tool) => {
77+
const normalizedName = normalizeToolName(tool.name);
78+
return (
79+
preservedToolNames.has(normalizedName) ||
80+
!LOCAL_MODEL_LEAN_DENY_TOOL_NAMES.has(normalizedName)
81+
);
82+
});
5183
}

0 commit comments

Comments
 (0)