fix(plugins): let callbacks opt out of short-circuit via shortCircuit: false#343
Open
tSte wants to merge 2 commits into
Open
fix(plugins): let callbacks opt out of short-circuit via shortCircuit: false#343tSte wants to merge 2 commits into
tSte wants to merge 2 commits into
Conversation
…t: false`
## Problem
`PluginManager.runCallbacks` short-circuits the plugin chain as soon as
a callback returns a non-`undefined` value, which silently disables
every plugin registered after a plugin that legitimately transforms a
value (localization, redaction, content filtering). Logging, metrics
and other observer-style plugins never get a chance to run.
## Change
Adds an opt-out: if a callback returns an object whose literal
`shortCircuit` field is `false`, the flag is stripped, the remaining
fields become the new *current* value, and the chain continues. The
next plugin receives that value spliced into the right input field
(`result` / `llmResponse` / `event` / `userMessage` / `tools`), so
transforms genuinely compose.
Any other non-`undefined` return preserves the legacy early-exit
behavior, so existing plugins are unaffected.
`BasePlugin` return types for `Content` / `Event` / `LlmResponse` /
tools-map are widened with \`& { shortCircuit?: boolean }\` so overrides
type-check under strict mode without casts. \`Record<string, unknown>\`-
typed callbacks need no change.
Closes google#342
- Rename ShortCircuit to ShortCircuitControl and make `shortCircuit` optional, so a plugin returning a plain Content/Event/LlmResponse still satisfies the callback return type (the required field was a breaking change for existing plugins). - runCallbacks defaults a missing flag to true and strips `shortCircuit` from the value on every path, including the short-circuit path. - afterToolCallback return type carries ShortCircuitControl, matching the other value-returning callbacks it is threaded alongside.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Problem:
PluginManager.runCallbacksshort-circuits the plugin chain as soon as a callback returns a non-undefinedvalue. A plugin that legitimately transforms a value (localization rewritingLlmResponse, redaction altering a tool result, content filtering) silently disables every plugin registered after it for that callback — logging, metrics, tracing, safety auditing, etc. The failure is silent (only alogger.debugline is emitted) and there is currently no way for a plugin to express "I transformed this, keep going."The
BasePlugindocstring already documents the intended pipeline semantic ("The modifications will be visible and passed to the next callback in the chain"), but the implementation inPluginManager.runCallbacksdoes not match it.Solution: Add an opt-out path. If a callback returns an object whose literal
shortCircuitfield isfalse, the flag is stripped, the remaining fields become the new current value, and the chain continues. The next plugin receives that value spliced into the appropriate input field (result/llmResponse/event/userMessage/tools), so transforms genuinely compose. Any other non-undefinedreturn preserves the legacy early-exit behavior, so existing plugins are unaffected.Example:
BasePluginreturn types forContent/Event/LlmResponse/ tools-map are widened with& { shortCircuit?: boolean }so overrides type-check under strict mode without casts.Record<string, unknown>-typed callbacks (beforeTool/afterTool/onToolError) need no widening.Testing Plan
Unit Tests:
plugin_manager_test.tscases pass unchanged (legacy short-circuit semantics preserved).{ shortCircuit: false, ...x }keeps plugin2 running and strips the flag from the final result.resultparam (pipeline).Manual End-to-End (E2E) Tests:
Verified against a real voice-agent setup with a
ResponseMessagePlugin(localizesresult.message) registered before aLoggingPlugin(logs the final tool result). Before the fix, the logging plugin was never invoked for any tool that produced a localized message. After the fix, the logging plugin runs and logs the translated message, confirming both the opt-out and the pipeline plumbing.Checklist
Additional context
The change conflates two distinct intents that the current short-circuit logic merges:
beforeModelCallbackreturning a cachedLlmResponseto skip the model call. Early exit is correct.afterModelCallbacktranslating the response. Early exit is harmful; the next plugin should see the transformed value.The
shortCircuit: falseopt-out lets callers express the second intent without changing the default behavior of the first. All short-circuiting callbacks are affected:onUserMessageCallback,beforeRunCallback,onEventCallback,beforeAgentCallback,afterAgentCallback,beforeToolSelection,beforeToolCallback,afterToolCallback,onModelErrorCallback,beforeModelCallback,afterModelCallback,onToolErrorCallback.