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:
- Types - Hook interfaces and payload definitions (
packages/core/src/hooks/types.ts) - Hook Manager - Registration and invocation logic (
packages/core/src/hooks/hook-manager.ts) - Runtime Integration - Hook invocation points in executors
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Agent Execution │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ │
│ │ HookManager │◄─── Composed from agent + execution hooks │
│ └──────┬──────┘ │
│ │ │
│ │ invoke('onAgentStart', payload, context) │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Parent Hooks │──►│ Agent Hooks │──►│ Exec Hooks │ │
│ │ (sub-agents) │ │ (defineAgent)│ │ (execute()) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ Hook Points: │
│ • onAgentStart • beforeLLMCall • beforeTool │
│ • onAgentComplete • afterLLMCall • afterTool │
│ • onAgentFail • onStateChange • beforeSubAgent │
│ • onMessage • afterSubAgent │
│ (onTextDelta and onThinking are defined but not yet wired) │
└─────────────────────────────────────────────────────────────┘HookManager Implementation
DefaultHookManager
The primary hook manager implementation:
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.
// Registration order determines execution order
manager.register(loggingHooks); // Runs first
manager.register(metricsHooks); // Runs second
manager.register(auditHooks); // Runs thirdError Propagation: Hook errors propagate up and fail the agent. This is intentional—hooks are part of execution, not advisory.
// If loggingHooks.onAgentStart throws, the agent fails
await manager.invoke('onAgentStart', payload, context);Parent-Child Hierarchy: Sub-agents inherit parent hooks via createChild().
// In sub-agent executor
const childManager = parentManager.createChild();
childManager.register(subAgentHooks); // Sub-agent can add its own
await childManager.invoke('onAgentStart', ...); // Parent hooks run firstNoopHookManager
Performance optimization when no hooks are configured:
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:
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:
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:
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:
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:
function createHookContext<TState, TOutput>(
options: CreateHookContextOptions<TState, TOutput>
): HookContext<TState, TOutput> {
return {
// Read-only execution info
runId: options.runId,
agentType: options.agentType,
streamId: options.streamId,
stepCount: options.stepCount,
parentAgentId: options.parentAgentId,
abortSignal: options.abortSignal,
// Session context (for tracing platforms)
sessionId: options.sessionId,
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.runId,
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()):
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:
| Hook | Location | When Invoked |
|---|---|---|
onAgentStart | runLoop() entry | Before first LLM call |
beforeLLMCall | Before llmAdapter.generateStep() | Each LLM call |
afterLLMCall | After planStepProcessing() | Each LLM response |
onMessage | After message append | User, assistant, tool messages |
beforeTool | tool-executor.ts | Before tool.execute() |
afterTool | tool-executor.ts | After tool completes |
onStateChange | tool-executor.ts | After state mutation |
beforeSubAgent | subagent-executor.ts | Before sub-agent starts |
afterSubAgent | subagent-executor.ts | After sub-agent completes |
onAgentComplete | runLoop() exit | On successful completion |
onAgentFail | runLoop() exit | On failure |
Example: Tool Executor Hook Integration (simplified from actual implementation):
// 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.
Activity Setup (packages/runtime-temporal/src/activities.ts):
// 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:
// 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 WorkflowStepExecutor at initialization time.
// packages/runtime-cloudflare/src/steps.ts
export class WorkflowStepExecutor {
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 };
}
}Sub-Agent Hook Inheritance
When a sub-agent executes, it inherits the parent's hooks via createChild():
// 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
await parentHookManager.invoke('beforeSubAgent', {
call,
agentConfig: subAgentConfig,
parentRunId: parentContext.runId,
}, 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,
result: result.output,
success: result.status === 'completed',
error: result.error,
durationMs: result.durationMs,
subAgentRunId: result.runId,
}, 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 hooksDesign 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.
// 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:
- Manager method call
- Parent chain traversal
- Handler array iteration
- 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
// Guard pattern for performance
if (hookManager.hasHooks()) {
await hookManager.invoke('beforeTool', payload, context);
}Note: The
onTextDeltaandonThinkinghooks 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:
// 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
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
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
- Hooks Guide - User-facing documentation
- Hooks API Reference - Type definitions
- Step Processing - LLM step handling
- Sub-Agent Execution - Sub-agent patterns