Plugins: enforce terminal hook decision semantics for tool/message guards (#54241)

* Plugins: enforce terminal hook decision policies

* Tests: assert terminal hook behavior in integration paths

* Docs: clarify terminal hook decision semantics

* Docs: add hook guard semantics to plugin guides

* Tests: isolate outbound format label expectations

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant
2026-03-25 00:11:13 -05:00
committed by GitHub
parent 5a5c5d4cde
commit 10161c2d79
10 changed files with 435 additions and 27 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
## 2026.3.23

View File

@@ -92,6 +92,13 @@ These run inside the agent loop or gateway pipeline:
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
Hook decision rules for outbound/tool guards:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
## Streaming + partial replies

View File

@@ -144,6 +144,15 @@ A single plugin can register any number of capabilities via the `api` object:
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
Hook guard semantics to keep in mind:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
See [SDK Overview hook decision semantics](/plugins/sdk-overview#hook-decision-semantics) for details.
## Registering agent tools
Tools are typed functions the LLM can call. They can be required (always

View File

@@ -152,6 +152,13 @@ methods:
| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
### Hook decision semantics
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
### API object fields
| Field | Type | Description |

View File

@@ -258,6 +258,15 @@ Common registration methods:
| `registerContextEngine` | Context engine |
| `registerService` | Background service |
Hook guard behavior for typed lifecycle hooks:
- `before_tool_call`: `{ block: true }` is terminal; lower-priority handlers are skipped.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear an earlier block.
- `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel.
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
## Related
- [Building Plugins](/plugins/building-plugins) — create your own plugin

View File

@@ -4,7 +4,9 @@ import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { addTestHook, createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import type { PluginHookRegistration } from "../plugins/types.js";
type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js");
type PiToolsAbortModule = typeof import("./pi-tools.abort.js");
@@ -39,6 +41,12 @@ beforeEach(async () => {
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
type BeforeToolCallHookInstall = {
pluginId: string;
priority?: number;
handler: BeforeToolCallHandlerMock;
};
function installBeforeToolCallHook(params?: {
enabled?: boolean;
runBeforeToolCallImpl?: (...args: unknown[]) => unknown;
@@ -54,6 +62,21 @@ function installBeforeToolCallHook(params?: {
return handler;
}
function installBeforeToolCallHooks(hooks: BeforeToolCallHookInstall[]): void {
resetGlobalHookRunner();
const registry = createEmptyPluginRegistry();
for (const hook of hooks) {
addTestHook({
registry,
pluginId: hook.pluginId,
hookName: "before_tool_call",
handler: hook.handler as PluginHookRegistration["handler"],
priority: hook.priority,
});
}
initializeGlobalHookRunner(registry);
}
describe("before_tool_call hook integration", () => {
let beforeToolCallHook: BeforeToolCallHandlerMock;
@@ -122,6 +145,28 @@ describe("before_tool_call hook integration", () => {
expect(execute).not.toHaveBeenCalled();
});
it("does not execute lower-priority hooks after block=true", async () => {
const high = vi.fn().mockResolvedValue({ block: true, blockReason: "blocked-high" });
const low = vi.fn().mockResolvedValue({ params: { shouldNotApply: true } });
installBeforeToolCallHooks([
{ pluginId: "high", priority: 100, handler: high },
{ pluginId: "low", priority: 0, handler: low },
]);
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
const extensionContext = {} as Parameters<typeof tool.execute>[3];
await expect(
tool.execute("call-stop", { cmd: "rm -rf /" }, undefined, extensionContext),
).rejects.toThrow("blocked-high");
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
});
it("continues execution when hook throws", async () => {
beforeToolCallHook = installBeforeToolCallHook({
runBeforeToolCallImpl: async () => {

View File

@@ -7,7 +7,11 @@ import {
whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createHookRunner } from "../../plugins/hooks.js";
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import type { PluginHookRegistration } from "../../plugins/types.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
@@ -19,8 +23,11 @@ const mocks = vi.hoisted(() => ({
}));
const hookMocks = vi.hoisted(() => ({
runner: {
hasHooks: vi.fn(() => false),
runMessageSent: vi.fn(async () => {}),
hasHooks: vi.fn<(_hookName?: string) => boolean>(() => false),
runMessageSending: vi.fn<(event: unknown, ctx: unknown) => Promise<unknown>>(
async () => undefined,
),
runMessageSent: vi.fn<(event: unknown, ctx: unknown) => Promise<void>>(async () => {}),
},
}));
const internalHookMocks = vi.hoisted(() => ({
@@ -210,6 +217,8 @@ describe("deliverOutboundPayloads", () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
hookMocks.runner.hasHooks.mockClear();
hookMocks.runner.hasHooks.mockReturnValue(false);
hookMocks.runner.runMessageSending.mockClear();
hookMocks.runner.runMessageSending.mockResolvedValue(undefined);
hookMocks.runner.runMessageSent.mockClear();
hookMocks.runner.runMessageSent.mockResolvedValue(undefined);
internalHookMocks.createInternalHookEvent.mockClear();
@@ -1031,6 +1040,48 @@ describe("deliverOutboundPayloads", () => {
);
});
it("short-circuits lower-priority message_sending hooks after cancel=true", async () => {
const hookRegistry = createEmptyPluginRegistry();
const high = vi.fn().mockResolvedValue({ cancel: true, content: "blocked" });
const low = vi.fn().mockResolvedValue({ cancel: false, content: "override" });
addTestHook({
registry: hookRegistry,
pluginId: "high",
hookName: "message_sending",
handler: high as PluginHookRegistration["handler"],
priority: 100,
});
addTestHook({
registry: hookRegistry,
pluginId: "low",
hookName: "message_sending",
handler: low as PluginHookRegistration["handler"],
priority: 0,
});
const realRunner = createHookRunner(hookRegistry);
hookMocks.runner.hasHooks.mockImplementation((hookName?: string) =>
realRunner.hasHooks((hookName ?? "") as never),
);
hookMocks.runner.runMessageSending.mockImplementation((event, ctx) =>
realRunner.runMessageSending(event as never, ctx as never),
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hello" }],
deps: { sendWhatsApp },
});
expect(hookMocks.runner.runMessageSending).toHaveBeenCalledTimes(1);
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled();
});
it("emits message_sent success for sendPayload deliveries", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });

View File

@@ -1,10 +1,16 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "./format.js";
const getChannelPluginMock = vi.hoisted(() => vi.fn((_channel: unknown) => undefined));
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: getChannelPluginMock,
}));
describe("formatOutboundDeliverySummary", () => {
it("formats fallback and provider-specific detail variants", () => {
const cases = [

View File

@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHookRunner } from "./hooks.js";
import { addTestHook } from "./hooks.test-helpers.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
import type {
PluginHookBeforeToolCallResult,
PluginHookMessageSendingResult,
PluginHookRegistration,
} from "./types.js";
function addBeforeToolCallHook(
registry: PluginRegistry,
pluginId: string,
handler: () => PluginHookBeforeToolCallResult | Promise<PluginHookBeforeToolCallResult>,
priority?: number,
) {
addTestHook({
registry,
pluginId,
hookName: "before_tool_call",
handler: handler as PluginHookRegistration["handler"],
priority,
});
}
function addMessageSendingHook(
registry: PluginRegistry,
pluginId: string,
handler: () => PluginHookMessageSendingResult | Promise<PluginHookMessageSendingResult>,
priority?: number,
) {
addTestHook({
registry,
pluginId,
hookName: "message_sending",
handler: handler as PluginHookRegistration["handler"],
priority,
});
}
const toolEvent = { toolName: "bash", params: { command: "echo hello" } };
const toolCtx = { toolName: "bash" };
const messageEvent = { to: "user-1", content: "hello" };
const messageCtx = { channelId: "telegram" };
describe("before_tool_call terminal block semantics", () => {
let registry: PluginRegistry;
beforeEach(() => {
registry = createEmptyPluginRegistry();
});
it("keeps block=true when a lower-priority hook returns block=false", async () => {
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "dangerous" }), 100);
addBeforeToolCallHook(registry, "low", () => ({ block: false }), 10);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("dangerous");
});
it("treats explicit block=false as no-op when no prior hook blocked", async () => {
addBeforeToolCallHook(registry, "single", () => ({ block: false }), 10);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBeUndefined();
});
it("treats passive handler output as no-op for prior block", async () => {
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "blocked" }), 100);
addBeforeToolCallHook(registry, "passive", () => ({}), 10);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("blocked");
});
it("short-circuits lower-priority hooks after block=true", async () => {
const high = vi.fn().mockReturnValue({ block: true, blockReason: "stop" });
const low = vi.fn().mockReturnValue({ params: { injected: true } });
addBeforeToolCallHook(registry, "high", high, 100);
addBeforeToolCallHook(registry, "low", low, 10);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
});
it("preserves deterministic same-priority registration order when terminal hook runs first", async () => {
const first = vi.fn().mockReturnValue({ block: true, blockReason: "first" });
const second = vi.fn().mockReturnValue({ block: true, blockReason: "second" });
addBeforeToolCallHook(registry, "first", first, 50);
addBeforeToolCallHook(registry, "second", second, 50);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("first");
expect(first).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();
});
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "guard" }), 100);
const low = vi.fn().mockImplementation(() => {
throw new Error("should not run");
});
addBeforeToolCallHook(registry, "low", low, 10);
const runner = createHookRunner(registry, { catchErrors: false });
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(low).not.toHaveBeenCalled();
});
it("respects block from a middle hook in a multi-handler chain", async () => {
const low = vi.fn().mockReturnValue({ block: false });
addBeforeToolCallHook(registry, "high-passive", () => ({}), 100);
addBeforeToolCallHook(
registry,
"middle-block",
() => ({ block: true, blockReason: "mid" }),
50,
);
addBeforeToolCallHook(registry, "low-false", low, 0);
const runner = createHookRunner(registry);
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
expect(result?.block).toBe(true);
expect(result?.blockReason).toBe("mid");
expect(low).not.toHaveBeenCalled();
});
});
describe("message_sending terminal cancel semantics", () => {
let registry: PluginRegistry;
beforeEach(() => {
registry = createEmptyPluginRegistry();
});
it("keeps cancel=true when a lower-priority hook returns cancel=false", async () => {
addMessageSendingHook(registry, "high", () => ({ cancel: true, content: "guarded" }), 100);
addMessageSendingHook(registry, "low", () => ({ cancel: false, content: "override" }), 10);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBe(true);
expect(result?.content).toBe("guarded");
});
it("treats explicit cancel=false as no-op when no prior hook canceled", async () => {
addMessageSendingHook(registry, "single", () => ({ cancel: false }), 10);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBeUndefined();
});
it("treats passive handler output as no-op for prior cancel", async () => {
addMessageSendingHook(registry, "high", () => ({ cancel: true }), 100);
addMessageSendingHook(registry, "passive", () => ({}), 10);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBe(true);
});
it("short-circuits lower-priority hooks after cancel=true", async () => {
const high = vi.fn().mockReturnValue({ cancel: true, content: "guarded" });
const low = vi.fn().mockReturnValue({ cancel: false, content: "mutated" });
addMessageSendingHook(registry, "high", high, 100);
addMessageSendingHook(registry, "low", low, 10);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBe(true);
expect(result?.content).toBe("guarded");
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
});
it("preserves deterministic same-priority registration order for non-terminal merges", async () => {
addMessageSendingHook(registry, "first", () => ({ content: "first" }), 50);
addMessageSendingHook(registry, "second", () => ({ content: "second" }), 50);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.content).toBe("second");
});
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
addMessageSendingHook(registry, "high", () => ({ cancel: true }), 100);
const low = vi.fn().mockImplementation(() => {
throw new Error("should not run");
});
addMessageSendingHook(registry, "low", low, 10);
const runner = createHookRunner(registry, { catchErrors: false });
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBe(true);
expect(low).not.toHaveBeenCalled();
});
it("allows lower-priority cancel when higher-priority hooks are non-terminal", async () => {
addMessageSendingHook(registry, "high-passive", () => ({ content: "rewritten" }), 100);
addMessageSendingHook(registry, "low-cancel", () => ({ cancel: true }), 10);
const runner = createHookRunner(registry);
const result = await runner.runMessageSending(messageEvent, messageCtx);
expect(result?.cancel).toBe(true);
});
});

View File

@@ -114,6 +114,13 @@ export type HookRunnerOptions = {
catchErrors?: boolean;
};
type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult;
shouldStop?: (result: TResult) => boolean;
terminalLabel?: string;
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
};
export type PluginTargetedInboundClaimOutcome =
| {
status: "handled";
@@ -160,20 +167,25 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
const logger = options.logger;
const catchErrors = options.catchErrors ?? true;
const firstDefined = <T>(prev: T | undefined, next: T | undefined): T | undefined => prev ?? next;
const lastDefined = <T>(prev: T | undefined, next: T | undefined): T | undefined => next ?? prev;
const stickyTrue = (prev?: boolean, next?: boolean): true | undefined =>
prev === true || next === true ? true : undefined;
const mergeBeforeModelResolve = (
acc: PluginHookBeforeModelResolveResult | undefined,
next: PluginHookBeforeModelResolveResult,
): PluginHookBeforeModelResolveResult => ({
// Keep the first defined override so higher-priority hooks win.
modelOverride: acc?.modelOverride ?? next.modelOverride,
providerOverride: acc?.providerOverride ?? next.providerOverride,
modelOverride: firstDefined(acc?.modelOverride, next.modelOverride),
providerOverride: firstDefined(acc?.providerOverride, next.providerOverride),
});
const mergeBeforePromptBuild = (
acc: PluginHookBeforePromptBuildResult | undefined,
next: PluginHookBeforePromptBuildResult,
): PluginHookBeforePromptBuildResult => ({
systemPrompt: next.systemPrompt ?? acc?.systemPrompt,
systemPrompt: lastDefined(acc?.systemPrompt, next.systemPrompt),
prependContext: concatOptionalTextSegments({
left: acc?.prependContext,
right: next.prependContext,
@@ -270,7 +282,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
policy: ModifyingHookPolicy<K, TResult> = {},
): Promise<TResult | undefined> {
const hooks = getHooksForName(registry, hookName);
if (hooks.length === 0) {
@@ -288,11 +300,20 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
)(event, ctx);
if (handlerResult !== undefined && handlerResult !== null) {
if (mergeResults && result !== undefined) {
result = mergeResults(result, handlerResult);
if (policy.mergeResults) {
result = policy.mergeResults(result, handlerResult);
} else {
result = handlerResult;
}
if (result && policy.shouldStop?.(result)) {
const terminalLabel = policy.terminalLabel ? ` ${policy.terminalLabel}` : "";
const priority = hook.priority ?? 0;
logger?.debug?.(
`[hooks] ${hookName}${terminalLabel} decided by ${hook.pluginId} (priority=${priority}); skipping remaining handlers`,
);
policy.onTerminal?.({ hookName, pluginId: hook.pluginId, result });
break;
}
}
} catch (err) {
handleHookError({ hookName, pluginId: hook.pluginId, error: err });
@@ -434,7 +455,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"before_model_resolve",
event,
ctx,
mergeBeforeModelResolve,
{ mergeResults: mergeBeforeModelResolve },
);
}
@@ -450,7 +471,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"before_prompt_build",
event,
ctx,
mergeBeforePromptBuild,
{ mergeResults: mergeBeforePromptBuild },
);
}
@@ -466,10 +487,12 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"before_agent_start",
event,
ctx,
(acc, next) => ({
...mergeBeforePromptBuild(acc, next),
...mergeBeforeModelResolve(acc, next),
}),
{
mergeResults: (acc, next) => ({
...mergeBeforePromptBuild(acc, next),
...mergeBeforeModelResolve(acc, next),
}),
},
);
}
@@ -604,10 +627,19 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"message_sending",
event,
ctx,
(acc, next) => ({
content: next.content ?? acc?.content,
cancel: next.cancel ?? acc?.cancel,
}),
{
mergeResults: (acc, next) => {
if (acc?.cancel === true) {
return acc;
}
return {
content: lastDefined(acc?.content, next.content),
cancel: stickyTrue(acc?.cancel, next.cancel),
};
},
shouldStop: (result) => result.cancel === true,
terminalLabel: "cancel=true",
},
);
}
@@ -639,11 +671,20 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"before_tool_call",
event,
ctx,
(acc, next) => ({
params: next.params ?? acc?.params,
block: next.block ?? acc?.block,
blockReason: next.blockReason ?? acc?.blockReason,
}),
{
mergeResults: (acc, next) => {
if (acc?.block === true) {
return acc;
}
return {
params: lastDefined(acc?.params, next.params),
block: stickyTrue(acc?.block, next.block),
blockReason: lastDefined(acc?.blockReason, next.blockReason),
};
},
shouldStop: (result) => result.block === true,
terminalLabel: "block=true",
},
);
}
@@ -832,7 +873,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"subagent_spawning",
event,
ctx,
mergeSubagentSpawningResult,
{ mergeResults: mergeSubagentSpawningResult },
);
}
@@ -848,7 +889,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
"subagent_delivery_target",
event,
ctx,
mergeSubagentDeliveryTargetResult,
{ mergeResults: mergeSubagentDeliveryTargetResult },
);
}