Skip to content

Hooks System Internals

This document explains the internal architecture of the hooks system, how hooks integrate with runtimes, and key design decisions.

Overview

The hooks system provides composable observability throughout agent execution. It's implemented as:

  1. Types - Hook interfaces and payload definitions (packages/core/src/hooks/types.ts)
  2. Hook Manager - Registration and invocation logic (packages/core/src/hooks/hook-manager.ts)
  3. Runtime Integration - Hook invocation points in executors

Architecture

mermaid
graph TB
    subgraph Execution ["Agent Execution"]
        HM["<b>HookManager</b><br/><i>Composed from agent + execution hooks</i>"]

        HM -->|"invoke('onAgentStart', payload, context)"| Hooks

        subgraph Hooks [" "]
            direction LR
            Parent["<b>Parent Hooks</b><br/>(sub-agents)"]
            Agent["<b>Agent Hooks</b><br/>(defineAgent)"]
            Exec["<b>Exec Hooks</b><br/>(execute())"]
            Parent --> Agent --> Exec
        end

        subgraph HookPoints ["Hook Points"]
            direction LR
            H1["onAgentStart<br/>onAgentComplete<br/>onAgentFail<br/>onAgentSuspended<br/>onAgentResumed<br/>onMessage"]
            H2["beforeLLMCall<br/>afterLLMCall<br/>onStateChange"]
            H3["beforeTool<br/>afterTool<br/>beforeSubAgent<br/>afterSubAgent"]
        end
    end

Note: onTextDelta and onThinking hooks are defined but not yet wired.

Canonical Hook Firing Order (v7 cross-runtime invariant)

For the regular tool execution path (and the approval-gated approve drain path), every runtime fires user-facing AgentHooks in the canonical sequence:

beforeTool → execute → onStateChange → onMessage → afterTool

onStateChange reflects the immediate state mutation from execute; onMessage surfaces the result-as-message; afterTool is universal cleanup with the full result payload.

Pre-2026-05-02: runtime-js, runtime-temporal, and CFW Workflows each fired in a different order (runtime-js fired onMessage AFTER afterTool; runtime-temporal fired onMessage BEFORE onStateChange). Sub-projects #2 + #3 unified the order so portable hook code can rely on cross-runtime sequencing. Per-runtime regression guards live in:

  • packages/runtime-js/src/__tests__/js-agent-executor-hooks.test.ts (regular path) and approve-path-hooks.test.ts (approve drain path)
  • packages/runtime-temporal/src/__tests__/v7-activities-hooks.test.ts (regular path) and v7-approve-path-hooks.test.ts (approve drain path)
  • packages/runtime-cloudflare/src/__tests__/approve-path-hooks-do.test.ts (DO approve drain path)
  • packages/e2e/src/__tests__/approval-gate-hook-parity.integ.test.ts (cross-backend)

Implementation note: runtime-js executes phase-1 tools in PARALLEL via Promise.all. To preserve LLM-input ordering of state.messages while maintaining the canonical hook order, runServerTool (in packages/runtime-js/src/run-loop.ts) defers afterTool firing back to the iterator's collection loop in packages/core/src/orchestration/step-iterator.ts via ExecuteServerToolResult.deferredAfterTool. The iterator pushes the message → fires onMessage → fires the deferred afterTool per result, in input order. runtime-temporal and CFW Workflows execute tools sequentially so they fire all hooks inline inside their executeServerToolWithHooks / per-tool helpers without needing the deferred-payload indirection.

Suspension Path Hook Guarantees (v7)

