-
Notifications
You must be signed in to change notification settings - Fork 169
fix(plugins): let callbacks opt out of short-circuit via shortCircuit: false #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,13 @@ import {BasePlugin, ContextCompactionTrigger} from './base_plugin.js'; | |
| * for that specific event is halted, and the returned value is propagated up | ||
| * the call stack. This allows plugins to short-circuit operations like agent | ||
| * runs, tool calls, or model requests. | ||
| * | ||
| * A plugin can opt out of the early exit by returning a payload that includes | ||
| * a literal `shortCircuit: false` field; in that case the remaining fields | ||
| * (with `shortCircuit` stripped) are forwarded to the next plugin as the new | ||
| * value of the transformed input field, and execution continues. Returning a | ||
| * payload with `shortCircuit: true` (or omitting the field entirely) preserves | ||
| * the legacy early-exit behavior. | ||
| */ | ||
| export class PluginManager { | ||
| private readonly plugins: Set<BasePlugin> = new Set(); | ||
|
|
@@ -83,35 +90,63 @@ export class PluginManager { | |
| * Runs the same callback for all plugins. This is a utility method to reduce | ||
| * duplication below. | ||
| * | ||
| * Plugins receive the latest transformed value (via `latest`); the caller is | ||
| * responsible for splicing it into the right field of the plugin params. If | ||
| * no prior plugin produced a value, `latest` is `undefined` and the caller | ||
| * should fall back to the original input. | ||
| * | ||
| * A plugin return value of `undefined` continues the chain unchanged. A | ||
| * return value with `shortCircuit: false` strips the flag and forwards the | ||
| * remaining fields to subsequent plugins. Any other return value short- | ||
| * circuits the chain; a `shortCircuit` flag, if present, is stripped before | ||
| * the value is returned. | ||
| * | ||
| * @param plugins The set of plugins to run | ||
| * @param callback A closure containing the callback method to run on each | ||
| * plugin | ||
| * plugin. Receives the latest transformed value, or `undefined`. | ||
| * @param callbackName The name of the function being called in the closure | ||
| * above. Used for logging purposes. | ||
| * @returns A promise containing the plugin method result. Must be casted to | ||
| * the proper type for the plugin method. | ||
| */ | ||
| private async runCallbacks( | ||
| plugins: Set<BasePlugin>, | ||
| callback: (plugin: BasePlugin) => Promise<unknown>, | ||
| callback: (plugin: BasePlugin, latest: unknown) => Promise<unknown>, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new signature (accepting Other callbacks that had their types updated in |
||
| callbackName: string, | ||
| ): Promise<unknown> { | ||
| let current: unknown = undefined; | ||
| for (const plugin of plugins) { | ||
| try { | ||
| const result = await callback(plugin); | ||
| if (result !== undefined) { | ||
| logger.debug( | ||
| `Plugin '${plugin.name}' returned a value for callback '${callbackName}', exiting early.`, | ||
| ); | ||
| return result; | ||
| const result = await callback(plugin, current); | ||
| if (result === undefined) continue; | ||
|
|
||
| let value: unknown = result; | ||
| if ( | ||
| typeof result === 'object' && | ||
| result !== null && | ||
| 'shortCircuit' in result | ||
| ) { | ||
| const {shortCircuit = true, ...data} = result as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| value = data; | ||
| if (shortCircuit === false) { | ||
| current = data; | ||
| continue; | ||
| } | ||
| } | ||
| logger.debug( | ||
| `Plugin '${plugin.name}' returned a value for callback '${callbackName}', exiting early.`, | ||
| ); | ||
| return value; | ||
| } catch (e) { | ||
| const errorMessage = `Error in plugin '${plugin.name}' during '${callbackName}' callback: ${e}`; | ||
| logger.error(errorMessage); | ||
| throw new Error(errorMessage); | ||
| } | ||
| } | ||
| return undefined; | ||
| return current; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -126,8 +161,11 @@ export class PluginManager { | |
| }): Promise<Content | undefined> { | ||
| return (await this.runCallbacks( | ||
| this.plugins, | ||
| (plugin: BasePlugin) => | ||
| plugin.onUserMessageCallback({userMessage, invocationContext}), | ||
| (plugin: BasePlugin, latest: unknown) => | ||
| plugin.onUserMessageCallback({ | ||
| userMessage: (latest as Content) ?? userMessage, | ||
| invocationContext, | ||
| }), | ||
| 'onUserMessageCallback', | ||
| )) as Content | undefined; | ||
| } | ||
|
|
@@ -174,8 +212,11 @@ export class PluginManager { | |
| }): Promise<Event | undefined> { | ||
| return (await this.runCallbacks( | ||
| this.plugins, | ||
| (plugin: BasePlugin) => | ||
| plugin.onEventCallback({invocationContext, event}), | ||
| (plugin: BasePlugin, latest: unknown) => | ||
| plugin.onEventCallback({ | ||
| invocationContext, | ||
| event: (latest as Event) ?? event, | ||
| }), | ||
| 'onEventCallback', | ||
| )) as Event | undefined; | ||
| } | ||
|
|
@@ -228,8 +269,11 @@ export class PluginManager { | |
| }): Promise<Readonly<Record<string, BaseTool>> | undefined> { | ||
| return (await this.runCallbacks( | ||
| this.plugins, | ||
| (plugin: BasePlugin) => | ||
| plugin.beforeToolSelection({callbackContext, tools}), | ||
| (plugin: BasePlugin, latest: unknown) => | ||
| plugin.beforeToolSelection({ | ||
| callbackContext, | ||
| tools: (latest as Readonly<Record<string, BaseTool>>) ?? tools, | ||
| }), | ||
| 'beforeToolSelection', | ||
| )) as Readonly<Record<string, BaseTool>> | undefined; | ||
| } | ||
|
|
@@ -306,8 +350,13 @@ export class PluginManager { | |
| }): Promise<Record<string, unknown> | undefined> { | ||
| return (await this.runCallbacks( | ||
| this.plugins, | ||
| (plugin: BasePlugin) => | ||
| plugin.afterToolCallback({tool, toolArgs, toolContext, result}), | ||
| (plugin: BasePlugin, latest: unknown) => | ||
| plugin.afterToolCallback({ | ||
| tool, | ||
| toolArgs, | ||
| toolContext, | ||
| result: (latest as Record<string, unknown>) ?? result, | ||
| }), | ||
| 'afterToolCallback', | ||
| )) as Record<string, unknown> | undefined; | ||
| } | ||
|
|
@@ -362,8 +411,11 @@ export class PluginManager { | |
| }): Promise<LlmResponse | undefined> { | ||
| return (await this.runCallbacks( | ||
| this.plugins, | ||
| (plugin: BasePlugin) => | ||
| plugin.afterModelCallback({callbackContext, llmResponse}), | ||
| (plugin: BasePlugin, latest: unknown) => | ||
| plugin.afterModelCallback({ | ||
| callbackContext, | ||
| llmResponse: (latest as LlmResponse) ?? llmResponse, | ||
| }), | ||
| 'afterModelCallback', | ||
| )) as LlmResponse | undefined; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: Immediately following this method (starting at line 418), there is a pre-existing unclosed comment block (
/**without closing*/). This causes the compiler to treat the entire block up to line 441 as a comment, silently commenting out a duplicate/broken method definition in between. We should clean this up while we are modifying this file.