diff --git a/CHANGELOG.md b/CHANGELOG.md index fff7799af26..c57909ccfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored. +- Plugins/hooks: let `plugins.entries..hooks.timeoutMs` and `plugins.entries..hooks.timeouts` bound plugin typed hooks from operator config, so slow hooks can be tuned without patching installed plugin code. Fixes #76778. Thanks @vincentkoc. - Telegram: add `channels.telegram.mediaGroupFlushMs` at the top level and per account so operators can tune album buffering instead of being stuck with the hard-coded 500ms media-group flush window. Fixes #76149. Thanks @vincentkoc. - Config/messages: coerce boolean `messages.visibleReplies` and `messages.groupChat.visibleReplies` values to the documented enum modes so an intuitive toggle no longer invalidates config and drops channel startup. Fixes #75390. Thanks @scottgl9. - Feishu: accept and honor `channels.feishu.blockStreaming` at the top level and per account, while keeping the legacy default off so Feishu cards no longer reject documented config or silently drop block replies. Fixes #75555. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 81a54e3c04b..c1403ffee54 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -df881d10bfb3d1ba0439e5984117dde70b5f7e856696f25c7f4b5c978a38f841 config-baseline.json +3a6c1626e7f5f6c7c8658516072e9ab327b668f6b25ecd3ab1e12cbcb6dc1f88 config-baseline.json f945a060012b3e7c675fb3ea0c5f18996cdcc06c9ec6cead389e04791a529ce9 config-baseline.core.json 09a952cf734a5b4a30f760e570c0f106d54aa8e74bf439dd4d07013f9f7607e4 config-baseline.channel.json -245aa98aabc6c2e3c57a69e639c2fb10d84a7e1e1b3bcdadc340fa61ca998287 config-baseline.plugin.json +055fae0d0067a751dc10125af7421da45633f73519c94c982d02b0c4eb2bdf67 config-baseline.plugin.json diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index bf1e9607f26..094cf4d651d 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -61,6 +61,32 @@ keep registration order. timeout. Omit it to use the default observation/decision timeout that the hook runner applies generically. +Operators can also set hook budgets without patching plugin code: + +```json +{ + "plugins": { + "entries": { + "my-plugin": { + "hooks": { + "timeoutMs": 30000, + "timeouts": { + "before_prompt_build": 90000, + "agent_end": 60000 + } + } + } + } + } +} +``` + +`hooks.timeouts.` overrides `hooks.timeoutMs`, which overrides the +plugin-authored `api.on(..., { timeoutMs })` value. Each configured value must +be a positive integer no greater than 600000 milliseconds. Prefer per-hook +overrides for known slow hooks so one plugin does not get a longer budget +everywhere. + Each hook receives `event.context.pluginConfig`, the resolved config for the plugin that registered that handler. Use it for hook decisions that need current plugin options; OpenClaw injects it per handler without mutating the diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index c7683dac92f..24932a87c9e 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -374,6 +374,25 @@ describe("plugins.entries.*.hooks", () => { expect(result.success).toBe(true); }); + it("accepts bounded typed hook timeout overrides", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "memory-recall": { + hooks: { + timeoutMs: 30_000, + timeouts: { + before_prompt_build: 90_000, + agent_end: 60_000, + }, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + it("rejects non-boolean values", () => { const result = OpenClawSchema.safeParse({ plugins: { @@ -405,6 +424,24 @@ describe("plugins.entries.*.hooks", () => { }); expect(result.success).toBe(false); }); + + it("rejects invalid typed hook timeout overrides", () => { + for (const hooks of [ + { timeoutMs: 0 }, + { timeoutMs: 600_001 }, + { timeouts: { before_prompt_build: -1 } }, + { timeouts: { before_prompt_build: 1.5 } }, + ]) { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "memory-recall": { hooks }, + }, + }, + }); + expect(result.success).toBe(false); + } + }); }); describe("plugins.entries.*.subagent", () => { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index fd4549d1de2..600e2e57efb 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24091,6 +24091,28 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.", }, + timeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 600000, + title: "Plugin Hook Timeout (ms)", + description: + "Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.", + }, + timeouts: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "integer", + exclusiveMinimum: 0, + maximum: 600000, + }, + title: "Plugin Hook Timeout Overrides", + description: + "Per-hook timeout overrides in milliseconds keyed by typed hook name, capped at 600000. Use narrow overrides for known slow hooks such as before_prompt_build or agent_end instead of raising every hook timeout.", + }, }, additionalProperties: false, title: "Plugin Hook Policy", @@ -28867,6 +28889,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", tags: ["access"], }, + "plugins.entries.*.hooks.timeoutMs": { + label: "Plugin Hook Timeout (ms)", + help: "Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.", + tags: ["performance"], + }, + "plugins.entries.*.hooks.timeouts": { + label: "Plugin Hook Timeout Overrides", + help: "Per-hook timeout overrides in milliseconds keyed by typed hook name, capped at 600000. Use narrow overrides for known slow hooks such as before_prompt_build or agent_end instead of raising every hook timeout.", + tags: ["performance"], + }, "plugins.entries.*.subagent": { label: "Plugin Subagent Policy", help: "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index ee95f2abb71..f8e1b974483 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -369,6 +369,8 @@ const TARGET_KEYS = [ "plugins.entries.*.hooks", "plugins.entries.*.hooks.allowPromptInjection", "plugins.entries.*.hooks.allowConversationAccess", + "plugins.entries.*.hooks.timeoutMs", + "plugins.entries.*.hooks.timeouts", "plugins.entries.*.subagent", "plugins.entries.*.subagent.allowModelOverride", "plugins.entries.*.subagent.allowedModels", @@ -800,6 +802,14 @@ describe("config help copy quality", () => { expect(pluginConversationPolicy.includes("llm_input")).toBe(true); expect(pluginConversationPolicy.includes("llm_output")).toBe(true); expect(pluginConversationPolicy.includes("before_agent_finalize")).toBe(true); + + const pluginHookTimeout = FIELD_HELP["plugins.entries.*.hooks.timeoutMs"]; + expect(pluginHookTimeout.includes("typed hooks")).toBe(true); + expect(pluginHookTimeout.includes("hooks.timeouts")).toBe(true); + + const pluginHookTimeouts = FIELD_HELP["plugins.entries.*.hooks.timeouts"]; + expect(pluginHookTimeouts.includes("before_prompt_build")).toBe(true); + expect(pluginHookTimeouts.includes("agent_end")).toBe(true); expect(pluginConversationPolicy.includes("agent_end")).toBe(true); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 11787e49a0a..74200ee706d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1220,6 +1220,10 @@ export const FIELD_HELP: Record = { "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "plugins.entries.*.hooks.allowConversationAccess": "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + "plugins.entries.*.hooks.timeoutMs": + "Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.", + "plugins.entries.*.hooks.timeouts": + "Per-hook timeout overrides in milliseconds keyed by typed hook name, capped at 600000. Use narrow overrides for known slow hooks such as before_prompt_build or agent_end instead of raising every hook timeout.", "plugins.entries.*.subagent": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", "plugins.entries.*.subagent.allowModelOverride": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 3c4264ab705..cb0d853cd6f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -915,6 +915,8 @@ export const FIELD_LABELS: Record = { "plugins.entries.*.hooks": "Plugin Hook Policy", "plugins.entries.*.hooks.allowConversationAccess": "Allow Conversation Access Hooks", "plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks", + "plugins.entries.*.hooks.timeoutMs": "Plugin Hook Timeout (ms)", + "plugins.entries.*.hooks.timeouts": "Plugin Hook Timeout Overrides", "plugins.entries.*.subagent": "Plugin Subagent Policy", "plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override", "plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 1f20e911279..0f5818baf37 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -8,6 +8,10 @@ export type PluginEntryConfig = { * Non-bundled plugins must opt in explicitly; bundled plugins stay allowed unless disabled. */ allowConversationAccess?: boolean; + /** Default timeout in milliseconds for this plugin's typed hooks. */ + timeoutMs?: number; + /** Per typed-hook timeout overrides in milliseconds. */ + timeouts?: Record; }; subagent?: { /** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7706b05bcf0..33a4e8c0592 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -190,6 +190,8 @@ const PluginEntrySchema = z .object({ allowPromptInjection: z.boolean().optional(), allowConversationAccess: z.boolean().optional(), + timeoutMs: z.number().int().positive().max(600_000).optional(), + timeouts: z.record(z.string(), z.number().int().positive().max(600_000)).optional(), }) .strict() .optional(), diff --git a/src/plugins/config-normalization-shared.ts b/src/plugins/config-normalization-shared.ts index ed61b11cd0b..33482bcd986 100644 --- a/src/plugins/config-normalization-shared.ts +++ b/src/plugins/config-normalization-shared.ts @@ -22,6 +22,8 @@ export type NormalizedPluginsConfig = { hooks?: { allowPromptInjection?: boolean; allowConversationAccess?: boolean; + timeoutMs?: number; + timeouts?: Record; }; subagent?: { allowModelOverride?: boolean; @@ -57,6 +59,33 @@ function normalizeSlotValue(value: unknown): string | null | undefined { return trimmed; } +function normalizeHookTimeoutMs(value: unknown): number | undefined { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + !Number.isFinite(value) || + value <= 0 || + value > 600_000 + ) { + return undefined; + } + return value; +} + +function normalizeHookTimeouts(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const normalized: Record = {}; + for (const [hookName, timeoutMs] of Object.entries(value)) { + const normalizedTimeoutMs = normalizeHookTimeoutMs(timeoutMs); + if (normalizedTimeoutMs !== undefined) { + normalized[hookName] = normalizedTimeoutMs; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + function normalizePluginEntries( entries: unknown, normalizePluginId: NormalizePluginId, @@ -83,12 +112,16 @@ function normalizePluginEntries( .allowPromptInjection, allowConversationAccess: (hooksRaw as { allowConversationAccess?: unknown }) .allowConversationAccess, + timeoutMs: normalizeHookTimeoutMs((hooksRaw as { timeoutMs?: unknown }).timeoutMs), + timeouts: normalizeHookTimeouts((hooksRaw as { timeouts?: unknown }).timeouts), } : undefined; const normalizedHooks = hooks && (typeof hooks.allowPromptInjection === "boolean" || - typeof hooks.allowConversationAccess === "boolean") + typeof hooks.allowConversationAccess === "boolean" || + hooks.timeoutMs !== undefined || + hooks.timeouts !== undefined) ? { ...(typeof hooks.allowPromptInjection === "boolean" ? { allowPromptInjection: hooks.allowPromptInjection } @@ -96,6 +129,8 @@ function normalizePluginEntries( ...(typeof hooks.allowConversationAccess === "boolean" ? { allowConversationAccess: hooks.allowConversationAccess } : {}), + ...(hooks.timeoutMs !== undefined ? { timeoutMs: hooks.timeoutMs } : {}), + ...(hooks.timeouts !== undefined ? { timeouts: hooks.timeouts } : {}), } : undefined; const subagentRaw = entry.subagent; diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 5a7b4e8fca4..0fe9c5f7f94 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -71,11 +71,21 @@ describe("normalizePluginsConfig", () => { hooks: { allowPromptInjection: false, allowConversationAccess: true, + timeoutMs: 250, + timeouts: { + before_prompt_build: 90_000, + agent_end: 60_000, + }, }, }, expectedHooks: { allowPromptInjection: false, allowConversationAccess: true, + timeoutMs: 250, + timeouts: { + before_prompt_build: 90_000, + agent_end: 60_000, + }, }, }, { @@ -84,6 +94,10 @@ describe("normalizePluginsConfig", () => { hooks: { allowPromptInjection: "nope", allowConversationAccess: "nope", + timeoutMs: 0, + timeouts: { + before_prompt_build: 900_000, + }, } as unknown as { allowPromptInjection: boolean; allowConversationAccess: boolean }, }, expectedHooks: undefined, diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index bebe155353d..92f6dc6cc9b 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -290,7 +290,10 @@ function hasExplicitHookPolicyConfig( entry: NormalizedPluginsConfig["entries"][string] | undefined, ): boolean { return ( - entry?.hooks?.allowConversationAccess === true || entry?.hooks?.allowPromptInjection === true + entry?.hooks?.allowConversationAccess === true || + entry?.hooks?.allowPromptInjection === true || + entry?.hooks?.timeoutMs !== undefined || + (entry?.hooks?.timeouts !== undefined && Object.keys(entry.hooks.timeouts).length > 0) ); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 2836db62b22..4bae4812fdf 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -5387,6 +5387,44 @@ module.exports = { ]); }); + it("applies configured typed hook timeout overrides", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "hook-timeouts", + filename: "hook-timeouts.cjs", + body: `module.exports = { id: "hook-timeouts", register(api) { + api.on("before_prompt_build", () => ({ prependContext: "prepend" }), { timeoutMs: 5000 }); + api.on("before_model_resolve", () => ({ providerOverride: "demo-provider" })); + api.on("before_agent_start", () => ({ modelOverride: "demo-model" })); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["hook-timeouts"], + entries: { + "hook-timeouts": { + hooks: { + timeoutMs: 250, + timeouts: { + before_model_resolve: 750, + }, + }, + }, + }, + }, + }); + + expect( + Object.fromEntries(registry.typedHooks.map((entry) => [entry.hookName, entry.timeoutMs])), + ).toEqual({ + before_prompt_build: 250, + before_model_resolve: 750, + before_agent_start: 250, + }); + }); + it("blocks conversation typed hooks for non-bundled plugins unless explicitly allowed", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index d3a3590c141..c0e3845026b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -224,8 +224,29 @@ export type { type PluginTypedHookPolicy = { allowPromptInjection?: boolean; allowConversationAccess?: boolean; + timeoutMs?: number; + timeouts?: Record; }; +function normalizeHookTimeoutMs(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.floor(value); +} + +function resolveTypedHookTimeoutMs(params: { + hookName: PluginHookName; + opts?: { timeoutMs?: number }; + policy?: PluginTypedHookPolicy; +}): number | undefined { + return ( + normalizeHookTimeoutMs(params.policy?.timeouts?.[params.hookName]) ?? + normalizeHookTimeoutMs(params.policy?.timeoutMs) ?? + normalizeHookTimeoutMs(params.opts?.timeoutMs) + ); +} + const constrainLegacyPromptInjectionHook = ( handler: PluginHookHandlerMap["before_agent_start"], ): PluginHookHandlerMap["before_agent_start"] => { @@ -2047,13 +2068,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } } + const timeoutMs = resolveTypedHookTimeoutMs({ hookName, opts, policy }); record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, hookName, handler: effectiveHandler, priority: opts?.priority, - ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), source: record.source, } as TypedPluginHookRegistration); }; diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 86403f2bd80..06f6f3dbfa9 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -575,6 +575,8 @@ describe("plugin status reports", () => { expectInspectPolicy(inspect!, { allowPromptInjection: undefined, allowConversationAccess: undefined, + hookTimeoutMs: undefined, + hookTimeouts: undefined, allowModelOverride: true, allowedModels: ["openai/gpt-5.5"], hasAllowedModelsConfig: true, @@ -733,6 +735,8 @@ describe("plugin status reports", () => { expectInspectPolicy(inspect!, { allowPromptInjection: false, allowConversationAccess: true, + hookTimeoutMs: undefined, + hookTimeouts: undefined, allowModelOverride: true, allowedModels: ["openai/gpt-5.5"], hasAllowedModelsConfig: true, diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 9b6522325f5..1e5766f5367 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -102,6 +102,8 @@ export type PluginInspectReport = { policy: { allowPromptInjection?: boolean; allowConversationAccess?: boolean; + hookTimeoutMs?: number; + hookTimeouts?: Record; allowModelOverride?: boolean; allowedModels: string[]; hasAllowedModelsConfig: boolean; @@ -515,6 +517,8 @@ export function buildPluginInspectReport(params: { policy: { allowPromptInjection: policyEntry?.hooks?.allowPromptInjection, allowConversationAccess: policyEntry?.hooks?.allowConversationAccess, + hookTimeoutMs: policyEntry?.hooks?.timeoutMs, + hookTimeouts: policyEntry?.hooks?.timeouts ? { ...policyEntry.hooks.timeouts } : undefined, allowModelOverride: policyEntry?.subagent?.allowModelOverride, allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true,