In v7's stateless suspension model, when a run reaches a HITL boundary it persists durable suspension state and exits the runLoop. Two lifecycle hooks bracket this transition:

  • onAgentSuspended — Fires when the runtime persists suspension state and is about to exit the runLoop. Receives an AgentSuspendedHookContext:

    typescript
    interface AgentSuspendedHookContext {
      sessionId: string;
      runId: string;
      /**
       * - 'client_tool': waiting for one or more client-executed tool results.
       * - 'awaiting_children': waiting for persistent/ephemeral child agents.
       * - 'step_partial': a partial step was persisted and the run will resume.
       */
      reason: 'client_tool' | 'awaiting_children' | 'step_partial';
      pendingToolCallIds: string[]; // empty for non-client_tool
      awaitingChildren: SuspendedChildWait[]; // empty for non-awaiting_children
      stepCount: number;
      hookManager?: HookManager;
      logger?: Logger;
    }

    Typical uses: drain background work (e.g. EmbeddingExecutor), flush traces, persist additional metadata to suspension_context before the runtime exits.

  • onAgentResumed — Fires after a resumed run has loaded checkpoint state and re-established the tracing context, BEFORE the first runStepIteration. Receives an AgentResumedHookContext:

    typescript
    interface AgentResumedHookContext {
      sessionId: string;
      runId: string; // new run ID for this resume
      previousRunId?: string;
      resumedFromCheckpointId?: string;
      hookManager?: HookManager;
      logger?: Logger;
    }

    Typical uses: re-establish OpenTelemetry context; recover unembedded memories; re-attach to remote sub-agent streams. The tracing-langfuse adapter reads state.tracingContext.lastActiveSpanId from the checkpoint via stateStore.getLatestCheckpoint(sessionId) to seed the resumed run's root span as a child of the suspending span.

Hook signatures differ from regular hooks. Both onAgentSuspended and onAgentResumed receive a SINGLE typed context argument (the suspension/resume context above) — not the (payload, context) pair used by onMessage, beforeTool, etc.

Resume-side error handling: Errors thrown from onAgentResumed are non-fatal — they are logged at warn-level and the resumed run proceeds. Failing the resumed run on a hook error would leave the session unrecoverable. Errors from onAgentSuspended are similarly logged and don't block suspension.

Tests: packages/core/src/hooks/__tests__/suspended-resumed-hooks.test.ts and the cross-backend lifecycle-hooks-parity.integ.test.ts pin the firing semantics.

Per-Runtime Hook Parity

Hookruntime-jsruntime-cloudflare (DO)runtime-cloudflare (Workflow)runtime-temporalruntime-dbos
onAgentStartyesyesyesyesyes
onAgentCompleteyesyesyesyesyes
onAgentFailyesyesyesyesyes
onAgentSuspendedyes (v7)yes (v7)yes (v7)yes (v7)n/a (DBOS-native)
onAgentResumedyes (v7)yes (v7)yes (v7)yes (v7)n/a (DBOS-native)
beforeLLMCall / afterLLMCallyesyesyesyesyes
beforeTool / afterToolyesyesyesyesyes
onStateChangeyesyesyesyesyes
onMessageyesyesyesyesyes
beforeSubAgent / afterSubAgentyesyesyesyespartial
onWorkspaceOpen / onWorkspaceClose / onWorkspaceEvicted / onWorkspaceEvictionRetry / onWorkspaceSnapshotyesyesn/a (workspaces fail-fast)n/a (workspaces fail-fast)n/a (silent ignore)
onTextDelta / onThinkingdefined-but-unwireddefined-but-unwireddefined-but-unwireddefined-but-unwireddefined-but-unwired

DBOS uses a separate native suspension primitive (DBOS.recv / DBOS.send) so the unified suspensionContext lifecycle hooks don't apply — operators using DBOS should attach to its workflow events directly.

HookManager Implementation

DefaultHookManager

The primary hook manager implementation:

typescript
class DefaultHookManager<TState, TOutput> implements HookManager<TState, TOutput> {
  private handlers: AgentHooks<TState, TOutput>[] = [];
  private parent?: HookManager<TState, TOutput>;

  constructor(parent?: HookManager<TState, TOutput>) {
    this.parent = parent;
  }

  register(hooks: AgentHooks<TState, TOutput>): void {
    this.handlers.push(hooks);
  }

  async invoke<K extends keyof AgentHooks<TState, TOutput>>(
    hookName: K,
    ...args: Parameters<NonNullable<AgentHooks<TState, TOutput>[K]>>
  ): Promise<void> {
    // Parent hooks run first
    if (this.parent) {
      await this.parent.invoke(hookName, ...args);
    }

    // Then registered handlers in order
    for (const handler of this.handlers) {
      const hookFn = handler[hookName];
      if (hookFn) {
        await (hookFn as Function)(...args);
      }
    }
  }

  createChild(): HookManager<TState, TOutput> {
    return new DefaultHookManager(this);
  }

  hasHooks(): boolean {
    return this.handlers.length > 0 || (this.parent?.hasHooks() ?? false);
  }
}

Key Behaviors

Sequential Execution: Handlers run in registration order, each awaited before the next.

