Skip to content

Commit baadd74

Browse files
committed
fix(plugins): narrow optional tool cold loads
1 parent 07b52b4 commit baadd74

10 files changed

Lines changed: 303 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Docs: https://docs.openclaw.ai
7272
- Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc.
7373
- Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX.
7474
- Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001.
75-
- Plugins/tools: honor `tools.alsoAllow` as an optional plugin tool discovery hint without treating its internal allow-all default as permission to load every optional plugin tool. Fixes #76616.
75+
- Plugins/tools: honor `tools.alsoAllow` as an optional plugin tool discovery hint without treating its internal allow-all default as permission to load every manifest-marked optional plugin tool. Fixes #76616.
7676
- Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc.
7777
- CLI/plugins: reject unowned command roots such as `openclaw foo` before managed proxy startup and full plugin CLI runtime loading while preserving manifest-owned and CLI-metadata-owned plugin commands. Fixes #75287. Thanks @neilofneils404.
7878
- CLI/message: skip local configured-channel plugin preload for explicit gateway-owned message actions, letting normalized CLI delivery delegate to the gateway without initializing channel runtime in the short-lived CLI process. Fixes #75477.

docs/plugins/building-plugins.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ plugin manifest:
252252
{
253253
"contracts": {
254254
"tools": ["my_tool", "workflow_tool"]
255+
},
256+
"toolMetadata": {
257+
"workflow_tool": {
258+
"optional": true
259+
}
255260
}
256261
}
257262
```
@@ -260,6 +265,9 @@ OpenClaw captures and caches the validated descriptor from the registered tool,
260265
so plugins do not duplicate `description` or schema data in the manifest. The
261266
manifest contract only declares ownership and discovery; execution still calls
262267
the live registered tool implementation.
268+
Set `toolMetadata.<tool>.optional: true` for tools registered with
269+
`api.registerTool(..., { optional: true })` so OpenClaw can avoid loading that
270+
plugin runtime until the tool is explicitly allowlisted.
263271

264272
Users enable optional tools in config:
265273

extensions/diffs/openclaw.plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"contracts": {
99
"tools": ["diffs"]
1010
},
11+
"toolMetadata": {
12+
"diffs": {
13+
"optional": true
14+
}
15+
},
1116
"skills": ["./skills"],
1217
"uiHints": {
1318
"viewerBaseUrl": {

extensions/llm-task/openclaw.plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"contracts": {
99
"tools": ["llm-task"]
1010
},
11+
"toolMetadata": {
12+
"llm-task": {
13+
"optional": true
14+
}
15+
},
1116
"configSchema": {
1217
"type": "object",
1318
"additionalProperties": false,

extensions/lobster/openclaw.plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"contracts": {
99
"tools": ["lobster"]
1010
},
11+
"toolMetadata": {
12+
"lobster": {
13+
"optional": true
14+
}
15+
},
1116
"configSchema": {
1217
"type": "object",
1318
"additionalProperties": false,

src/plugins/manifest-registry.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,7 @@ describe("loadPluginManifestRegistry", () => {
13821382
},
13831383
toolMetadata: {
13841384
image_generate: {
1385+
optional: true,
13851386
authSignals: [
13861387
{
13871388
provider: "openai-codex",
@@ -1450,6 +1451,7 @@ describe("loadPluginManifestRegistry", () => {
14501451
});
14511452
expect(registry.plugins[0]?.toolMetadata).toEqual({
14521453
image_generate: {
1454+
optional: true,
14531455
authSignals: [
14541456
{
14551457
provider: "openai-codex",

src/plugins/manifest-tool-availability.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ function toolMetadataPasses(params: {
215215
env: NodeJS.ProcessEnv;
216216
hasAuthForProvider?: (providerId: string) => boolean;
217217
}): boolean {
218+
const authSignals = listToolAuthSignals(params.metadata);
219+
if (!params.metadata.configSignals?.length && authSignals.length === 0) {
220+
return true;
221+
}
218222
if (
219223
params.metadata.configSignals?.some((signal) =>
220224
manifestConfigSignalPasses({
@@ -226,7 +230,7 @@ function toolMetadataPasses(params: {
226230
) {
227231
return true;
228232
}
229-
for (const signal of listToolAuthSignals(params.metadata)) {
233+
for (const signal of authSignals) {
230234
if (
231235
!manifestProviderBaseUrlGuardPasses({
232236
config: params.config,

src/plugins/manifest.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,9 @@ export type PluginManifestCapabilityProviderMetadata = {
458458
configSignals?: PluginManifestCapabilityProviderConfigSignal[];
459459
};
460460

461-
export type PluginManifestToolMetadata = PluginManifestCapabilityProviderMetadata;
461+
export type PluginManifestToolMetadata = PluginManifestCapabilityProviderMetadata & {
462+
optional?: boolean;
463+
};
462464

463465
export type PluginManifestProviderAuthChoice = {
464466
/** Provider id owned by this manifest entry. */
@@ -715,6 +717,22 @@ function normalizeCapabilityProviderConfigSignals(
715717
return signals.length > 0 ? signals : undefined;
716718
}
717719

720+
function normalizeCapabilityProviderMetadataEntry(
721+
rawMetadata: Record<string, unknown>,
722+
): PluginManifestCapabilityProviderMetadata | undefined {
723+
const aliases = normalizeTrimmedStringList(rawMetadata.aliases);
724+
const authProviders = normalizeTrimmedStringList(rawMetadata.authProviders);
725+
const authSignals = normalizeCapabilityProviderAuthSignals(rawMetadata.authSignals);
726+
const configSignals = normalizeCapabilityProviderConfigSignals(rawMetadata.configSignals);
727+
const metadata = {
728+
...(aliases.length > 0 ? { aliases } : {}),
729+
...(authProviders.length > 0 ? { authProviders } : {}),
730+
...(authSignals ? { authSignals } : {}),
731+
...(configSignals ? { configSignals } : {}),
732+
} satisfies PluginManifestCapabilityProviderMetadata;
733+
return Object.keys(metadata).length > 0 ? metadata : undefined;
734+
}
735+
718736
function normalizeCapabilityProviderMetadata(
719737
value: unknown,
720738
): Record<string, PluginManifestCapabilityProviderMetadata> | undefined {
@@ -727,18 +745,33 @@ function normalizeCapabilityProviderMetadata(
727745
if (!providerId || isBlockedObjectKey(providerId) || !isRecord(rawMetadata)) {
728746
continue;
729747
}
730-
const aliases = normalizeTrimmedStringList(rawMetadata.aliases);
731-
const authProviders = normalizeTrimmedStringList(rawMetadata.authProviders);
732-
const authSignals = normalizeCapabilityProviderAuthSignals(rawMetadata.authSignals);
733-
const configSignals = normalizeCapabilityProviderConfigSignals(rawMetadata.configSignals);
748+
const metadata = normalizeCapabilityProviderMetadataEntry(rawMetadata);
749+
if (metadata) {
750+
normalized[providerId] = metadata;
751+
}
752+
}
753+
return Object.keys(normalized).length > 0 ? normalized : undefined;
754+
}
755+
756+
function normalizePluginToolMetadata(
757+
value: unknown,
758+
): Record<string, PluginManifestToolMetadata> | undefined {
759+
if (!isRecord(value)) {
760+
return undefined;
761+
}
762+
const normalized: Record<string, PluginManifestToolMetadata> = Object.create(null);
763+
for (const [rawToolName, rawMetadata] of Object.entries(value)) {
764+
const toolName = normalizeOptionalString(rawToolName) ?? "";
765+
if (!toolName || isBlockedObjectKey(toolName) || !isRecord(rawMetadata)) {
766+
continue;
767+
}
768+
const providerMetadata = normalizeCapabilityProviderMetadataEntry(rawMetadata);
734769
const metadata = {
735-
...(aliases.length > 0 ? { aliases } : {}),
736-
...(authProviders.length > 0 ? { authProviders } : {}),
737-
...(authSignals ? { authSignals } : {}),
738-
...(configSignals ? { configSignals } : {}),
739-
} satisfies PluginManifestCapabilityProviderMetadata;
770+
...providerMetadata,
771+
...(rawMetadata.optional === true ? { optional: true } : {}),
772+
} satisfies PluginManifestToolMetadata;
740773
if (Object.keys(metadata).length > 0) {
741-
normalized[providerId] = metadata;
774+
normalized[toolName] = metadata;
742775
}
743776
}
744777
return Object.keys(normalized).length > 0 ? normalized : undefined;
@@ -1596,7 +1629,7 @@ export function loadPluginManifest(
15961629
const musicGenerationProviderMetadata = normalizeCapabilityProviderMetadata(
15971630
raw.musicGenerationProviderMetadata,
15981631
);
1599-
const toolMetadata = normalizeCapabilityProviderMetadata(raw.toolMetadata);
1632+
const toolMetadata = normalizePluginToolMetadata(raw.toolMetadata);
16001633
const configContracts = normalizeManifestConfigContracts(raw.configContracts);
16011634
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
16021635

0 commit comments

Comments
 (0)