fix(plugins): expose hook timeout overrides

This commit is contained in:
Vincent Koc
2026-05-03 12:21:59 -07:00
parent c5488ea577
commit 1d34564de9
17 changed files with 243 additions and 5 deletions

View File

@@ -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.<id>.hooks.timeoutMs` and `plugins.entries.<id>.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.

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -1220,6 +1220,10 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -915,6 +915,8 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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<string, number>;
};
subagent?: {
/** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */

View File

@@ -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(),

View File

@@ -22,6 +22,8 @@ export type NormalizedPluginsConfig = {
hooks?: {
allowPromptInjection?: boolean;
allowConversationAccess?: boolean;
timeoutMs?: number;
timeouts?: Record<string, number>;
};
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<string, number> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const normalized: Record<string, number> = {};
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;

View File

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

View File

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

View File

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

View File

@@ -224,8 +224,29 @@ export type {
type PluginTypedHookPolicy = {
allowPromptInjection?: boolean;
allowConversationAccess?: boolean;
timeoutMs?: number;
timeouts?: Record<string, number>;
};
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);
};

View File

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

View File

@@ -102,6 +102,8 @@ export type PluginInspectReport = {
policy: {
allowPromptInjection?: boolean;
allowConversationAccess?: boolean;
hookTimeoutMs?: number;
hookTimeouts?: Record<string, number>;
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,