typescript
// Registration order determines execution order
manager.register(loggingHooks); // Runs first
manager.register(metricsHooks); // Runs second
manager.register(auditHooks); // Runs third

Error Propagation: Hook errors propagate up and fail the agent. This is intentional—hooks are part of execution, not advisory.

typescript
// If loggingHooks.onAgentStart throws, the agent fails
await manager.invoke('onAgentStart', payload, context);

Parent-Child Hierarchy: Sub-agents inherit parent hooks via createChild().

typescript
// In sub-agent executor
const childManager = parentManager.createChild();
childManager.register(subAgentHooks); // Sub-agent can add its own
await childManager.invoke('onAgentStart', ...); // Parent hooks run first

NoopHookManager

Performance optimization when no hooks are configured:

typescript
class NoopHookManager<TState, TOutput> implements HookManager<TState, TOutput> {
  register(): void {}
  unregister(): void {}
  async invoke(): Promise<void> {}
  createChild(): HookManager<TState, TOutput> {
    return this;
  }
  hasHooks(): boolean {
    return false;
  }
}

// Singleton instance
export const noopHookManager: HookManager<any, any> = new NoopHookManager();

When building the hook manager, the runtime returns noopHookManager if no hooks exist:

typescript
function buildHookManager(agentHooks, executionHooks, existingManager) {
  if (!agentHooks && !executionHooks && !existingManager) {
    return noopHookManager;
  }
  return mergeHooks(agentHooks, executionHooks, existingManager);
}

Factory Functions

createHookManager

Creates a manager with optional initial hooks:

typescript
function createHookManager<TState, TOutput>(
  initialHooks?: AgentHooks<TState, TOutput>
): HookManager<TState, TOutput> {
  const manager = new DefaultHookManager<TState, TOutput>();
  if (initialHooks) {
    manager.register(initialHooks);
  }
  return manager;
}

composeHookManagers

Composes multiple hook sets into one manager:

typescript
function composeHookManagers<TState, TOutput>(
  ...hooksList: (AgentHooks<TState, TOutput> | undefined)[]
): HookManager<TState, TOutput> {
  const manager = new DefaultHookManager<TState, TOutput>();
  for (const hooks of hooksList) {
    if (hooks) {
      manager.register(hooks);
    }
  }
  return manager;
}

mergeHooks

Merges agent-level and execution-level hooks:

typescript
function mergeHooks<TState, TOutput>(
  agentHooks?: AgentHooks<TState, TOutput>,
  executionHooks?: AgentHooks<TState, TOutput>,
  existingManager?: HookManager<TState, TOutput>
): HookManager<TState, TOutput> {
  const manager = existingManager ?? new DefaultHookManager<TState, TOutput>();

  if (agentHooks) {
    manager.register(agentHooks); // Agent hooks first
  }
  if (executionHooks) {
    manager.register(executionHooks); // Execution hooks second
  }

  return manager;
}

HookContext Creation

The createHookContext function builds context from runtime state:

typescript
function createHookContext<TState, TOutput>(
  options: CreateHookContextOptions<TState, TOutput>
): HookContext<TState, TOutput> {
  return {
    // Read-only execution info (sessionId is the primary identifier)
    sessionId: options.sessionId,
    agentType: options.agentType,
    streamId: options.streamId,
    stepCount: options.stepCount,
    parentSessionId: options.parentSessionId,
    abortSignal: options.abortSignal,

    // Session context (for tracing platforms)
    userId: options.userId,
    tags: options.tags,
    metadata: options.metadata,

    // State access
    getState: options.getState,
    updateState: options.updateState,

    // Stream emission
    emit: options.emit,
    emitCustom: async (eventName: string, data: unknown) => {
      await options.emit({
        type: 'custom',
        agentId: options.sessionId, // Use sessionId as agentId for chunk identification
        agentType: options.agentType,
        timestamp: Date.now(),
        eventName,
        data,
      });
    },
  };
}

Runtime Integration

JS Runtime Integration

The JSAgentExecutor invokes hooks at key points in the execution loop.

Hook Manager Building (in JSAgentExecutor.buildHookManager()):

