-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add HooksManager lifecycle hook system to callModel #7
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
Open
mattapperson
wants to merge
10
commits into
main
Choose a base branch
from
feat/hooks-manager
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2982107
feat: add HooksManager lifecycle hook system to callModel
mattapperson c4c525d
test: add adversarial unit tests for hooks system
mattapperson 35133a0
Merge pull request #8 from OpenRouterTeam/adversarial-hooks-tests
mattapperson a081d80
Merge branch 'main' into feat/hooks-manager
mattapperson 3fa8730
style: apply Biome formatting and import ordering
mattapperson 2461a41
Merge branch 'main' into feat/hooks-manager
mattapperson a5177c5
fix(hooks): address PR review feedback across hooks system
mattapperson f436a80
refactor(hooks): rename HookContext to LifecycleHookContext and harde…
mattapperson d1acead
fix(hooks): tighten engine correctness across review feedback
mattapperson fe319a2
fix(hooks): harden engine around review feedback
mattapperson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,293 @@ | ||
| import type { $ZodType } from 'zod/v4/core'; | ||
| import { safeParse } from 'zod/v4/core'; | ||
| import { matchesTool } from './hooks-matchers.js'; | ||
| import type { AsyncOutput, EmitResult, HookEntry, LifecycleHookContext } from './hooks-types.js'; | ||
| import { | ||
| BLOCK_FIELDS, | ||
| BLOCK_HOOKS, | ||
| DEFAULT_ASYNC_TIMEOUT, | ||
| isAsyncOutput, | ||
| MUTATION_FIELD_MAP, | ||
| } from './hooks-types.js'; | ||
|
|
||
| export interface ExecuteChainOptions { | ||
| readonly hookName: string; | ||
| readonly throwOnHandlerError: boolean; | ||
| readonly toolName?: string | undefined; | ||
| /** | ||
| * Optional Zod schema to validate each handler's result BEFORE the chain | ||
| * applies mutation piping or short-circuit logic. Validation errors are | ||
| * handled the same way as handler throws: either re-thrown (strict mode) or | ||
| * logged as a warning (default). | ||
| * | ||
| * Void-typed hooks typically pass `undefined` here; results in that case are | ||
| * not validated. | ||
| */ | ||
| readonly resultSchema?: $ZodType | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true for non-null, non-array plain objects -- values where | ||
| * object-spread cloning is safe. Custom hooks can register payload schemas | ||
| * like `z.number()`, `z.string()`, or `z.array(...)`; spreading a primitive | ||
| * silently produces `{}` and spreading an array reindexes it into an object, | ||
| * which would hand handlers a mangled value. This mirrors the invariant that | ||
| * `applyMutations` relies on when deciding whether mutation piping can run. | ||
| */ | ||
| function isPlainMutableObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| /** | ||
| * Execute a chain of hook handlers sequentially. | ||
| * | ||
| * Supports: | ||
| * - ToolMatcher and filter-based skipping (matcher fails closed: a handler with | ||
| * a matcher and no `options.toolName` is skipped) | ||
| * - Sync results validated against `options.resultSchema` and collected into `results` | ||
| * - Async fire-and-forget via a returned {@link AsyncOutput} -- the handler's | ||
| * `work` promise is pushed to `pending` without being awaited; the manager is | ||
| * responsible for draining/timing out that work | ||
| * - Per-hook mutation piping (driven by {@link MUTATION_FIELD_MAP}) | ||
| * - Short-circuit on block/reject fields (non-empty string or `true`) | ||
| * - Cooperative abort via `context.signal`: the chain checks | ||
| * `signal.aborted` between handlers and bails out when set so | ||
| * `abortInflight()` has a deterministic effect on the chain itself (not just | ||
| * on handlers that happen to consult the signal). | ||
| * | ||
| * Payload isolation: the initial payload is shallow-cloned before entering the | ||
| * chain so mutation piping (via {@link MUTATION_FIELD_MAP}) doesn't mutate the | ||
| * caller's payload at the top level. This is a top-level-only guarantee: | ||
| * handlers that directly mutate a nested object (e.g. | ||
| * `payload.toolInput.foo = 'bar'`) still reach the caller's original nested | ||
| * reference. Handlers MUST return mutations via the documented result fields | ||
| * (e.g. `mutatedInput`) rather than mutating nested payload fields in place. | ||
| */ | ||
| export async function executeHandlerChain<P, R>( | ||
| entries: ReadonlyArray<HookEntry<P, R>>, | ||
| initialPayload: P, | ||
| context: LifecycleHookContext, | ||
| options: ExecuteChainOptions, | ||
| ): Promise<EmitResult<R, P>> { | ||
| const results: R[] = []; | ||
| const pending: Promise<void>[] = []; | ||
| // Only clone when the payload is a plain mutable object. Spreading a | ||
| // primitive (e.g. a custom hook's `z.number()` payload) would silently | ||
| // produce `{}`; spreading an array reindexes it into an object. For those | ||
| // cases we pass the value through untouched so handlers see the original | ||
| // typed-`P` value. For plain objects we still clone so the chain can apply | ||
| // mutation piping without mutating the caller's payload at the top level. | ||
| // Nested fields are shared with the caller; see the function-level docstring | ||
| // for the invariant handlers are expected to respect. | ||
| let currentPayload: P = isPlainMutableObject(initialPayload) | ||
| ? ({ | ||
| ...initialPayload, | ||
| } as P) | ||
| : initialPayload; | ||
| let blocked = false; | ||
|
|
||
| const blockField = BLOCK_FIELDS[options.hookName]; | ||
| const canBlock = BLOCK_HOOKS.has(options.hookName); | ||
| const mutationMap = MUTATION_FIELD_MAP[options.hookName]; | ||
|
|
||
| for (let i = 0; i < entries.length; i++) { | ||
|
mattapperson marked this conversation as resolved.
|
||
| // Cooperative abort: bail out of the chain when abortInflight() has fired. | ||
| // Checked before each handler so synchronous chains stop promptly instead | ||
| // of running to completion with only advisory notice to each handler. | ||
| if (context.signal.aborted) { | ||
| break; | ||
| } | ||
|
|
||
| const entry = entries[i]; | ||
| if (!entry) { | ||
| continue; | ||
| } | ||
|
|
||
| // Matcher check for tool-scoped hooks. Matchers fail closed: if a matcher | ||
| // is registered and no toolName is available for this emit, we skip the | ||
| // handler rather than invoking it globally. | ||
| if (entry.matcher !== undefined) { | ||
| if (options.toolName === undefined) { | ||
| continue; | ||
| } | ||
| if (!matchesTool(entry.matcher, options.toolName)) { | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| // Filter check | ||
| if (entry.filter && !entry.filter(currentPayload)) { | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| const returnValue = await entry.handler(currentPayload, context); | ||
|
|
||
| // Async fire-and-forget: the handler has returned a signal describing | ||
| // detached work. Track the (optional) work promise for drain/timeout. | ||
| if (isAsyncOutput(returnValue)) { | ||
| const asyncOutput: AsyncOutput = returnValue; | ||
| const trackedWork = trackAsyncWork(asyncOutput, options.hookName); | ||
| if (trackedWork !== undefined) { | ||
| pending.push(trackedWork); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| // Void / undefined / null -- side-effect only, continue | ||
| if (returnValue === undefined || returnValue === null) { | ||
| continue; | ||
| } | ||
|
|
||
| // Validate the result against the schema if one is supplied. A failure | ||
| // here is treated like any other handler error: propagated in strict | ||
| // mode, logged otherwise. On success, use the parsed output (which may | ||
| // differ from the input for schemas with .transform() / .default() / | ||
| // .catch() / .coerce) so downstream callers see transformed values. | ||
| let result: R; | ||
| if (options.resultSchema) { | ||
| const validation = safeParse(options.resultSchema, returnValue); | ||
| if (!validation.success) { | ||
| const err = new Error( | ||
| `[HooksManager] Handler ${i} for hook "${options.hookName}" returned an invalid result: ${validation.error.message}`, | ||
| ); | ||
| if (options.throwOnHandlerError) { | ||
| throw err; | ||
| } | ||
| console.warn(err.message); | ||
| continue; | ||
| } | ||
| result = validation.data as R; | ||
| } else { | ||
| result = returnValue as R; | ||
| } | ||
| results.push(result); | ||
|
mattapperson marked this conversation as resolved.
|
||
|
|
||
| // Apply mutation piping -- only hooks listed in MUTATION_FIELD_MAP participate. | ||
| if (mutationMap) { | ||
| currentPayload = applyMutations(currentPayload, result, mutationMap); | ||
| } | ||
|
|
||
| // Short-circuit on block | ||
| if (canBlock && blockField && isBlockTriggered(result, blockField)) { | ||
| blocked = true; | ||
| break; | ||
| } | ||
| } catch (error) { | ||
| if (options.throwOnHandlerError) { | ||
| throw error; | ||
| } | ||
| console.warn(`[HooksManager] Handler ${i} for hook "${options.hookName}" threw:`, error); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| results, | ||
| pending, | ||
| finalPayload: currentPayload, | ||
| blocked, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Given an {@link AsyncOutput} signal, return a Promise<void> that resolves | ||
| * when the handler's detached `work` settles OR the timeout fires -- whichever | ||
| * is first. Returns `undefined` if there is no work to track. | ||
| * | ||
| * Rejections of the detached `work` promise are logged as warnings. Note: | ||
| * `throwOnHandlerError` governs synchronous handler failures only -- detached | ||
| * fire-and-forget work never re-throws here, it just surfaces via the warning. | ||
| */ | ||
| function trackAsyncWork(output: AsyncOutput, hookName: string): Promise<void> | undefined { | ||
| if (output.work === undefined) { | ||
| return undefined; | ||
| } | ||
| const timeout = output.asyncTimeout ?? DEFAULT_ASYNC_TIMEOUT; | ||
|
|
||
| return new Promise<void>((resolve) => { | ||
| let settled = false; | ||
| const finish = () => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
| settled = true; | ||
| clearTimeout(timeoutId); | ||
| resolve(); | ||
| }; | ||
|
|
||
| const timeoutId = setTimeout(finish, timeout); | ||
|
mattapperson marked this conversation as resolved.
|
||
| // In Node.js, Timeout objects expose `.unref()` to remove the reference | ||
| // the timer holds on the event loop. Without this, a leaked hook whose | ||
| // `work` never settles keeps the process alive for the full | ||
| // DEFAULT_ASYNC_TIMEOUT window after the main workload has finished. | ||
| // Guarded for browsers / other environments that return a plain number | ||
| // from setTimeout and don't expose the method. | ||
| if (isUnrefable(timeoutId)) { | ||
| timeoutId.unref(); | ||
| } | ||
|
|
||
| output.work?.then(finish, (error: unknown) => { | ||
| console.warn(`[HooksManager] Async work for hook "${hookName}" rejected:`, error); | ||
| finish(); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Typeguard for the Node.js Timeout object that carries a `.unref()` method. | ||
| * Browsers and other environments return a number from setTimeout, which has | ||
| * no such method. | ||
| */ | ||
| function isUnrefable(handle: unknown): handle is { | ||
| unref: () => void; | ||
| } { | ||
| if (typeof handle !== 'object' || handle === null || !('unref' in handle)) { | ||
| return false; | ||
| } | ||
| const candidate: { | ||
| unref: unknown; | ||
| } = handle; | ||
| return typeof candidate.unref === 'function'; | ||
| } | ||
|
|
||
| /** | ||
| * Apply mutation fields from a result onto the current payload. | ||
| */ | ||
| function applyMutations<P, R>(payload: P, result: R, mutationMap: Record<string, string>): P { | ||
| if (typeof result !== 'object' || result === null) { | ||
| return payload; | ||
| } | ||
|
|
||
| let mutated = payload; | ||
| for (const [resultField, payloadField] of Object.entries(mutationMap)) { | ||
| if (resultField in result) { | ||
| const value = (result as Record<string, unknown>)[resultField]; | ||
| if (value !== undefined) { | ||
| mutated = { | ||
| ...mutated, | ||
| [payloadField]: value, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| return mutated; | ||
| } | ||
|
|
||
| /** | ||
| * Check if a result triggers a short-circuit block. | ||
| * | ||
| * A block fires when the field is `=== true` or a non-empty string. Empty | ||
| * strings are treated as "no block reason supplied" -- they do NOT trigger a | ||
| * short-circuit, which keeps emit consistent with callers that look up the | ||
| * first block reason with a truthy check. | ||
| */ | ||
| function isBlockTriggered<R>(result: R, blockField: string): boolean { | ||
| if (typeof result !== 'object' || result === null) { | ||
| return false; | ||
| } | ||
| const value = (result as Record<string, unknown>)[blockField]; | ||
| if (value === true) { | ||
| return true; | ||
| } | ||
| return typeof value === 'string' && value.length > 0; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.