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 src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,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,
HookContext,
HookDefinition,
HookEntry,
HookHandler,
HookRegistry,
HookReturn,
HooksManagerOptions,
InlineHookConfig,
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 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 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
144 changes: 144 additions & 0 deletions src/lib/hooks-emit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { matchesTool } from './hooks-matchers.js';
import type { AsyncOutput, EmitResult, HookContext, HookEntry } 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;
}

/**
* Execute a chain of hook handlers sequentially.
*
* Supports:
* - ToolMatcher and filter-based skipping
* - Sync results collected into `results`
* - Async fire-and-forget via `{ async: true }` return
* - Mutation piping (mutatedInput -> toolInput, mutatedPrompt -> prompt)
* - Short-circuit on block/reject fields
*/
export async function executeHandlerChain<P, R>(
entries: ReadonlyArray<HookEntry<P, R>>,
initialPayload: P,
context: HookContext,
options: ExecuteChainOptions,
): Promise<EmitResult<R, P>> {
const results: R[] = [];
const pending: Promise<void>[] = [];
let currentPayload = {
...initialPayload,
} as P;
let blocked = false;

const blockField = BLOCK_FIELDS[options.hookName];
const canBlock = BLOCK_HOOKS.has(options.hookName);

for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry) {
continue;
}

// Matcher check for tool-scoped hooks
if (
entry.matcher !== undefined &&
options.toolName !== undefined &&
!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
if (isAsyncOutput(returnValue)) {
const asyncOutput = returnValue as AsyncOutput;
const timeout = asyncOutput.asyncTimeout ?? DEFAULT_ASYNC_TIMEOUT;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

const asyncPromise = Promise.resolve().then(() => {
clearTimeout(timeoutId);
});
pending.push(asyncPromise);
continue;
}

// Void / undefined -- side-effect only, continue
if (returnValue === undefined || returnValue === null) {
continue;
}

const result = returnValue as R;
results.push(result);

// Apply mutation piping
currentPayload = applyMutations(currentPayload, result);

// 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,
};
}

/**
* Apply mutation fields from a result onto the current payload.
*/
function applyMutations<P, R>(payload: P, result: R): P {
if (typeof result !== 'object' || result === null) {
return payload;
}

let mutated = payload;
for (const [resultField, payloadField] of Object.entries(MUTATION_FIELD_MAP)) {
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.
*/
function isBlockTriggered<R>(result: R, blockField: string): boolean {
if (typeof result !== 'object' || result === null) {
return false;
}
const value = (result as Record<string, unknown>)[blockField];
return value === true || typeof value === 'string';
}
Loading
Loading