typescript
private buildHookManager<TState, TOutput>(
  agent: AgentConfig<..., ...>,
  options: ExecuteOptions<TState, TOutput>
): HookManager<TState, TOutput> {
  // Return noop if no hooks configured
  if (!agent.hooks && !options.hooks && !options.hookManager) {
    return noopHookManager;
  }

  // Use provided manager or create new one
  const manager = options.hookManager ?? new DefaultHookManager<TState, TOutput>();

  // Register agent hooks first
  if (agent.hooks) {
    manager.register(agent.hooks);
  }

  // Then execution hooks
  if (options.hooks) {
    manager.register(options.hooks);
  }

  return manager;
}

Hook Invocation Points (post-A.2 — JSAgentExecutor.runLoop was removed; the JS step loop is now runStepIteration from core, called from packages/runtime-js/src/run-loop.ts):

HookLocationWhen Invoked
onAgentStartrun-loop.ts entry (skipped on resume)Before first LLM call
onAgentResumedrun-loop.ts entry on resume pathAfter checkpoint state load, before first runStepIteration
beforeLLMCallBefore llmAdapter.generateStep()Each LLM call
afterLLMCallAfter planStepProcessing()Each LLM response
onMessageAfter message appendUser, assistant, tool messages
beforeToolrun-loop.ts (runServerTool) — see canonical orderBefore tool.execute()
onStateChangerun-loop.ts (runServerTool) — see canonical orderAfter state mutation, before message append
afterToolstep-iterator.ts (deferred) — see canonical orderAfter tool completes (deferred so onMessage fires first)
beforeSubAgentsubagent-executor.tsBefore sub-agent starts
afterSubAgentsubagent-executor.tsAfter sub-agent completes
onAgentSuspendedstep-iterator.ts at suspension boundaryWhen run persists suspension state and is about to exit
onAgentCompleteendAgentStream (both __finish__ and finishWith)On successful completion
onAgentFailrun-loop.ts exitOn failure

See the Canonical Hook Firing Order section above for the full ordering rationale.

Example: Tool Executor Hook Integration (simplified from actual implementation):

typescript
// packages/runtime-js/src/execution/tool-executor.ts

async function executeSingleToolCall<TState, TOutput>(
  call: ParsedToolCall,
  tool: Tool,
  baseContext: ToolContext,
  // ... other params: writer, runId, stateStore, state, batchAbortSignal
  hookManager?: HookManager<any, any>,
  getHookContext?: () => HookContext<TState, TOutput>
): Promise<RegularToolResult> {
  // Create per-tool state tracker for atomic state merging
  const currentState = baseContext.getState();
  const toolTracker = new ImmerStateTracker(structuredClone(currentState));

  // Create tool-specific context with its own state tracker
  const toolContext: ToolContext = {
    ...baseContext,
    getState: () => toolTracker.getState(),
    updateState: (updater) => toolTracker.updateState(updater),
  };

  // Hook: beforeTool
  if (hookManager && getHookContext) {
    await hookManager.invoke('beforeTool', { toolCall: call, tool }, getHookContext());
  }

  const toolStartTime = Date.now();

  try {
    const result = await tool.execute(call.arguments, toolContext);

    // After tool completes, check if state was modified
    if (toolTracker.hasChanges()) {
      const previousState = structuredClone(currentState);

      // Merge state changes to store...

      // Hook: onStateChange
      if (hookManager && getHookContext) {
        await hookManager.invoke(
          'onStateChange',
          {
            previousState,
            newState: toolTracker.getState(),
            source: 'tool',
            toolName: call.name,
          },
          getHookContext()
        );
      }
    }

    // Hook: afterTool (success)
    if (hookManager && getHookContext) {
      await hookManager.invoke(
        'afterTool',
        {
          toolCall: call,
          tool,
          result,
          success: true,
          error: undefined,
          durationMs: Date.now() - toolStartTime,
        },
        getHookContext()
      );
    }

    return {
      kind: 'tool',
      id: call.id,
      toolName: call.name,
      arguments: call.arguments,
      success: true,
      result,
    };
  } catch (error) {
    const errorObj = error instanceof Error ? error : new Error(String(error));

    // Hook: afterTool (failure)
    if (hookManager && getHookContext) {
      await hookManager.invoke(
        'afterTool',
        {
          toolCall: call,
          tool,
          result: undefined,
          success: false,
          error: errorObj,
          durationMs: Date.now() - toolStartTime,
        },
        getHookContext()
      );
    }

    return {
      kind: 'tool',
      id: call.id,
      toolName: call.name,
      arguments: call.arguments,
      success: false,
      error: errorObj.message,
    };
  }
}

Temporal Runtime Integration

