feat: add search provider lifecycle hooks

This commit is contained in:
Tak Hoffman
2026-03-11 23:43:55 -05:00
parent 0997deb0e9
commit 9cffd72953
4 changed files with 548 additions and 14 deletions

View File

@@ -4,7 +4,7 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const loadOpenClawPlugins = vi.hoisted(() =>
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[], typedHooks: [] })),
);
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })),
@@ -116,7 +116,7 @@ describe("setupSearch", () => {
vi.stubEnv("MOONSHOT_API_KEY", "");
vi.stubEnv("PERPLEXITY_API_KEY", "");
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [], typedHooks: [] });
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
ensureOnboardingPluginInstalled.mockReset();
@@ -154,6 +154,7 @@ describe("setupSearch", () => {
configUiHints: undefined,
},
],
typedHooks: [],
});
const cfg: OpenClawConfig = {};
@@ -257,6 +258,7 @@ describe("setupSearch", () => {
configUiHints: undefined,
},
],
typedHooks: [],
});
const cfg: OpenClawConfig = {
tools: {
@@ -384,6 +386,7 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
});
const cfg: OpenClawConfig = {};
@@ -411,6 +414,151 @@ describe("setupSearch", () => {
);
});
it("shows a provider setup note from before_search_provider_configure hooks", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
configFieldOrder: ["apiKey"],
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: {
type: "object",
properties: {
apiKey: { type: "string" },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
},
},
],
typedHooks: [
{
pluginId: "tavily-search",
hookName: "before_search_provider_configure",
priority: 0,
source: "/tmp/tavily-search",
handler: () => ({ note: "Read the provider docs before entering your key." }),
},
],
});
const { prompter, notes } = createPrompter({
actionValue: "__configure_provider__",
selectValue: "tavily",
textValue: "tvly-test-key",
});
await setupSearch({}, runtime, prompter);
expect(notes).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "Provider setup",
message: "Read the provider docs before entering your key.",
}),
]),
);
});
it("fires after_search_provider_activate only when the active provider changes", async () => {
const afterActivate = vi.fn();
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
isAvailable: () => true,
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: undefined,
configUiHints: undefined,
},
],
typedHooks: [
{
pluginId: "tavily-search",
hookName: "after_search_provider_activate",
priority: 0,
source: "/tmp/tavily-search",
handler: afterActivate,
},
],
});
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "brave",
enabled: true,
apiKey: "BSA-test-key",
},
},
},
plugins: {
entries: {
"tavily-search": {
enabled: true,
config: {
apiKey: "tvly-existing-key",
},
},
},
},
};
const { prompter } = createPrompter({
actionValue: "__switch_active__",
selectValue: "tavily",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(afterActivate).toHaveBeenCalledTimes(1);
expect(afterActivate).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "tavily",
previousProviderId: "brave",
intent: "switch-active",
}),
expect.objectContaining({
workspaceDir: undefined,
}),
);
});
it("re-prompts invalid plugin config values before saving", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
@@ -452,6 +600,7 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
});
const cfg: OpenClawConfig = {};
@@ -517,6 +666,7 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
});
const cfg: OpenClawConfig = {
@@ -600,8 +750,9 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
}
: { searchProviders: [], plugins: [] };
: { searchProviders: [], plugins: [], typedHooks: [] };
});
ensureOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
@@ -703,6 +854,7 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
}
: {
searchProviders: [],
@@ -733,6 +885,7 @@ describe("setupSearch", () => {
},
},
],
typedHooks: [],
};
});
loadPluginManifestRegistry.mockReturnValue({

View File

@@ -17,6 +17,7 @@ import {
resolveCapabilitySlotSelection,
} from "../plugins/capability-slots.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { createHookRunner, type HookRunner } from "../plugins/hooks.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
@@ -96,6 +97,19 @@ type PluginPromptableField =
existingValue?: boolean;
};
type SearchProviderHookDetails = {
providerId: string;
providerLabel: string;
providerSource: "builtin" | "plugin";
pluginId?: string;
configured: boolean;
};
const HOOK_RUNNER_LOGGER = {
warn: () => {},
error: () => {},
} as const;
function hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -285,6 +299,113 @@ function validatePluginSearchProviderConfig(
};
}
function createSearchProviderHookRunner(
config: OpenClawConfig,
workspaceDir?: string,
): HookRunner | null {
try {
const registry = loadOpenClawPlugins({
config,
cache: false,
workspaceDir,
suppressOpenAllowlistWarning: true,
});
if (registry.typedHooks.length === 0) {
return null;
}
return createHookRunner(registry, {
logger: HOOK_RUNNER_LOGGER,
catchErrors: true,
});
} catch {
return null;
}
}
async function maybeNoteBeforeSearchProviderConfigure(params: {
hookRunner: HookRunner | null;
config: OpenClawConfig;
provider: SearchProviderHookDetails;
intent: SearchProviderFlowIntent;
prompter: WizardPrompter;
workspaceDir?: string;
}): Promise<void> {
if (!params.hookRunner?.hasHooks("before_search_provider_configure")) {
return;
}
const result = await params.hookRunner.runBeforeSearchProviderConfigure(
{
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
intent: params.intent,
activeProviderId: resolveCapabilitySlotSelection(params.config, "providers.search") ?? null,
configured: params.provider.configured,
},
{
workspaceDir: params.workspaceDir,
},
);
if (result?.note?.trim()) {
await params.prompter.note(result.note, "Provider setup");
}
}
async function runAfterSearchProviderHooks(params: {
hookRunner: HookRunner | null;
originalConfig: OpenClawConfig;
resultConfig: OpenClawConfig;
provider: SearchProviderHookDetails;
intent: SearchProviderFlowIntent;
workspaceDir?: string;
}): Promise<void> {
if (!params.hookRunner) {
return;
}
const activeProviderBefore =
resolveCapabilitySlotSelection(params.originalConfig, "providers.search") ?? null;
const activeProviderAfter =
resolveCapabilitySlotSelection(params.resultConfig, "providers.search") ?? null;
if (params.hookRunner.hasHooks("after_search_provider_configure")) {
await params.hookRunner.runAfterSearchProviderConfigure(
{
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
intent: params.intent,
activeProviderId: activeProviderAfter,
configured: params.provider.configured,
},
{
workspaceDir: params.workspaceDir,
},
);
}
if (
activeProviderAfter === params.provider.providerId &&
activeProviderBefore !== activeProviderAfter &&
params.hookRunner.hasHooks("after_search_provider_activate")
) {
await params.hookRunner.runAfterSearchProviderActivate(
{
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
previousProviderId: activeProviderBefore,
intent: params.intent,
},
{
workspaceDir: params.workspaceDir,
},
);
}
}
async function promptPluginSearchProviderConfig(
config: OpenClawConfig,
entry: PluginSearchProviderEntry,
@@ -610,12 +731,42 @@ export async function applySearchProviderChoice(params: {
return installedConfig;
}
const enabled = enablePluginInConfig(installedConfig, installedProvider.pluginId);
const hookRunner = createSearchProviderHookRunner(enabled.config, params.opts?.workspaceDir);
const providerDetails: SearchProviderHookDetails = {
providerId: installedProvider.value,
providerLabel: installedProvider.label,
providerSource: "plugin",
pluginId: installedProvider.pluginId,
configured: installedProvider.configured,
};
let next =
intent === "switch-active"
? setWebSearchProvider(enabled.config, installedProvider.value)
: enabled.config;
await maybeNoteBeforeSearchProviderConfigure({
hookRunner,
config: next,
provider: providerDetails,
intent,
prompter: params.prompter,
workspaceDir: params.opts?.workspaceDir,
});
next = await promptPluginSearchProviderConfig(next, installedProvider, params.prompter);
return preserveSearchProviderIntent(installedConfig, next, intent, installedProvider.value);
const result = preserveSearchProviderIntent(
installedConfig,
next,
intent,
installedProvider.value,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: installedConfig,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: params.opts?.workspaceDir,
});
return result;
}
return configureSearchProviderSelection(
@@ -775,18 +926,61 @@ export async function configureSearchProviderSelection(
const selectedEntry = providerEntries.find((entry) => entry.value === choice);
if (selectedEntry?.kind === "plugin") {
const enabled = enablePluginInConfig(config, selectedEntry.pluginId);
const hookRunner = createSearchProviderHookRunner(enabled.config, opts?.workspaceDir);
const providerDetails: SearchProviderHookDetails = {
providerId: selectedEntry.value,
providerLabel: selectedEntry.label,
providerSource: "plugin",
pluginId: selectedEntry.pluginId,
configured: selectedEntry.configured,
};
let next =
intent === "switch-active"
? setWebSearchProvider(enabled.config, selectedEntry.value)
: enabled.config;
if (selectedEntry.configured) {
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
const result = preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
if (opts?.quickstartDefaults && selectedEntry.configured) {
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
const result = preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
await maybeNoteBeforeSearchProviderConfigure({
hookRunner,
config: next,
provider: providerDetails,
intent,
prompter,
workspaceDir: opts?.workspaceDir,
});
next = await promptPluginSearchProviderConfig(next, selectedEntry, prompter);
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
const result = preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
const builtinChoice = choice as SearchProvider;
@@ -794,6 +988,13 @@ export async function configureSearchProviderSelection(
if (!entry) {
return config;
}
const hookRunner = createSearchProviderHookRunner(config, opts?.workspaceDir);
const providerDetails: SearchProviderHookDetails = {
providerId: builtinChoice,
providerLabel: entry.label,
providerSource: "builtin",
configured: hasExistingKey(config, builtinChoice) || hasKeyInEnv(entry),
};
const existingKey = resolveExistingKey(config, builtinChoice);
const keyConfigured = hasExistingKey(config, builtinChoice);
const envAvailable = hasKeyInEnv(entry);
@@ -802,16 +1003,43 @@ export async function configureSearchProviderSelection(
const result = existingKey
? applySearchKey(config, builtinChoice, existingKey)
: applyProviderOnly(config, builtinChoice);
return preserveSearchProviderIntent(config, result, intent, builtinChoice);
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: next,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return next;
}
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, builtinChoice, existingKey)
: applyProviderOnly(config, builtinChoice);
return preserveSearchProviderIntent(config, result, intent, builtinChoice);
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: next,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return next;
}
await maybeNoteBeforeSearchProviderConfigure({
hookRunner,
config,
provider: providerDetails,
intent,
prompter,
workspaceDir: opts?.workspaceDir,
});
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
@@ -827,12 +1055,21 @@ export async function configureSearchProviderSelection(
].join("\n"),
"Web search",
);
return preserveSearchProviderIntent(
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, ref),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
const keyInput = await prompter.text({
@@ -847,30 +1084,57 @@ export async function configureSearchProviderSelection(
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(builtinChoice, key, opts?.secretInputMode);
return preserveSearchProviderIntent(
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, secretInput),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
if (existingKey) {
return preserveSearchProviderIntent(
const result = preserveSearchProviderIntent(
config,
applySearchKey(config, builtinChoice, existingKey),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
if (keyConfigured || envAvailable) {
return preserveSearchProviderIntent(
const result = preserveSearchProviderIntent(
config,
applyProviderOnly(config, builtinChoice),
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
await prompter.note(
@@ -882,7 +1146,7 @@ export async function configureSearchProviderSelection(
"Web search",
);
return preserveSearchProviderIntent(
const result = preserveSearchProviderIntent(
config,
applyCapabilitySlotSelection({
config,
@@ -892,6 +1156,15 @@ export async function configureSearchProviderSelection(
intent,
builtinChoice,
);
await runAfterSearchProviderHooks({
hookRunner,
originalConfig: config,
resultConfig: result,
provider: providerDetails,
intent,
workspaceDir: opts?.workspaceDir,
});
return result;
}
function preserveSearchProviderIntent(

View File

@@ -8,12 +8,16 @@
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterSearchProviderActivateEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterCompactionEvent,
PluginHookAfterToolCallEvent,
PluginHookAgentContext,
PluginHookAgentEndEvent,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@@ -37,6 +41,7 @@ import type {
PluginHookSessionContext,
PluginHookSessionEndEvent,
PluginHookSessionStartEvent,
PluginHookSearchProviderContext,
PluginHookSubagentContext,
PluginHookSubagentDeliveryTargetEvent,
PluginHookSubagentDeliveryTargetResult,
@@ -57,6 +62,8 @@ export type {
PluginHookAgentContext,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@@ -84,6 +91,7 @@ export type {
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
PluginHookSearchProviderContext,
PluginHookSubagentContext,
PluginHookSubagentDeliveryTargetEvent,
PluginHookSubagentDeliveryTargetResult,
@@ -94,6 +102,8 @@ export type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterSearchProviderActivateEvent,
};
export type HookRunnerLogger = {
@@ -181,6 +191,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return next;
};
const mergeBeforeSearchProviderConfigure = (
acc: PluginHookBeforeSearchProviderConfigureResult | undefined,
next: PluginHookBeforeSearchProviderConfigureResult,
): PluginHookBeforeSearchProviderConfigureResult => ({
note: concatOptionalTextSegments({
left: acc?.note,
right: next.note,
}),
});
const handleHookError = (params: {
hookName: PluginHookName;
pluginId: string;
@@ -318,6 +338,30 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
);
}
async function runBeforeSearchProviderConfigure(
event: PluginHookBeforeSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<PluginHookBeforeSearchProviderConfigureResult | undefined> {
return runModifyingHook<
"before_search_provider_configure",
PluginHookBeforeSearchProviderConfigureResult
>("before_search_provider_configure", event, ctx, mergeBeforeSearchProviderConfigure);
}
async function runAfterSearchProviderConfigure(
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_search_provider_configure", event, ctx);
}
async function runAfterSearchProviderActivate(
event: PluginHookAfterSearchProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_search_provider_activate", event, ctx);
}
/**
* Run agent_end hook.
* Allows plugins to analyze completed conversations.
@@ -727,6 +771,9 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runBeforeModelResolve,
runBeforePromptBuild,
runBeforeAgentStart,
runBeforeSearchProviderConfigure,
runAfterSearchProviderConfigure,
runAfterSearchProviderActivate,
runLlmInput,
runLlmOutput,
runAgentEnd,

View File

@@ -502,6 +502,9 @@ export type PluginHookName =
| "before_model_resolve"
| "before_prompt_build"
| "before_agent_start"
| "before_search_provider_configure"
| "after_search_provider_configure"
| "after_search_provider_activate"
| "llm_input"
| "llm_output"
| "agent_end"
@@ -528,6 +531,9 @@ export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
"before_prompt_build",
"before_agent_start",
"before_search_provider_configure",
"after_search_provider_configure",
"after_search_provider_activate",
"llm_input",
"llm_output",
"agent_end",
@@ -667,6 +673,46 @@ export const stripPromptMutationFieldsFromLegacyHookResult = (
: undefined;
};
// search-provider hooks
export type PluginHookSearchProviderContext = {
workspaceDir?: string;
};
export type PluginHookSearchProviderSource = "builtin" | "plugin";
export type PluginHookBeforeSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookBeforeSearchProviderConfigureResult = {
note?: string;
};
export type PluginHookAfterSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookAfterSearchProviderActivateEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
previousProviderId?: string | null;
intent: "switch-active" | "configure-provider";
};
// llm_input hook
export type PluginHookLlmInputEvent = {
runId: string;
@@ -980,6 +1026,21 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
before_search_provider_configure: (
event: PluginHookBeforeSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
) =>
| Promise<PluginHookBeforeSearchProviderConfigureResult | void>
| PluginHookBeforeSearchProviderConfigureResult
| void;
after_search_provider_configure: (
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
) => Promise<void> | void;
after_search_provider_activate: (
event: PluginHookAfterSearchProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
) => Promise<void> | void;
llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
llm_output: (
event: PluginHookLlmOutputEvent,