feat: generalize plugin provider capabilities and hooks

This commit is contained in:
Tak Hoffman
2026-03-12 00:15:17 -05:00
parent 9cffd72953
commit 5d0012471c
8 changed files with 388 additions and 48 deletions

View File

@@ -451,6 +451,13 @@ describe("setupSearch", () => {
},
],
typedHooks: [
{
pluginId: "tavily-search",
hookName: "before_provider_configure",
priority: 10,
source: "/tmp/tavily-search",
handler: () => ({ note: "Generic provider guidance." }),
},
{
pluginId: "tavily-search",
hookName: "before_search_provider_configure",
@@ -473,7 +480,7 @@ describe("setupSearch", () => {
expect.arrayContaining([
expect.objectContaining({
title: "Provider setup",
message: "Read the provider docs before entering your key.",
message: "Generic provider guidance.\n\nRead the provider docs before entering your key.",
}),
]),
);
@@ -481,6 +488,7 @@ describe("setupSearch", () => {
it("fires after_search_provider_activate only when the active provider changes", async () => {
const afterActivate = vi.fn();
const afterProviderActivate = vi.fn();
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
@@ -506,6 +514,13 @@ describe("setupSearch", () => {
},
],
typedHooks: [
{
pluginId: "tavily-search",
hookName: "after_provider_activate",
priority: 0,
source: "/tmp/tavily-search",
handler: afterProviderActivate,
},
{
pluginId: "tavily-search",
hookName: "after_search_provider_activate",
@@ -547,6 +562,19 @@ describe("setupSearch", () => {
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(afterActivate).toHaveBeenCalledTimes(1);
expect(afterProviderActivate).toHaveBeenCalledTimes(1);
expect(afterProviderActivate).toHaveBeenCalledWith(
expect.objectContaining({
providerKind: "search",
slot: "providers.search",
providerId: "tavily",
previousProviderId: "brave",
intent: "switch-active",
}),
expect.objectContaining({
workspaceDir: undefined,
}),
);
expect(afterActivate).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "tavily",

View File

@@ -330,25 +330,48 @@ async function maybeNoteBeforeSearchProviderConfigure(params: {
prompter: WizardPrompter;
workspaceDir?: string;
}): Promise<void> {
if (!params.hookRunner?.hasHooks("before_search_provider_configure")) {
if (
!params.hookRunner?.hasHooks("before_provider_configure") &&
!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");
const activeProviderId =
resolveCapabilitySlotSelection(params.config, "providers.search") ?? null;
const ctx = { workspaceDir: params.workspaceDir };
const genericResult = params.hookRunner.hasHooks("before_provider_configure")
? await params.hookRunner.runBeforeProviderConfigure(
{
providerKind: "search",
slot: "providers.search",
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
intent: params.intent,
activeProviderId,
configured: params.provider.configured,
},
ctx,
)
: undefined;
const searchResult = params.hookRunner.hasHooks("before_search_provider_configure")
? await params.hookRunner.runBeforeSearchProviderConfigure(
{
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
intent: params.intent,
activeProviderId,
configured: params.provider.configured,
},
ctx,
)
: undefined;
const note = [genericResult?.note, searchResult?.note].filter(hasNonEmptyString).join("\n\n");
if (note.trim()) {
await params.prompter.note(note, "Provider setup");
}
}
@@ -368,41 +391,65 @@ async function runAfterSearchProviderHooks(params: {
const activeProviderAfter =
resolveCapabilitySlotSelection(params.resultConfig, "providers.search") ?? null;
const ctx = { workspaceDir: params.workspaceDir };
const genericConfigureEvent = {
providerKind: "search" as const,
slot: "providers.search",
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,
};
const searchConfigureEvent = {
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,
};
if (params.hookRunner.hasHooks("after_provider_configure")) {
await params.hookRunner.runAfterProviderConfigure(genericConfigureEvent, ctx);
}
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,
},
);
await params.hookRunner.runAfterSearchProviderConfigure(searchConfigureEvent, ctx);
}
if (
activeProviderAfter === params.provider.providerId &&
activeProviderBefore !== activeProviderAfter &&
params.hookRunner.hasHooks("after_search_provider_activate")
(params.hookRunner.hasHooks("after_provider_activate") ||
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,
},
);
const genericActivateEvent = {
providerKind: "search" as const,
slot: "providers.search",
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
previousProviderId: activeProviderBefore,
intent: params.intent,
};
const searchActivateEvent = {
providerId: params.provider.providerId,
providerLabel: params.provider.providerLabel,
providerSource: params.provider.providerSource,
pluginId: params.provider.pluginId,
previousProviderId: activeProviderBefore,
intent: params.intent,
};
if (params.hookRunner.hasHooks("after_provider_activate")) {
await params.hookRunner.runAfterProviderActivate(genericActivateEvent, ctx);
}
if (params.hookRunner.hasHooks("after_search_provider_activate")) {
await params.hookRunner.runAfterSearchProviderActivate(searchActivateEvent, ctx);
}
}
}

View File

@@ -334,6 +334,29 @@ describe("config plugin validation", () => {
}
});
it("routes missing slot selection diagnostics to the slot config path", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
slots: { memory: "missing-memory-backend" },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.slots.memory",
message: "plugin not found: missing-memory-backend",
});
expect(res.warnings).toContainEqual({
path: "plugins.slots.memory",
message:
"plugin: memory slot plugin not found or not marked as memory: missing-memory-backend",
});
}
});
it("surfaces allowed enum values for plugin config diagnostics", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },

View File

@@ -49,7 +49,7 @@ function resolvePluginDiagnosticPath(diag: {
if (diag.message.includes("plugin path not found")) {
return "plugins.load.paths";
}
if (diag.code === "capability_slot_conflict" && diag.slot) {
if (diag.slot) {
return resolveCapabilitySlotConfigPath(diag.slot as CapabilitySlotId);
}
if (diag.pluginId) {

View File

@@ -10,6 +10,8 @@ import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterSearchProviderActivateEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterProviderActivateEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterCompactionEvent,
PluginHookAfterToolCallEvent,
PluginHookAgentContext,
@@ -18,6 +20,8 @@ import type {
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@@ -64,6 +68,8 @@ export type {
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@@ -104,6 +110,8 @@ export type {
PluginHookGatewayStopEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterSearchProviderActivateEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterProviderActivateEvent,
};
export type HookRunnerLogger = {
@@ -201,6 +209,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
}),
});
const mergeBeforeProviderConfigure = (
acc: PluginHookBeforeProviderConfigureResult | undefined,
next: PluginHookBeforeProviderConfigureResult,
): PluginHookBeforeProviderConfigureResult => ({
note: concatOptionalTextSegments({
left: acc?.note,
right: next.note,
}),
});
const handleHookError = (params: {
hookName: PluginHookName;
pluginId: string;
@@ -348,6 +366,32 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
>("before_search_provider_configure", event, ctx, mergeBeforeSearchProviderConfigure);
}
async function runBeforeProviderConfigure(
event: PluginHookBeforeProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<PluginHookBeforeProviderConfigureResult | undefined> {
return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>(
"before_provider_configure",
event,
ctx,
mergeBeforeProviderConfigure,
);
}
async function runAfterProviderConfigure(
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_provider_configure", event, ctx);
}
async function runAfterProviderActivate(
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_provider_activate", event, ctx);
}
async function runAfterSearchProviderConfigure(
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
@@ -771,8 +815,11 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runBeforeModelResolve,
runBeforePromptBuild,
runBeforeAgentStart,
runBeforeProviderConfigure,
runBeforeSearchProviderConfigure,
runAfterProviderConfigure,
runAfterSearchProviderConfigure,
runAfterProviderActivate,
runAfterSearchProviderActivate,
runLlmInput,
runLlmOutput,

View File

@@ -2256,4 +2256,84 @@ describe("loadOpenClawPlugins", () => {
expect.arrayContaining([expect.objectContaining({ id: "memory-b", status: "error" })]),
);
});
it("errors when a declared capability is not registered at runtime", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "search-manifest-mismatch",
body: `module.exports = { id: "search-manifest-mismatch", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-manifest-mismatch",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.beta"],
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "search-manifest-mismatch",
code: "capability_declared_not_registered",
capability: "providers.search.beta",
message: "declared capability was not registered at runtime: providers.search.beta",
}),
]),
);
});
it("warns when a runtime capability is not declared in the manifest", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "search-runtime-undeclared",
body: `module.exports = { id: "search-runtime-undeclared", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-runtime-undeclared",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
pluginId: "search-runtime-undeclared",
code: "capability_registered_not_declared",
capability: "providers.search.alpha",
message: "runtime capability was not declared in manifest: providers.search.alpha",
}),
]),
);
});
it("emits a structured diagnostic when the configured memory slot is missing", () => {
useNoBundledPlugins();
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
slots: {
memory: "missing-memory-backend",
},
},
},
});
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
code: "capability_slot_selection_missing",
slot: "memory.backend",
capability: "missing-memory-backend",
message: "memory slot plugin not found or not marked as memory: missing-memory-backend",
}),
]),
);
});
});