In Temporal, hooks run inside activities (non-deterministic code), not in the workflow (deterministic code). The hook manager is passed to the GenericAgentActivities class at initialization time when activities are registered.

Per-call hooks on Temporal / CFW Workflows. Both runtimes resolve the agent reference BY NAME from the agent registry inside activities/steps (the workflow input is name + state, not a serialized config). To install per-call hooks, callers use the agent registry's replace(updatedConfig) API — the activity-side hookManager picks up per-call hooks from the registry, not from the workflow input. JS and DBOS read the agent reference inline at execute() time so the options.hooks parameter is sufficient.

See ./concepts.md §AgentRegistry replace API and the runtime-temporal docs for the full API description.

Activity Setup (packages/runtime-temporal/src/activities.ts):

typescript
// Hook manager is passed at activity registration time, not serialized
export class GenericAgentActivities {
  private readonly hookManager?: HookManager<any, any>;

  constructor(
    registry: AgentRegistry,
    stateStore: StateStore,
    streamManager: StreamManager,
    llmAdapter: LLMAdapter,
    cancellationProvider?: CancellationSignalProvider,
    heartbeatProvider?: HeartbeatProvider,
    hookManager?: HookManager<any, any> // Passed at setup time
  ) {
    this.hookManager = hookManager;
  }
}

Hook Invocation in Activities:

typescript
// Inside activity methods
if (this.hookManager) {
  await this.hookManager.invoke('beforeLLMCall', {
    messages,
    tools,
    config: agentConfig,
    model,
  }, hookContext);
}

const stepResult = await llmAdapter.generateStep(...);

if (this.hookManager) {
  await this.hookManager.invoke('afterLLMCall', {
    result: stepResult,
    plan,
    durationMs,
    model,
    usage: stepResult.usage,
  }, hookContext);
}

Important: Hook managers are NOT serialized to workflows. They must be provided when activities are registered. This means:

  • Hooks defined in defineAgent({ hooks: ... }) work if the activity runner receives them at setup
  • Execution-time hooks passed to executor.execute() require the executor to be configured with hook support

Cloudflare Runtime Integration

Similar to Temporal, hooks run in step methods. The hook manager is passed to the AgentSteps class at initialization time.

typescript
// packages/runtime-cloudflare/src/steps.ts

export class AgentSteps {
  private readonly hookManager?: HookManager<any, any>;

  constructor(
    stateStore: StateStore,
    streamManager: StreamManager,
    llmAdapter: LLMAdapter,
    hookManager?: HookManager<any, any> // Passed at setup time
  ) {
    this.hookManager = hookManager;
  }

  async executeToolStep(/* ... */): Promise<ToolStepOutput> {
    if (this.hookManager) {
      await this.hookManager.invoke('beforeTool', { toolCall, tool }, hookContext);
    }

    // Execute tool...

    if (this.hookManager) {
      await this.hookManager.invoke(
        'afterTool',
        {
          toolCall,
          tool,
          result,
          success,
          error,
          durationMs,
        },
        hookContext
      );
    }

    return { result, success };
  }
}

Stream Finalization (endAgentStream):

The Cloudflare runtime's endAgentStream handles both finishWith state promotion and sub-agent lifecycle hooks, matching the Temporal runtime's behavior:

typescript
async endAgentStream(input: EndAgentStreamInput): Promise<void> {
  const state = await this.loadAgentState(input.sessionId);
  if (!state) return;

  // Promote finishWith output to completed status
  if (input.finishWithOutput !== undefined && state.status !== 'completed') {
    state.status = 'completed';
    state.output = input.finishWithOutput;
    await this.saveAgentState(state);
  }

  // Fire onAgentComplete hook
  if (state.status === 'completed') {
    await this.invokeHookWithStateTracking('onAgentComplete', { ... }, state, config);
  }

  // Skip stream close for sub-agents (they share parent's stream)
  if (!input.skipStreamClose) {
    await this.streamManager.endStream(state.streamId, state.output);
  }
}

The workflow calls endAgentStream with the appropriate parameters depending on the context:

ContextfinishWithOutputskipStreamClose
Root agent, normal completionnot setfalse (default)
Root agent, finishWith completiontool outputfalse (default)
Sub-agent, normal completionnot settrue
Sub-agent, finishWith completiontool outputtrue

Completion Path Hook Guarantees

Normal Completion (__finish__ tool)

