diff --git a/apps/e2e/demo-e2e-hooks/e2e/hook-order.e2e.spec.ts b/apps/e2e/demo-e2e-hooks/e2e/hook-order.e2e.spec.ts new file mode 100644 index 000000000..e659fe86f --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/e2e/hook-order.e2e.spec.ts @@ -0,0 +1,52 @@ +/** E2E: hooks on one stage run lowest priority first, and only a dev-mode server logs the order they resolve to. */ +import { expect, test } from '@frontmcp/testing'; + +const SERVER = 'apps/e2e/demo-e2e-hooks/src/main.hook-order.ts'; +const ANSWERED = 'gate-order answered'; +const CALL_TOOL_ORDER = "Hook order of 'tools:call-tool' for 'hook-order'"; +const EXECUTE_ORDER = + ' execute: will FeatureGatePlugin.refuseDisabled (-10) → will SetupGatePlugin.requireSetup (10) → stage CallToolFlow.execute (0)'; + +const countOf = (logs: string, text: string) => logs.split(text).length - 1; + +/** The server's logs once they hold `text` `times` times: what a call logs can reach the spec after its answer. */ +async function logsWith(server: { getLogs(): string[] }, text: string, times: number): Promise { + const deadline = Date.now() + 10_000; + for (;;) { + const logs = server.getLogs().join('\n'); + if (countOf(logs, text) >= times) return logs; + if (Date.now() > deadline) throw new Error(`The server logged '${text}' fewer than ${times} times`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +test.describe('Hook order E2E', () => { + // DEBUG=1 (which also shows the test servers' output) turns the print on: keep it off whatever the runner sets + test.use({ server: SERVER, project: 'demo-e2e-hooks', publicMode: true, env: { NODE_ENV: 'test', DEBUG: '0' } }); + + test('runs the hooks of two plugins on one stage lowest priority first', async ({ mcp }) => { + const result = await mcp.tools.call('gate-order', {}); + + expect(result).toBeSuccessful(); + expect(result.json<{ ran: string[] }>().ran).toEqual(['feature-gate', 'setup-gate']); + }); + + test('logs no hook order outside dev mode', async ({ mcp, server }) => { + await mcp.tools.call('gate-order', {}); + + expect(countOf(await logsWith(server, ANSWERED, 1), 'Hook order of')).toBe(0); + }); +}); + +test.describe('Hook order E2E in dev mode', () => { + test.use({ server: SERVER, project: 'demo-e2e-hooks', publicMode: true, env: { NODE_ENV: 'development' } }); + + test('logs the resolved order of the stage once', async ({ mcp, server }) => { + await mcp.tools.call('gate-order', {}); + await mcp.tools.call('gate-order', {}); + + const logs = await logsWith(server, ANSWERED, 2); + expect(countOf(logs, CALL_TOOL_ORDER)).toBe(1); + expect(logs).toContain(EXECUTE_ORDER); + }); +}); diff --git a/apps/e2e/demo-e2e-hooks/src/apps/hook-order/data/gate-log.ts b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/data/gate-log.ts new file mode 100644 index 000000000..223430288 --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/data/gate-log.ts @@ -0,0 +1,16 @@ +/** + * The gates that ran before the current `gate-order` call, in the order they ran. + */ +class GateLog { + private ran: string[] = []; + + record(gate: string): void { + this.ran.push(gate); + } + + take(): string[] { + return this.ran.splice(0); + } +} + +export const gateLog = new GateLog(); diff --git a/apps/e2e/demo-e2e-hooks/src/apps/hook-order/index.ts b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/index.ts new file mode 100644 index 000000000..8d3c04e93 --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/index.ts @@ -0,0 +1,13 @@ +import { App } from '@frontmcp/sdk'; + +import FeatureGatePlugin from './plugins/feature-gate.plugin'; +import SetupGatePlugin from './plugins/setup-gate.plugin'; +import GateOrderTool from './tools/gate-order.tool'; + +@App({ + id: 'hook-order', + name: 'hook-order', + plugins: [SetupGatePlugin, FeatureGatePlugin], + tools: [GateOrderTool], +}) +export class HookOrderApp {} diff --git a/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/feature-gate.plugin.ts b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/feature-gate.plugin.ts new file mode 100644 index 000000000..50481befc --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/feature-gate.plugin.ts @@ -0,0 +1,12 @@ +import { Plugin, ToolHook } from '@frontmcp/sdk'; + +import { gateLog } from '../data/gate-log'; + +/** Meant to refuse a disabled feature before any other gate, so it has the lower priority. */ +@Plugin({ name: 'feature-gate', description: 'Records that the feature gate ran' }) +export default class FeatureGatePlugin { + @ToolHook.Will('execute', { priority: -10 }) + refuseDisabled() { + gateLog.record('feature-gate'); + } +} diff --git a/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/setup-gate.plugin.ts b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/setup-gate.plugin.ts new file mode 100644 index 000000000..cb6e07d4b --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/plugins/setup-gate.plugin.ts @@ -0,0 +1,12 @@ +import { Plugin, ToolHook } from '@frontmcp/sdk'; + +import { gateLog } from '../data/gate-log'; + +/** Listed first, but its priority is the higher, so it runs after the feature gate. */ +@Plugin({ name: 'setup-gate', description: 'Records that the setup gate ran' }) +export default class SetupGatePlugin { + @ToolHook.Will('execute', { priority: 10 }) + requireSetup() { + gateLog.record('setup-gate'); + } +} diff --git a/apps/e2e/demo-e2e-hooks/src/apps/hook-order/tools/gate-order.tool.ts b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/tools/gate-order.tool.ts new file mode 100644 index 000000000..71cc9e42e --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/hook-order/tools/gate-order.tool.ts @@ -0,0 +1,21 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { gateLog } from '../data/gate-log'; + +const outputSchema = z.object({ ran: z.array(z.string()) }); + +@Tool({ + name: 'gate-order', + description: 'The gates that ran before this call, in the order they ran', + inputSchema: {}, + outputSchema, +}) +export default class GateOrderTool extends ToolContext { + async execute(): Promise> { + const ran = gateLog.take(); + // The spec waits for this line: the call logged everything before it, its hook order included. + this.logger.info(`gate-order answered: ${ran.join(', ')}`); + return { ran }; + } +} diff --git a/apps/e2e/demo-e2e-hooks/src/main.hook-order.ts b/apps/e2e/demo-e2e-hooks/src/main.hook-order.ts new file mode 100644 index 000000000..91907034d --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/main.hook-order.ts @@ -0,0 +1,18 @@ +/** + * A server whose app has two plugins hooking the same stage, logging at info level so a dev-mode run shows the + * resolved hook order. + */ +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + +import { HookOrderApp } from './apps/hook-order'; + +const port = parseInt(process.env['PORT'] ?? '3120', 10); + +@FrontMcp({ + info: { name: 'Demo E2E Hook Order', version: '0.1.0' }, + apps: [HookOrderApp], + logging: { level: LogLevel.Info }, + http: { port }, + auth: { mode: 'public' }, +}) +export default class Server {} diff --git a/libs/sdk/src/agent/flows/call-agent.flow.ts b/libs/sdk/src/agent/flows/call-agent.flow.ts index 500419a6a..4d07c0756 100644 --- a/libs/sdk/src/agent/flows/call-agent.flow.ts +++ b/libs/sdk/src/agent/flows/call-agent.flow.ts @@ -31,7 +31,7 @@ import { InvalidOutputError, RateLimitError, } from '../../errors'; -import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks'; +import { entryClassHooksJoin } from '../../hooks/entry-class-hooks'; import { hooksBoundTo } from '../../hooks/hooks.utils'; import { FlowContextProviders } from '../../provider/flow-context-providers'; import { type SdkAuthInfo } from '../../server/server.types'; @@ -142,7 +142,7 @@ function invalidOutputFrom(outputError: unknown): InvalidOutputError { const name = 'agents:call-agent' as const; /** Where the hooks an agent class declares join a run: they run on the instance 'createAgentContext' builds. */ -export const agentClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createAgentContext' }; +export const agentClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createAgentContext' }); const { Stage } = FlowHooksOf<'agents:call-agent'>(name); /** diff --git a/libs/sdk/src/common/metadata/hook.metadata.ts b/libs/sdk/src/common/metadata/hook.metadata.ts index c4af96371..a6221964a 100644 --- a/libs/sdk/src/common/metadata/hook.metadata.ts +++ b/libs/sdk/src/common/metadata/hook.metadata.ts @@ -22,6 +22,7 @@ export type HookPriority = number; export type HookAppliesTo = 'own-app' | 'uncovered-apps'; export interface HookOptions { + /** Lowest runs first (default 0), and the lowest Around is outermost; ties run plugin and provider hooks in registration order, then the flow's own step, then the entry class's hooks. */ priority?: HookPriority; filter?: (ctx: Ctx) => boolean | Promise; appliesTo?: HookAppliesTo; diff --git a/libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts b/libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts index cde4ffb04..f988abae6 100644 --- a/libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts +++ b/libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts @@ -1,12 +1,26 @@ /** * A stage runs its will, did and finally hooks only when it has some, and reads each list when the stage starts, so * hooks a run adds (the tool class's own, joined at `createToolCallContext`) still run on stages that had none before. + * In dev mode (or with DEBUG), the first run of a flow for a hook owner logs the order its hooks resolve to. */ import 'reflect-metadata'; +import type { HookMetadata } from '../../common'; import type { DirectClient } from '../../direct/client.types'; import { connect } from '../../direct/connect'; -import { App, FlowHooksOf, LogLevel, Plugin, Tool, ToolContext, type FlowCtxOf } from '../../index'; +import { + App, + FlowHooksOf, + LogLevel, + LogTransport, + LogTransportInterface, + Plugin, + Tool, + ToolContext, + type FlowCtxOf, + type LogRecord, +} from '../../index'; +import { describeHookOrder, type StageEntry } from '../flow.stages'; const ToolHook = FlowHooksOf('tools:call-tool'); @@ -60,7 +74,43 @@ class EchoTool extends ToolContext { } } -@App({ id: 'stages', name: 'Stages', tools: [EchoTool], plugins: [StageTracePlugin] }) +@Tool({ name: 'plain', inputSchema: {} }) +class PlainTool extends ToolContext { + async execute() { + return { content: [{ type: 'text' as const, text: 'ok' }] }; + } +} + +@Tool({ name: 'guarded', inputSchema: {} }) +class GuardedTool extends ToolContext { + async execute() { + return { content: [{ type: 'text' as const, text: 'ok' }] }; + } + + @ToolHook.Will('findTool') + static refuseNobody() { + trace.push('tool static will findTool'); + } +} + +@Tool({ name: 'context-hooked', inputSchema: {} }) +class ContextHookedTool extends ToolContext { + async execute() { + return { content: [{ type: 'text' as const, text: 'ok' }] }; + } + + @ToolHook.Stage('createToolCallContext', { priority: -1 }) + afterContext() { + trace.push('tool stage createToolCallContext'); + } +} + +@App({ + id: 'stages', + name: 'Stages', + tools: [EchoTool, PlainTool, GuardedTool, ContextHookedTool], + plugins: [StageTracePlugin], +}) class StagesApp {} describe('flow stage hook lists', () => { @@ -100,3 +150,147 @@ describe('flow stage hook lists', () => { } }); }); + +const logged: LogRecord[] = []; + +@LogTransport({ name: 'HookOrderCapture', description: 'Keeps the log records for assertions' }) +class HookOrderCapture extends LogTransportInterface { + log(record: LogRecord): void { + logged.push(record); + } +} + +describe('the resolved hook order print', () => { + const { NODE_ENV, DEBUG } = process.env; + + afterEach(() => { + process.env['NODE_ENV'] = NODE_ENV; + if (DEBUG === undefined) delete process.env['DEBUG']; + else process.env['DEBUG'] = DEBUG; + }); + + async function hookOrderPrints( + env: { NODE_ENV: string; DEBUG?: string }, + toolNames: string[] = ['echo', 'echo'], + ): Promise { + process.env['NODE_ENV'] = env.NODE_ENV; + if (env.DEBUG === undefined) delete process.env['DEBUG']; + else process.env['DEBUG'] = env.DEBUG; + logged.length = 0; + const devClient = await connect({ + info: { name: 'stage-hooks-order', version: '1.0.0' }, + apps: [StagesApp], + logging: { level: LogLevel.Info, enableConsole: false, transports: [HookOrderCapture] }, + }); + try { + for (const toolName of toolNames) await devClient.callTool(toolName, {}).catch(() => undefined); + } finally { + await devClient.close(); + } + return logged.map((record) => record.message).filter((message) => message.startsWith('Hook order of')); + } + + const HEADER_END = ', lowest priority first (an around hook wraps what its brackets hold):'; + + it('logs the order of each hooked stage of a flow once, in dev mode, at info level', async () => { + const prints = await hookOrderPrints({ NODE_ENV: 'development' }); + + const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'")); + expect(callTool).toEqual([ + [ + `Hook order of 'tools:call-tool' for 'stages' and EchoTool${HEADER_END}`, + ' findTool: stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)', + ' checkToolCredentials: will EchoTool.willCheckCredentials (0) → stage CallToolFlow.checkToolCredentials (0)', + ' validateInput: around StageTracePlugin.aroundValidateInput (0) [ stage CallToolFlow.validateInput (0) ] → finally StageTracePlugin.finallyValidateInput (0)', + ' validateOutput: stage CallToolFlow.validateOutput (0) → did EchoTool.didValidateOutput (0) → finally EchoTool.finallyValidateOutput (0)', + ' finalize: will EchoTool.willFinalize (0) → stage CallToolFlow.finalize (0)', + ].join('\n'), + ]); + const records = logged.filter((record) => record.message === callTool[0]); + expect(records.map((record) => record.level)).toEqual([LogLevel.Info]); + }); + + it('logs once more for another hook owner, and for each entry class with hooks of its own', async () => { + const prints = await hookOrderPrints({ NODE_ENV: 'development' }, ['echo', 'plain', 'plain', 'guarded', 'missing']); + + const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'")); + expect(callTool.map((message) => message.split('\n')[0])).toEqual([ + `Hook order of 'tools:call-tool' for 'stages' and EchoTool${HEADER_END}`, + `Hook order of 'tools:call-tool' for 'stages'${HEADER_END}`, + `Hook order of 'tools:call-tool' for 'stages' and GuardedTool${HEADER_END}`, + `Hook order of 'tools:call-tool'${HEADER_END}`, + ]); + expect(callTool[1].split('\n').slice(1)).toEqual([ + ' findTool: stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)', + ' validateInput: around StageTracePlugin.aroundValidateInput (0) [ stage CallToolFlow.validateInput (0) ] → finally StageTracePlugin.finallyValidateInput (0)', + ]); + expect(callTool[2]).toContain( + ' findTool: will GuardedTool.refuseNobody (0) → stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)', + ); + }); + + it('lists an instance hook of the stage that builds the instance after that stage, where it runs', async () => { + const prints = await hookOrderPrints({ NODE_ENV: 'development' }, ['context-hooked']); + + const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'")); + expect(callTool[0]).toContain( + ' createToolCallContext: stage CallToolFlow.createToolCallContext (0) → stage ContextHookedTool.afterContext (-1)', + ); + }); + + it('logs it with DEBUG set outside dev mode', async () => { + const prints = await hookOrderPrints({ NODE_ENV: 'test', DEBUG: '1' }); + + expect(prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'"))).toHaveLength(1); + }); + + it('logs nothing outside dev mode without DEBUG', async () => { + await expect(hookOrderPrints({ NODE_ENV: 'test' })).resolves.toEqual([]); + }); +}); + +describe('describeHookOrder', () => { + class WrapPlugin {} + + function step(hook: Partial): StageEntry { + const metadata: HookMetadata = { + flow: 'tools:call-tool', + stage: 'execute', + type: 'stage', + method: 'm', + target: null, + }; + return { hook: { ...metadata, ...hook }, _priority: hook.priority ?? 0, _order: 0, method: async () => undefined }; + } + + it('skips the stages no hook joined, and names a step without a target after the flow class', () => { + const base = { execute: [step({ method: 'execute' })], validateInput: [step({ method: 'validateInput' })] }; + const resolved = { + ...base, + aroundExecute: [step({ type: 'around', method: 'wrap', target: WrapPlugin, priority: -1 })], + }; + + expect(describeHookOrder(['validateInput', 'execute'], base, resolved, 'MyFlow')).toEqual([ + ' execute: around WrapPlugin.wrap (-1) [ stage MyFlow.execute (0) ]', + ]); + }); + + it('nests the Around hooks lowest priority outermost, around the steps only', () => { + class InnerPlugin {} + const base = { execute: [step({ method: 'execute' })] }; + const resolved = { + willExecute: [step({ type: 'will', method: 'gate', target: InnerPlugin })], + aroundExecute: [ + step({ type: 'around', method: 'outer', target: WrapPlugin, priority: -10 }), + step({ type: 'around', method: 'inner', target: InnerPlugin, priority: 10 }), + ], + execute: [...base.execute, step({ method: 'extra', target: new InnerPlugin() as HookMetadata['target'] })], + didExecute: [step({ type: 'did', method: 'audit', target: WrapPlugin })], + finallyExecute: [step({ type: 'finally', method: 'close', target: WrapPlugin })], + }; + + expect(describeHookOrder(['execute'], base, resolved, 'MyFlow')).toEqual([ + ' execute: will InnerPlugin.gate (0) → around WrapPlugin.outer (-10) [ around InnerPlugin.inner (10) [ stage MyFlow.execute (0) → stage InnerPlugin.extra (0) ] ] → did WrapPlugin.audit (0) → finally WrapPlugin.close (0)', + ]); + }); +}); diff --git a/libs/sdk/src/flows/flow.instance.ts b/libs/sdk/src/flows/flow.instance.ts index 3c2c4834a..d4d8e5bfa 100644 --- a/libs/sdk/src/flows/flow.instance.ts +++ b/libs/sdk/src/flows/flow.instance.ts @@ -1,5 +1,8 @@ import 'reflect-metadata'; +import { tokenName } from '@frontmcp/di'; +import { isDebug, isDevelopment } from '@frontmcp/utils'; + import { FlowControl, FlowEntry, @@ -9,6 +12,7 @@ import { type FlowInputOf, type FlowName, type FlowOutputOf, + type FlowPhase, type FlowPlan, type FlowRecord, type FlowStagesOf, @@ -27,6 +31,7 @@ import { FRONTMCP_CONTEXT, FrontMcpContextStorage, type FrontMcpContext } from ' import { runAsFlow } from '../context/running-flow'; import { InternalMcpError, PublicMcpError, RequestContextNotAvailableError } from '../errors'; import { findMisconfiguration, misconfigurationBody } from '../errors/misconfiguration'; +import { entryContextStageOf } from '../hooks/entry-class-hooks'; import type HookRegistry from '../hooks/hook.registry'; import { bindContextHookTargets, staticHooksFor } from '../hooks/hooks.utils'; import type ProviderRegistry from '../provider/provider.registry'; @@ -36,8 +41,10 @@ import { matchMountedPath } from './flow.http-path'; import { cloneStageMap, collectFlowHookMap, + describeHookOrder, mergeHookMetasIntoStageMap, type HookRunner, + type StageEntry, type StageMap, } from './flow.stages'; @@ -136,6 +143,23 @@ export function stageBasesOf(group: readonly string[]): string[] { return bases; } +// The order a failed run reaches them in: its error stages run before its finalize stages. +const FLOW_PHASES: readonly FlowPhase[] = ['pre', 'execute', 'post', 'error', 'finalize']; + +/** A stage's list with the hooks that joined it while `step` ran moved right after `step`, where they start. */ +function listJoinedAfterStep( + list: StageEntry[], + present: readonly StageEntry[], + step: StageEntry, +): StageEntry[] { + const stepIndex = list.indexOf(step); + if (stepIndex === -1) return list; + const joinedBefore = list.slice(0, stepIndex).filter((entry) => !present.includes(entry)); + const rest = list.filter((entry) => !joinedBefore.includes(entry)); + rest.splice(rest.indexOf(step) + 1, 0, ...joinedBefore); + return rest; +} + /** The order after the last entry of a stage map, where hooks merged into it start. */ function nextStageOrder(stages: StageMap): number { return Math.max(0, ...Object.values(stages).flatMap((list) => list.map((e) => e._order ?? 0))) + 1; @@ -150,6 +174,9 @@ export class FlowInstance extends FlowEntry { private hooks: HookRegistry; private readonly logger: FrontMcpLogger; private baseStageOrderEnd?: number; + /** In dev mode or with DEBUG only: per hook owner, the entry classes (`undefined`: none) whose hook order is logged. */ + private readonly loggedHookOrders = + isDevelopment() || isDebug() ? new Map>() : undefined; constructor(scope: ScopeEntry, record: FlowRecord, deps: Set, globalProviders: ProviderRegistry) { super(scope, record); @@ -508,6 +535,8 @@ export class FlowInstance extends FlowEntry { // instance hooks do when the stage that builds the instance appends them. await materializeAndMerge(entryStaticHooks); + if (this.loggedHookOrders) this.logHookOrderOnce(this.loggedHookOrders, stages, hookOwnerId, entryClass); + let responded: FlowOutputOf | undefined; const toStageResult = (e: unknown): StageResult => { @@ -784,4 +813,41 @@ export class FlowInstance extends FlowEntry { return responded; } + + /** Logs the order the run's hooks resolve to, on the first run for its hook owner and its entry class's hooks. */ + private logHookOrderOnce( + logged: Map>, + stages: StageMap, + ownerId: string | undefined, + entryClass: Token | undefined, + ): void { + const entryHooks = entryClass + ? this.hooks.getClsHooks(entryClass).filter((hook) => hook.metadata.flow === this.name) + : []; + const entryKey = entryHooks.length > 0 ? entryClass : undefined; + const loggedEntries = logged.get(ownerId) ?? new Set(); + if (loggedEntries.has(entryKey)) return; + logged.set(ownerId, loggedEntries.add(entryKey)); + + // The entry's instance hooks join when its context is built: merge them where they will land. + const resolved = cloneStageMap(stages); + const instanceHooks = entryHooks.filter((hook) => !hook.metadata.static).map((hook) => hook.metadata); + mergeHookMetasIntoStageMap(this.FlowClass, resolved, instanceHooks, nextStageOrder(stages)); + const contextStage = entryContextStageOf(this.name); + const contextStep = contextStage ? this.stages[contextStage]?.[0] : undefined; + if (contextStage && contextStep && resolved[contextStage]) { + resolved[contextStage] = listJoinedAfterStep(resolved[contextStage], stages[contextStage] ?? [], contextStep); + } + const stageNames = FLOW_PHASES.flatMap((phase) => { + const group = this.plan[phase]; + return group ? stageBasesOf(group) : []; + }); + const lines = describeHookOrder(stageNames, this.stages, resolved, this.FlowClass.name); + if (lines.length === 0) return; + + const owner = ownerId === undefined ? '' : ` for '${ownerId}'`; + const entry = entryKey === undefined ? '' : ` and ${tokenName(entryKey)}`; + const header = `Hook order of '${this.name}'${owner}${entry}, lowest priority first (an around hook wraps what its brackets hold):`; + this.logger.info([header, ...lines].join('\n')); + } } diff --git a/libs/sdk/src/flows/flow.stages.ts b/libs/sdk/src/flows/flow.stages.ts index 8a46635cf..00e7fcf56 100644 --- a/libs/sdk/src/flows/flow.stages.ts +++ b/libs/sdk/src/flows/flow.stages.ts @@ -4,6 +4,8 @@ import { resolvePendingTC39HooksForClass } from '../common/decorators/hook.decor export type StageEntry = { /** `next` is passed only to an Around hook: it runs the rest of the stage. */ method: (ctx: C, next?: () => Promise) => Promise; + /** The flow step or hook this entry runs. */ + hook: HookMetadata; _priority: number; _order: number; }; @@ -63,6 +65,7 @@ export function collectFlowHookMap(FlowClass: any): StageMap { const resolved = resolveStageKey(m.type, m.stage); const entry: StageEntry = { + hook: m, _priority: m.priority ?? 0, _order: order++, method: async (ctx: any, next = NO_NEXT) => { @@ -108,6 +111,7 @@ export function mergeHookMetasIntoStageMap( const resolved = resolveStageKey(m.type, m.stage); const entry: StageEntry = { + hook: m, _priority: m.priority ?? 0, _order: order++, method: async (ctx: any, next = NO_NEXT) => { @@ -136,3 +140,35 @@ export function mergeHookMetasIntoStageMap( sortStageMap(table); } + +const HOOK_TYPES_IN_RUN_ORDER = ['will', 'around', 'stage', 'did', 'finally'] as const; + +/** `will SomePlugin.method (0)`: a step's type, the class that declares it, its method and its priority. */ +function describeStep(hook: HookMetadata, flowClassName: string): string { + // A plugin or provider hook's `target` is the instance its method runs on. + const target: unknown = hook.target; + const owner = + typeof target === 'function' ? target.name : target && typeof target === 'object' ? target.constructor.name : ''; + return `${hook.type} ${owner || flowClassName}.${hook.method} (${hook.priority ?? 0})`; +} + +/** One line per stage of `stages` that `resolved` adds a hook to over `base`, listing its steps in the order they run. */ +export function describeHookOrder( + stages: readonly string[], + base: StageMap, + resolved: StageMap, + flowClassName: string, +): string[] { + const lines: string[] = []; + for (const stage of stages) { + const keys = HOOK_TYPES_IN_RUN_ORDER.map((type) => resolveStageKey(type, stage)); + if (!keys.some((key) => (resolved[key]?.length ?? 0) > (base[key]?.length ?? 0))) continue; + + const [will, around, body, did, after] = keys.map((key) => + (resolved[key] ?? []).map((entry) => describeStep(entry.hook, flowClassName)), + ); + const wrapped = around.reduceRight((inner, outer) => `${outer} [ ${inner} ]`, body.join(' → ')); + lines.push(` ${stage}: ${[...will, wrapped, ...did, ...after].filter(Boolean).join(' → ')}`); + } + return lines; +} diff --git a/libs/sdk/src/hooks/__tests__/flow-hooks.behaviour.spec.ts b/libs/sdk/src/hooks/__tests__/flow-hooks.behaviour.spec.ts index d84abc03b..e4469c248 100644 --- a/libs/sdk/src/hooks/__tests__/flow-hooks.behaviour.spec.ts +++ b/libs/sdk/src/hooks/__tests__/flow-hooks.behaviour.spec.ts @@ -244,3 +244,133 @@ describe('tools:call-tool hooks from nested plugins', () => { expect(trace.filter((entry) => entry === 'hook on nested plugin provider')).toHaveLength(1); }); }); + +const orderTrace: string[] = []; + +@Plugin({ name: 'setup-gate' }) +class SetupGatePlugin { + @ToolHook.Will('execute', { priority: 10 }) + requireSetup() { + orderTrace.push('will setup-gate (10)'); + } + + @ToolHook.Around('execute', { priority: 10 }) + async wrapInner(_ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise) { + orderTrace.push('around setup-gate (10): before'); + await next(); + orderTrace.push('around setup-gate (10): after'); + } +} + +@Plugin({ name: 'feature-gate' }) +class FeatureGatePlugin { + @ToolHook.Will('execute', { priority: -10 }) + refuseDisabled() { + orderTrace.push('will feature-gate (-10)'); + } + + @ToolHook.Around('execute', { priority: -10 }) + async wrapOuter(_ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise) { + orderTrace.push('around feature-gate (-10): before'); + await next(); + orderTrace.push('around feature-gate (-10): after'); + } +} + +@Plugin({ name: 'first-tie' }) +class FirstTiePlugin { + @ToolHook.Will('execute') + firstWill() { + orderTrace.push('will first-tie (0)'); + } + + @ToolHook.Stage('execute') + firstStep() { + orderTrace.push('stage first-tie (0)'); + } +} + +@Plugin({ name: 'second-tie' }) +class SecondTiePlugin { + @ToolHook.Will('execute') + secondWill() { + orderTrace.push('will second-tie (0)'); + } +} + +@Tool({ name: 'ordered', inputSchema: {} }) +class OrderedTool extends ToolContext { + async execute() { + orderTrace.push('EXECUTE ordered'); + return { ok: true }; + } + + @ToolHook.Will('execute') + toolWill() { + orderTrace.push('will tool class (0)'); + } + + @ToolHook.Stage('execute') + toolStep() { + orderTrace.push('stage tool class (0)'); + } +} + +@App({ + id: 'ordered', + name: 'Ordered', + tools: [OrderedTool], + plugins: [SetupGatePlugin, FeatureGatePlugin, FirstTiePlugin, SecondTiePlugin], +}) +class OrderedApp {} + +describe('tools:call-tool hook order on one stage', () => { + let trace: string[]; + + beforeAll(async () => { + const server = await createTestFetchServer({ + info: { name: 'flow-hooks-order', version: '1.0.0' }, + apps: [OrderedApp], + }); + orderTrace.length = 0; + await rpc20260728(server.handler, 'tools/call', { name: 'ordered', arguments: {} }); + trace = [...orderTrace]; + }); + + it('runs the hooks of two plugins lowest priority first, whatever order the plugins are listed in', () => { + expect(trace.filter((entry) => entry.includes('-gate') && entry.startsWith('will'))).toEqual([ + 'will feature-gate (-10)', + 'will setup-gate (10)', + ]); + }); + + it('breaks a tie by registration order, with plugin hooks before the hooks of the tool class', () => { + expect(trace.filter((entry) => entry.startsWith('will'))).toEqual([ + 'will feature-gate (-10)', + 'will first-tie (0)', + 'will second-tie (0)', + 'will tool class (0)', + 'will setup-gate (10)', + ]); + }); + + it('runs a plugin step before the flow own step and a tool class step after it, on a tie', () => { + expect(trace.filter((entry) => entry.startsWith('stage') || entry.startsWith('EXECUTE'))).toEqual([ + 'stage first-tie (0)', + 'EXECUTE ordered', + 'stage tool class (0)', + ]); + }); + + it('nests Around hooks with the lowest priority outermost, around every step of the stage', () => { + expect(trace.filter((entry) => !entry.startsWith('will'))).toEqual([ + 'around feature-gate (-10): before', + 'around setup-gate (10): before', + 'stage first-tie (0)', + 'EXECUTE ordered', + 'stage tool class (0)', + 'around setup-gate (10): after', + 'around feature-gate (-10): after', + ]); + }); +}); diff --git a/libs/sdk/src/hooks/__tests__/hooks.utils.spec.ts b/libs/sdk/src/hooks/__tests__/hooks.utils.spec.ts index 16136d049..6137e0350 100644 --- a/libs/sdk/src/hooks/__tests__/hooks.utils.spec.ts +++ b/libs/sdk/src/hooks/__tests__/hooks.utils.spec.ts @@ -1,11 +1,13 @@ import 'reflect-metadata'; import { Provider } from '../../common/decorators'; -import type { HookEntry } from '../../common/entries'; -import { type ProviderType } from '../../common/interfaces'; +import type { HookEntry, ScopeEntry } from '../../common/entries'; +import { type HookType, type ProviderType } from '../../common/interfaces'; import { ProviderScope, type HookContextRun, type HookMetadata } from '../../common/metadata'; +import { HookKind, type HookRecord } from '../../common/records'; import { ToolHook } from '../../index'; import ProviderRegistry from '../../provider/provider.registry'; +import HookRegistry from '../hook.registry'; import { bindContextHookTargets, normalizeHooksFromProviders, serverProviderHooks } from '../hooks.utils'; @Provider({ name: 'context-audit', scope: ProviderScope.CONTEXT }) @@ -138,3 +140,59 @@ describe('ProviderRegistry.getContextScopedClasses', () => { registry.dispose(); }); }); + +describe('HookRegistry order', () => { + const scope = { logger: { child: () => scope.logger } } as unknown as ScopeEntry; + + function hookRegistry(): HookRegistry { + const providers = { addRegistry: jest.fn(), getActiveScope: () => scope } as unknown as ProviderRegistry; + return new HookRegistry(providers, []); + } + + function record(method: string, priority: number, metadata: Partial = {}): HookRecord { + const run = () => undefined; + const target = { [method]: run } as unknown as HookMetadata['target']; + return { + kind: HookKind.METHOD_TOKEN, + provide: run as unknown as HookType, + metadata: { flow: 'tools:call-tool', stage: 'execute', type: 'will', method, target, priority, ...metadata }, + }; + } + + const methods = (hooks: HookEntry[]) => hooks.map((hook) => hook.metadata.method); + + it('lists a flow and its stages lowest priority first, in registration order on a tie', async () => { + const registry = hookRegistry(); + await registry.registerHooks(false, record('a', 10), record('b', 0), record('c', 10), record('d', -5)); + + expect(methods(registry.getFlowHooks('tools:call-tool'))).toEqual(['d', 'b', 'a', 'c']); + expect(methods(registry.getFlowStageHooks('tools:call-tool', 'execute'))).toEqual(['d', 'b', 'a', 'c']); + expect(methods(registry.getFlowHooksForOwner('tools:call-tool', 'desk'))).toEqual(['d', 'b', 'a', 'c']); + }); + + it('lists the hooks of an entry class lowest priority first', async () => { + const registry = hookRegistry(); + class EntryClass {} + const onEntry = (method: string, priority: number) => + record(method, priority, { target: EntryClass.prototype as HookMetadata['target'] }); + await registry.registerHooks(true, onEntry('late', 5), onEntry('early', -5)); + + expect(methods(registry.getClsHooks(EntryClass))).toEqual(['early', 'late']); + }); + + it('serves inherited hooks in priority order, after its own hooks of the same priority', async () => { + const parent = hookRegistry(); + const owner = { kind: 'app' as const, id: 'desk', ref: Symbol('desk') }; + await parent.registerHooks(false, record('parent-0', 0, { owner }), record('parent-10', 10, { owner })); + const child = hookRegistry(); + await child.registerHooks(false, record('own-0', 0), record('own-20', 20)); + child.inheritFrom(parent, 'desk'); + + expect(methods(child.getFlowHooksForOwner('tools:call-tool', 'agent'))).toEqual([ + 'own-0', + 'parent-0', + 'parent-10', + 'own-20', + ]); + }); +}); diff --git a/libs/sdk/src/hooks/entry-class-hooks.ts b/libs/sdk/src/hooks/entry-class-hooks.ts index b84d999e0..4b5bf8a91 100644 --- a/libs/sdk/src/hooks/entry-class-hooks.ts +++ b/libs/sdk/src/hooks/entry-class-hooks.ts @@ -20,6 +20,19 @@ export interface EntryClassHooksJoin { contextStage: string; } +const contextStageByFlow = new Map(); + +/** Declare where an entry class's hooks join its flow, and remember the flow's context stage for the hook order log. */ +export function entryClassHooksJoin(join: EntryClassHooksJoin): EntryClassHooksJoin { + contextStageByFlow.set(join.flow, join.contextStage); + return join; +} + +/** The stage of `flow` that builds an entry's instance, when an entry class joins that flow. */ +export function entryContextStageOf(flow: string): string | undefined { + return contextStageByFlow.get(flow); +} + /** The stages of a plan, in the order a run reaches them. */ function stagesInOrder(plan: EntryFlowPlan): string[] { return [ diff --git a/libs/sdk/src/hooks/hook.registry.ts b/libs/sdk/src/hooks/hook.registry.ts index 834282a7e..901b46978 100644 --- a/libs/sdk/src/hooks/hook.registry.ts +++ b/libs/sdk/src/hooks/hook.registry.ts @@ -64,7 +64,7 @@ export default class HookRegistry extends RegistryAbstract = new Map(); - /** Fast O(1) indexes of *instances*, sorted by priority (desc) */ + /** Fast O(1) indexes of *instances*, in the order a stage runs them: lowest priority first, then registration order */ private entriesByCls: Map = new Map(); private hooksByFlow: Map = new Map(); private hooksByFlowStage: Map> = new Map(); @@ -109,16 +109,15 @@ export default class HookRegistry extends RegistryAbstract>> 1; const mp = this.getPriority(arr[mid]); - if (mp < p) { + if (mp > p) { hi = mid; } else { lo = mid + 1; @@ -166,7 +165,7 @@ export default class HookRegistry extends RegistryAbstract( flow: Name, ownerId?: string, @@ -219,7 +218,7 @@ export default class HookRegistry extends RegistryAbstract( flow: Name, ownerId?: string, @@ -245,7 +244,7 @@ export default class HookRegistry extends RegistryAbstract( flow: Name, ): HookEntry, Name, FlowStagesOf, FlowCtxOf>[] { @@ -267,7 +266,7 @@ export default class HookRegistry extends RegistryAbstract[]; } - /** Hooks for a specific *flow + stage*, sorted by priority (desc). */ + /** Hooks for a specific *flow + stage*, in run order. */ getFlowStageHooks( flow: Name, stage: FlowStagesOf | string, diff --git a/libs/sdk/src/job/flows/execute-job.flow.ts b/libs/sdk/src/job/flows/execute-job.flow.ts index acd11c81c..4e34db847 100644 --- a/libs/sdk/src/job/flows/execute-job.flow.ts +++ b/libs/sdk/src/job/flows/execute-job.flow.ts @@ -17,7 +17,7 @@ import { JobEntry } from '../../common/entries/job.entry'; import { JobContext } from '../../common/interfaces/job.interface'; import { runOnSurface } from '../../context/call-surface'; import { InternalMcpError, JobNotAuthorizedError } from '../../errors'; -import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks'; +import { entryClassHooksJoin } from '../../hooks/entry-class-hooks'; import { hooksBoundTo } from '../../hooks/hooks.utils'; import { EXECUTE_JOB_FLOW, @@ -110,7 +110,7 @@ declare global { const name = EXECUTE_JOB_FLOW; /** Where the hooks a job class declares join a run: instance methods run on the instance 'createJobContext' builds. */ -export const jobClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createJobContext' }; +export const jobClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createJobContext' }); const { Stage } = FlowHooksOf<'jobs:execute-job'>(name); /** The job a run's raw input names. */ diff --git a/libs/sdk/src/prompt/flows/get-prompt.flow.ts b/libs/sdk/src/prompt/flows/get-prompt.flow.ts index 91e64f07a..73358df09 100644 --- a/libs/sdk/src/prompt/flows/get-prompt.flow.ts +++ b/libs/sdk/src/prompt/flows/get-prompt.flow.ts @@ -30,7 +30,7 @@ import { PromptNotFoundError, } from '../../errors'; import { ResolvedEntries } from '../../flows/resolved-entries'; -import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks'; +import { entryClassHooksJoin } from '../../hooks/entry-class-hooks'; import { hooksBoundTo } from '../../hooks/hooks.utils'; import { FlowContextProviders } from '../../provider/flow-context-providers'; import { appOwnerIdOf } from '../../utils/lineage.utils'; @@ -93,7 +93,7 @@ declare global { const name = 'prompts:get-prompt' as const; /** Where the hooks a prompt class declares join a run: they run on the instance 'createPromptContext' builds. */ -export const promptClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createPromptContext' }; +export const promptClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createPromptContext' }); const { Stage } = FlowHooksOf<'prompts:get-prompt'>(name); /** Prompts `resolveHookOwnerId` found, reused by the same run's `findPrompt`. */ diff --git a/libs/sdk/src/resource/flows/read-resource.flow.ts b/libs/sdk/src/resource/flows/read-resource.flow.ts index 731085245..399b67732 100644 --- a/libs/sdk/src/resource/flows/read-resource.flow.ts +++ b/libs/sdk/src/resource/flows/read-resource.flow.ts @@ -29,7 +29,7 @@ import { ResourceReadError, } from '../../errors'; import { ResolvedEntries } from '../../flows/resolved-entries'; -import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks'; +import { entryClassHooksJoin } from '../../hooks/entry-class-hooks'; import { hooksBoundTo } from '../../hooks/hooks.utils'; import { FlowContextProviders } from '../../provider/flow-context-providers'; import { isUIResourceUri } from '../../tool/ui/ui-shared'; @@ -90,7 +90,7 @@ declare global { const name = 'resources:read-resource' as const; /** Where the hooks a resource class declares join a run: they run on the instance 'createResourceContext' builds. */ -export const resourceClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createResourceContext' }; +export const resourceClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createResourceContext' }); const { Stage } = FlowHooksOf<'resources:read-resource'>(name); /** Resources `resolveHookOwnerId` matched, reused by the same run's `findResource`. */ diff --git a/libs/sdk/src/tool/flows/call-tool.flow.ts b/libs/sdk/src/tool/flows/call-tool.flow.ts index 5a23f470d..0a3bed0b0 100644 --- a/libs/sdk/src/tool/flows/call-tool.flow.ts +++ b/libs/sdk/src/tool/flows/call-tool.flow.ts @@ -63,7 +63,7 @@ import { ToolNotFoundError, } from '../../errors'; import { ResolvedEntries } from '../../flows/resolved-entries'; -import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks'; +import { entryClassHooksJoin } from '../../hooks/entry-class-hooks'; import { hooksBoundTo } from '../../hooks/hooks.utils'; import { FlowContextProviders } from '../../provider/flow-context-providers'; import { type Scope } from '../../scope'; @@ -220,7 +220,7 @@ declare global { const name = 'tools:call-tool' as const; /** Where the hooks a tool class declares join a run: they run on the instance 'createToolCallContext' builds. */ -export const toolClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createToolCallContext' }; +export const toolClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createToolCallContext' }); const { Stage } = FlowHooksOf<'tools:call-tool'>(name); /** Tools `resolveHookOwnerId` found, reused by the same run's `findTool`. */ diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md index ce8c1e0fd..f2cfb0bd3 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md @@ -59,7 +59,7 @@ A stage runs its `@Will` hooks, then its body (wrapped by its `@Around` hooks), - `@Did` runs only when the stage succeeded. When a `@Will` hook, the body or an `@Around` hook fails, the stage's `@Did` hooks are skipped. So a `@Did` hook that closes what a `@Will` hook opened (a span, a timer, a lock) leaks it on every failure. - `@Finally` runs after the stage on every outcome: when it succeeded or responded, when it failed, when a `@Will` hook failed or responded in its place, when an `@Around` hook failed, and when `finalize` refused an answer the tool's output schema rejects (`InvalidOutputError`). Read the failure from `ctx.error`: the first error the run failed with (what a stage or hook threw or passed to `ctx.fail()`; a `FlowControl.abort(reason)` reads as `Flow aborted: `), `undefined` while the run has none. `ctx.error` reads `ctx.state.flowError`, which the state types only on `tools:call-tool`, `resources:read-resource`, `prompts:get-prompt` and `jobs:execute-job`; `ctx.error` is typed on every flow. - A `@Finally` hook cannot change the outcome. If it calls `ctx.respond()` or `ctx.fail()`, or throws, the flow logs a warning and goes on as if it had not: the client gets the same answer or the same error, the values the hook set on `ctx.state` before it responded, failed or threw are put back (a job's answer, for one; an object it changed in place is not), and the stage's other `@Finally` hooks still run. A `@Finally` hook that returns normally keeps what it set on `ctx.state`, like any other hook: on `jobs:execute-job`, setting `state.answer` before `updateRunState` changes the result the run records and returns. -- `@Finally` hooks of one stage run in `priority` order, like every other hook. A stage the flow never reaches (because an earlier stage failed) runs none of its hooks, `@Finally` included: hook `finalize` to see the end of every call. +- `@Finally` hooks of one stage run in `priority` order, lowest first, like every other hook (see [Hook Order](#hook-order)). A stage the flow never reaches (because an earlier stage failed) runs none of its hooks, `@Finally` included: hook `finalize` to see the end of every call. ```typescript import { FlowCtxOf, Plugin, ToolHook } from '@frontmcp/sdk'; @@ -89,13 +89,60 @@ Up to 1.10.15 there was no `@Finally`: a failure in `finalize` (an output schema ### What `@Stage` Does -A `@Stage('execute')` hook does not replace the flow's own `execute` step: it adds a step to that stage, and the flow's own step still runs. The steps of a stage run in `priority` order (lower first). At equal priority, a plugin's or provider's step runs before the flow's own step, and a step declared on a `@Tool` class runs after it. The value a `@Stage` method returns is ignored. +A `@Stage('execute')` hook does not replace the flow's own `execute` step: it adds a step to that stage, and the flow's own step still runs. The steps of a stage run in `priority` order (lower first). At equal priority, a plugin's or provider's step runs before the flow's own step, and a step declared on a `@Tool` class runs after it (see [Hook Order](#hook-order)). The value a `@Stage` method returns is ignored. To keep the flow's own step from running: - Call `ctx.respond(result)` from the `@Stage` (or a `@Will`) hook. The flow ends with that result: it skips the rest of the stage and the main stages after it, while the stage's `@Finally` hooks (and its `@Did` hooks, when a `@Stage` hook answered) and the cleanup stages (in `tools:call-tool`: `releaseSemaphore`, `releaseQuota`, `applyUI`, `finalize`) still run. In `tools:call-tool`, `result` is a complete MCP `CallToolResult`; it does not go through the tool's output schema. - Or use an `@Around` hook that does not call `next()`. The stage is skipped and the flow goes on; in `tools:call-tool`, set `ctx.state.required.toolContext.output`, and the result is validated and formatted like the tool's own. +## Hook Order + +Hooks on one stage run in `priority` order, **lowest first**; the default is `0`, and a higher number does not run earlier. A stage runs its `@Will` hooks, then its steps (the flow's own step and the `@Stage` hooks) wrapped by its `@Around` hooks, then its `@Did` hooks, then its `@Finally` hooks, each list in `priority` order. + +One exception: an entry class's (`@Tool`, `@Resource`, `@Prompt`, `@Agent`, `@Job`) instance `@Stage` hooks on the stage that builds its instance (`createToolCallContext`, `createResourceContext`, `createPromptContext`, `createAgentContext`, `createJobContext`) join while that stage's own step runs, so none of them runs before that step: one whose priority would place it before the step runs right after it, and the others keep their place by priority. The hook order print lists them where they run. + +- **Ties.** At equal priority, the hooks of plugins and providers run in the order they were registered (an app's plugins in the order its `plugins` array lists them), then the flow's own step, then the hooks declared on the entry class (`@Tool`, `@Resource`, `@Prompt`, `@Agent`, `@Job`): its `static` hooks, then its instance hooks. +- **Around nesting.** The `@Around` hook with the lowest priority is the outermost: its code before `next()` runs first and its code after `next()` runs last. The `@Around` hooks of a stage wrap all of its steps, but not its `@Will`, `@Did` or `@Finally` hooks. + +Two plugins on the same stage, where the feature gate must refuse before the setup gate runs: + +```typescript +import { App, FlowCtxOf, Plugin, ToolHook } from '@frontmcp/sdk'; + +@Plugin({ name: 'setup-gate' }) +export class SetupGatePlugin { + @ToolHook.Will('execute', { priority: 10 }) + requireSetup(ctx: FlowCtxOf<'tools:call-tool'>) { + // refuse when the account is not set up + } +} + +@Plugin({ name: 'feature-gate' }) +export class FeatureGatePlugin { + @ToolHook.Will('execute', { priority: -10 }) + refuseDisabled(ctx: FlowCtxOf<'tools:call-tool'>) { + // refuse when the feature is disabled + } +} + +@App({ name: 'billing', plugins: [SetupGatePlugin, FeatureGatePlugin], tools: [InvoiceTool] }) +export class BillingApp {} +``` + +`refuseDisabled` (`-10`) runs before `requireSetup` (`10`), though its plugin is listed second. At equal priorities, `requireSetup` would run first, because its plugin is listed first. + +### Seeing the Resolved Order + +With `NODE_ENV=development`, or `DEBUG=1`, a flow logs at info level the order its hooks resolve to, on its first run for each hook owner (the app of the entry the run is for) and for each entry class that declares hooks of its own. It is logged once, not per request, and nothing is built for it unless the server runs in dev mode or with `DEBUG` set. Each line is a stage that has hooks, with its steps in the order they run, as `type Class.method (priority)`; an `@Around` hook's brackets hold what it wraps: + +```text +[FlowInstance] INFO Hook order of 'tools:call-tool' for 'billing', lowest priority first (an around hook wraps what its brackets hold): + execute: will FeatureGatePlugin.refuseDisabled (-10) → will SetupGatePlugin.requireSetup (10) → stage CallToolFlow.execute (0) +``` + +The print is new in 1.10.16. + ## Available Flow Names These are the flow names with pre-built hook decorator exports in `@frontmcp/sdk` (see "Pre-Built Hook Type Exports" below): @@ -351,7 +398,7 @@ All five decorators accept an optional options object: }) ``` -- **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did`, `@Around`, `@Finally` and `@Stage` alike. Default: `0`. +- **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did`, `@Around`, `@Finally` and `@Stage` alike, and the lowest `@Around` is the outermost. Default: `0`. Ties and an example: [Hook Order](#hook-order). - **filter** (`(ctx) => boolean`) - A predicate that receives the flow context, typed like the hook's (`ctx.toolName`, `ctx.request`, ...). Return `false` to skip this hook for the current invocation. - **appliesTo** (`'own-app' | 'uncovered-apps'`) - A hook of a plugin installed on an app runs, in `tools/call`, `resources/read`, `prompts/get` and `completion/complete`, only for that app's entries (`'own-app'`), unless the plugin has `scope: 'server'`. With `'uncovered-apps'` it also runs for the entries of any app that has no instance of the same hook (same class and method) of its own or from a server-level plugin. Use it for gates an entry's metadata asks for (approval, feature flags), so the entry is not left ungated when the plugin sits on another app. Server-level plugins' hooks, and list-flow hooks, already run for every app. @@ -468,7 +515,7 @@ import { LoggingPlugin } from './plugins/logging.plugin'; export class MyApp {} ``` -Plugins are initialized in array order. Hook priority determines execution order within the same stage. +Plugins are initialized in array order. Hook priority determines execution order within the same stage, lowest first; at equal priority, hooks run in array order (see [Hook Order](#hook-order)). Hooks declared on an app's providers, on its plugins (including plugins nested inside them), and on those plugins' providers run only for that app's tools, resources and prompts (`tools:call-tool`, `resources:read-resource`, `prompts:get-prompt`, `completion:complete`), including the ones its adapters and plugins provide, such as the tools an OpenAPI adapter generates. Plugins and providers registered on the server (`@FrontMcp({ plugins, providers })`) apply to every app. So do the hooks of a plugin declared with `scope: 'server'`, those on its providers included, even when it is installed on one app; its providers and context extensions still stay with that app, and each app that installs it adds another copy of its hooks that runs for every app. Up to 1.9.0 those hooks ran only for the installing app. Resources and prompts the server serves outside every app, such as the SEP-2640 `skill://` resources, run every app's hooks. @@ -610,7 +657,7 @@ An instance method on an earlier stage, or `Will`/`Around` on `createToolCallCon | `InvalidHookFlowError` at startup | Entry-class hook that could never run | Make early-stage hooks `static`; move list-flow hooks (and a `@Job` hook for a flow other than `jobs:execute-job`) to a plugin or a provider | | Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) | | `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage | -| Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; lower numbers execute first | +| Multiple hooks execute in wrong order | Priorities not set, or set as if a higher number ran first | Lower numbers execute first: give the hook that must run first the lowest `priority`; run with `NODE_ENV=development` to log the order | | The tool still runs despite a plugin `@Stage` | `@Stage` adds a step; it does not replace the stage | End the flow with `ctx.respond(result)`, or use `@Around` without calling `next()` | | A span or timer opened in `@Will` stays open | It is closed in `@Did`, which a failed stage skips | Close it in `@Finally` on the same stage | | `ctx.respond()` in a `@Finally` hook does nothing | `@Finally` cannot change a stage's outcome | Answer from `@Will`, `@Stage` or `@Around`; the flow logs a warning for each ignored `@Finally` response or throw |