View File

@@ -340,6 +340,10 @@ function collectDeclaredCapabilities(plugin: PluginRecord): Set<string> {
return new Set([...plugin.declaredCapabilities, ...plugin.capabilityIds]);
}
function collectCapabilityIds(plugin: PluginRecord): Set<string> {
return new Set(plugin.capabilityIds);
}
function evaluateCapabilityRelationships(params: {
activePlugins: PluginRecord[];
candidatePlugin?: PluginRecord;
@@ -417,6 +421,44 @@ function evaluateCapabilityRelationships(params: {
return diagnostics;
}
function evaluateCapabilityDeclarationAlignment(plugin: PluginRecord): PluginDiagnostic[] {
const diagnostics: PluginDiagnostic[] = [];
const declaredCapabilities = new Set(plugin.declaredCapabilities);
const runtimeCapabilities = collectCapabilityIds(plugin);
for (const capability of declaredCapabilities) {
if (runtimeCapabilities.has(capability)) {
continue;
}
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_declared_not_registered",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `declared capability was not registered at runtime: ${capability}`,
});
}
for (const capability of runtimeCapabilities) {
if (declaredCapabilities.has(capability)) {
continue;
}
diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
code: "capability_registered_not_declared",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `runtime capability was not declared in manifest: ${capability}`,
});
}
return diagnostics;
}
function recordPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
@@ -987,6 +1029,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
message: "plugin register returned a promise; async registration is ignored",
});
}
pushDiagnostics(registry.diagnostics, evaluateCapabilityDeclarationAlignment(record));
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
@@ -1007,6 +1050,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (typeof memorySlot === "string" && !memorySlotMatched) {
registry.diagnostics.push({
level: "warn",
code: "capability_slot_selection_missing",
slot: "memory.backend",
capability: memorySlot,
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
});
}