When the LLM calls the __finish__ tool, the agent's status is set to completed during the normal step processing flow. The onAgentComplete hook fires as part of stream finalization.

finishWith Tool Completion

The finishWith code path bypasses the normal step processing status update. To ensure onAgentComplete still fires, each runtime's endAgentStream method handles this:

typescript
// In endAgentStream (Temporal activities / Cloudflare AgentSteps)
if (input.finishWithOutput !== undefined && state.status !== 'completed') {
  state.status = 'completed';
  state.output = input.finishWithOutput;
  await this.saveAgentState(state);
}

if (state.status === 'completed') {
  await hookManager.invoke(
    'onAgentComplete',
    { output, finalState, stepCount, durationMs },
    hookContext
  );
}

This guarantees onAgentComplete fires regardless of which completion path the agent takes.

Sub-Agent Completion

Sub-agents share the parent's stream but still need their own onAgentComplete hook to fire (for tracing to emit root spans, flush metrics, etc.). The skipStreamClose parameter allows this:

typescript
// Sub-agents call endAgentStream with skipStreamClose: true
await endAgentStream({
  sessionId: subAgentSessionId,
  skipStreamClose: true, // Don't close parent's stream
});

This fires onAgentComplete for the sub-agent without closing the parent's stream. Both the normal completion and finishWith paths handle sub-agents this way across all runtimes (JS, Temporal, Cloudflare).

Sub-Agent Hook Inheritance

When a sub-agent executes, it inherits the parent's hooks via createChild():

typescript
// packages/runtime-js/src/execution/subagent-executor.ts

async function executeSubAgent(
  call: ParsedSubAgentCall,
  parentHookManager: HookManager,
  parentContext: HookContext
): Promise<SubAgentResult> {
  // Create child manager that inherits parent hooks
  const childHookManager = parentHookManager.createChild();

  // Sub-agent's own hooks are registered on the child
  if (subAgentConfig.hooks) {
    childHookManager.register(subAgentConfig.hooks);
  }

  // Invoke beforeSubAgent on parent
  // agentConfig is the local config for in-process sub-agents,
  // or undefined for remote sub-agents
  await parentHookManager.invoke(
    'beforeSubAgent',
    {
      call,
      agentConfig: subAgentConfig, // undefined for remote sub-agents
      parentSessionId: parentContext.sessionId,
    },
    parentContext
  );

  // Execute sub-agent with child hook manager
  // When child invokes hooks, parent hooks run first
  const result = await runSubAgent(subAgentConfig, call.input, {
    hookManager: childHookManager,
  });

  // Invoke afterSubAgent on parent
  await parentHookManager.invoke(
    'afterSubAgent',
    {
      call,
      agentConfig: subAgentConfig, // undefined for remote sub-agents
      result: result.output,
      success: result.status === 'completed',
      error: result.error,
      durationMs: result.durationMs,
      subSessionId: result.sessionId,
    },
    parentContext
  );

  return result;
}

Invocation Order for Sub-Agent:

Parent's onAgentStart → Parent's hooks
Child's onAgentStart  → Parent's hooks (via parent) → Child's hooks
Child's beforeTool    → Parent's hooks (via parent) → Child's hooks
Child's onAgentComplete → Parent's hooks (via parent) → Child's hooks
Parent's afterSubAgent → Parent's hooks

Design Decisions

1. Hooks Fail the Agent

Decision: Hook errors propagate and fail the agent execution.

Rationale: Hooks are part of execution, not advisory. If logging or auditing fails, that's a real failure that should be visible.

Alternative Considered: Catching hook errors and logging warnings. Rejected because it could hide critical failures (e.g., audit requirements not met).

2. Sequential Execution

Decision: Hooks execute sequentially in registration order.

Rationale:

  • Predictable execution order
  • Hooks can depend on each other (e.g., auth check before audit)
  • Simplifies debugging

Alternative Considered: Parallel execution for performance. Rejected because it complicates error handling and ordering guarantees.

3. NoopHookManager Optimization

Decision: Return singleton noopHookManager when no hooks are configured.

Rationale: Avoids overhead of empty manager creation and iteration when hooks aren't used.

typescript
// Fast path: no hooks
if (!agent.hooks && !options.hooks) {
  return noopHookManager; // All methods are no-ops
}

4. Parent-Child Hierarchy for Sub-Agents

Decision: Sub-agents inherit parent hooks via createChild().

