Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,53 @@ export {
toolRequiresApproval,
updateState,
} from './lib/conversation-state.js';
export { HooksManager } from './lib/hooks-manager.js';
// Hooks system
export { matchesTool } from './lib/hooks-matchers.js';
export { resolveHooks } from './lib/hooks-resolve.js';
export {
BUILT_IN_HOOK_NAMES,
BUILT_IN_HOOKS,
PermissionRequestPayloadSchema,
PermissionRequestResultSchema,
PostToolUseFailurePayloadSchema,
PostToolUsePayloadSchema,
PreToolUsePayloadSchema,
PreToolUseResultSchema,
SessionEndPayloadSchema,
SessionStartPayloadSchema,
StopPayloadSchema,
StopResultSchema,
UserPromptSubmitPayloadSchema,
UserPromptSubmitResultSchema,
} from './lib/hooks-schemas.js';
export type {
AsyncOutput,
BuiltInHookDefinitions,
EmitResult,
HookDefinition,
HookEntry,
HookHandler,
HookRegistry,
HookReturn,
HooksManagerOptions,
InlineHookConfig,
LifecycleHookContext,
PermissionRequestPayload,
PermissionRequestResult,
PostToolUseFailurePayload,
PostToolUsePayload,
PreToolUsePayload,
PreToolUseResult,
SessionEndPayload,
SessionStartPayload,
StopPayload,
StopResult,
ToolMatcher,
UserPromptSubmitPayload,
UserPromptSubmitResult,
} from './lib/hooks-types.js';
export { HookName } from './lib/hooks-types.js';
export type { GetResponseOptions } from './lib/model-result.js';
export { ModelResult } from './lib/model-result.js';
// Next turn params helpers
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/inner-loop/call-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core';
import type { RequestOptions } from '@openrouter/sdk/lib/sdks';
import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core';
import type { CallModelInput } from '../lib/async-params.js';
import { resolveHooks } from '../lib/hooks-resolve.js';
import type { GetResponseOptions } from '../lib/model-result.js';
import { ModelResult } from '../lib/model-result.js';
import { convertToolsToAPIFormat } from '../lib/tool-executor.js';
Expand Down Expand Up @@ -91,6 +92,7 @@ export function callModel<
sharedContextSchema,
onTurnStart,
onTurnEnd,
hooks,
...apiRequest
} = request;

Expand Down Expand Up @@ -152,5 +154,8 @@ export function callModel<
...(onTurnEnd !== undefined && {
onTurnEnd,
}),
...(hooks !== undefined && {
hooks: resolveHooks(hooks),
}),
} as GetResponseOptions<TTools, TShared>);
}
5 changes: 5 additions & 0 deletions packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type * as models from '@openrouter/sdk/models';
import type { OpenResponsesResult } from '@openrouter/sdk/models';
import type { HooksManager } from './hooks-manager.js';
import type { InlineHookConfig } from './hooks-types.js';
import type { Item } from './item-types.js';
import type { ContextInput } from './tool-context.js';
import type {
Expand Down Expand Up @@ -80,6 +82,8 @@ type BaseCallModelInput<
* Receives the turn context and the completed response for that turn
*/
onTurnEnd?: (context: TurnContext, response: OpenResponsesResult) => void | Promise<void>;
/** Hook system for lifecycle events. Accepts inline config or a HooksManager instance. */
hooks?: InlineHookConfig | HooksManager;
};

/**
Expand Down Expand Up @@ -180,6 +184,7 @@ export async function resolveAsyncFunctions<TTools extends readonly Tool[] = rea
'sharedContextSchema', // Client-side schema for shared context validation
'onTurnStart', // Client-side turn start callback
'onTurnEnd', // Client-side turn end callback
'hooks', // Client-side hook system
]);

// Iterate over all keys in the input
Expand Down
293 changes: 293 additions & 0 deletions packages/agent/src/lib/hooks-emit.ts
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)
Comment thread
mattapperson marked this conversation as resolved.
? ({
...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++) {
Comment thread
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);
Comment thread
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);
Comment thread
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;
}
Loading
Loading