View File

@@ -487,9 +487,12 @@ export type PluginDiagnostic = {
source?: string;
code?:
| "capability_declared_duplicate"
| "capability_declared_not_registered"
| "capability_missing_requirement"
| "capability_conflict_present"
| "capability_slot_conflict";
| "capability_slot_conflict"
| "capability_registered_not_declared"
| "capability_slot_selection_missing";
capability?: string;
slot?: string;
};
@@ -502,8 +505,11 @@ export type PluginHookName =
| "before_model_resolve"
| "before_prompt_build"
| "before_agent_start"
| "before_provider_configure"
| "before_search_provider_configure"
| "after_provider_configure"
| "after_search_provider_configure"
| "after_provider_activate"
| "after_search_provider_activate"
| "llm_input"
| "llm_output"
@@ -531,8 +537,11 @@ export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
"before_prompt_build",
"before_agent_start",
"before_provider_configure",
"before_search_provider_configure",
"after_provider_configure",
"after_search_provider_configure",
"after_provider_activate",
"after_search_provider_activate",
"llm_input",
"llm_output",
@@ -678,8 +687,30 @@ export type PluginHookSearchProviderContext = {
workspaceDir?: string;
};
export type PluginHookProviderLifecycleContext = {
workspaceDir?: string;
};
export type PluginHookSearchProviderSource = "builtin" | "plugin";
export type PluginHookProviderKind = "search";
export type PluginHookBeforeProviderConfigureEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookBeforeProviderConfigureResult = {
note?: string;
};
export type PluginHookBeforeSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
@@ -694,6 +725,18 @@ export type PluginHookBeforeSearchProviderConfigureResult = {
note?: string;
};
export type PluginHookAfterProviderConfigureEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookAfterSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
@@ -704,6 +747,17 @@ export type PluginHookAfterSearchProviderConfigureEvent = {
configured: boolean;
};
export type PluginHookAfterProviderActivateEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
previousProviderId?: string | null;
intent: "switch-active" | "configure-provider";
};
export type PluginHookAfterSearchProviderActivateEvent = {
providerId: string;
providerLabel: string;
@@ -1026,6 +1080,13 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
before_provider_configure: (
event: PluginHookBeforeProviderConfigureEvent,
ctx: PluginHookProviderLifecycleContext,
) =>
| Promise<PluginHookBeforeProviderConfigureResult | void>
| PluginHookBeforeProviderConfigureResult
| void;
before_search_provider_configure: (
event: PluginHookBeforeSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
@@ -1033,10 +1094,18 @@ export type PluginHookHandlerMap = {
| Promise<PluginHookBeforeSearchProviderConfigureResult | void>
| PluginHookBeforeSearchProviderConfigureResult
| void;
after_provider_configure: (
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
after_search_provider_configure: (
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
) => Promise<void> | void;
after_provider_activate: (
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
after_search_provider_activate: (
event: PluginHookAfterSearchProviderActivateEvent,
ctx: PluginHookSearchProviderContext,