Rationale:

  • Observability flows through entire execution tree
  • Parent can trace all activity including sub-agents
  • Sub-agents can add specialized hooks without losing parent's

5. Hook Context is Read-Only (Except State)

Decision: Hook context properties are read-only; only state can be modified.

Rationale: Hooks should observe, not control execution flow. State modification is allowed for use cases like tracking metadata.

Performance Considerations

Hook Invocation Overhead

Each hook invocation involves:

  1. Manager method call
  2. Parent chain traversal
  3. Handler array iteration
  4. Async function calls

For frequently-invoked hooks (like beforeTool/afterTool in tool-heavy agents), consider:

  • Using hasHooks() guard before building payload
  • Keeping hook implementations lightweight
typescript
// Guard pattern for performance
if (hookManager.hasHooks()) {
  await hookManager.invoke('beforeTool', payload, context);
}

Note: The onTextDelta and onThinking hooks are defined in the type system but not yet wired into the runtime. Streaming observability is currently handled via stream chunks rather than hooks.

Context Creation

createHookContext() creates a new object per invocation. For frequent hooks, the context is typically created once per step and reused:

typescript
// Create once per step
const hookContext = createHookContext({
  runId,
  agentType,
  streamId,
  stepCount,
  // ... methods bound to current state
});

// Reuse for all hooks in this step
await hookManager.invoke('beforeLLMCall', payload, hookContext);
await hookManager.invoke('afterLLMCall', payload, hookContext);

Testing

Unit Testing Hook Managers

typescript
import { createHookManager, DefaultHookManager } from '@helix-agents/core';

describe('DefaultHookManager', () => {
  it('invokes handlers in registration order', async () => {
    const order: string[] = [];
    const manager = createHookManager<unknown, unknown>();

    manager.register({
      onAgentStart: () => {
        order.push('first');
      },
    });
    manager.register({
      onAgentStart: () => {
        order.push('second');
      },
    });

    await manager.invoke('onAgentStart', {} as any, {} as any);

    expect(order).toEqual(['first', 'second']);
  });

  it('parent hooks run before child hooks', async () => {
    const order: string[] = [];
    const parent = createHookManager<unknown, unknown>();
    parent.register({
      onAgentStart: () => {
        order.push('parent');
      },
    });

    const child = parent.createChild();
    child.register({
      onAgentStart: () => {
        order.push('child');
      },
    });

    await child.invoke('onAgentStart', {} as any, {} as any);

    expect(order).toEqual(['parent', 'child']);
  });

  it('propagates hook errors', async () => {
    const manager = createHookManager({
      onAgentStart: () => {
        throw new Error('Hook failed');
      },
    });

    await expect(manager.invoke('onAgentStart', {} as any, {} as any)).rejects.toThrow(
      'Hook failed'
    );
  });
});

Integration Testing

typescript
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { defineAgent, MockLLMAdapter, FINISH_TOOL_NAME } from '@helix-agents/core';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { z } from 'zod';

describe('JSAgentExecutor hooks', () => {
  let stateStore: InMemoryStateStore;
  let streamManager: InMemoryStreamManager;
  let mockLLM: MockLLMAdapter;
  let executor: JSAgentExecutor;

  beforeEach(() => {
    stateStore = new InMemoryStateStore();
    streamManager = new InMemoryStreamManager();
    mockLLM = new MockLLMAdapter();
    executor = new JSAgentExecutor(stateStore, streamManager, mockLLM);
  });

  it('invokes hooks during execution', async () => {
    const calls: string[] = [];

    const agent = defineAgent({
      name: 'test',
      systemPrompt: 'Test',
      outputSchema: z.object({ result: z.string() }),
      llmConfig: { model: {} as never },
      hooks: {
        onAgentStart: () => calls.push('start'),
        beforeLLMCall: () => calls.push('before-llm'),
        afterLLMCall: () => calls.push('after-llm'),
        onAgentComplete: () => calls.push('complete'),
      },
    });

    mockLLM.addResponse({
      type: 'tool_calls',
      toolCalls: [{ id: '1', name: FINISH_TOOL_NAME, arguments: { result: 'done' } }],
    });

    const handle = await executor.execute(agent, 'Hello');
    await handle.result();

    expect(calls).toEqual(['start', 'before-llm', 'after-llm', 'complete']);
  });
});

See Also

Released under the MIT License.