Files
2026-07-29 10:59:18 +07:00

16 KiB

Hook System Reference

Overview

The hook system provides extensibility points at both the Agent and AgentHarness layers. Hooks are asynchronous, can be cancelled via abort signal, and run in subscription order.


Hook Categories

1. Message Transformation Hooks

convertToLlm

Location: AgentLoopConfig.convertToLlm

Purpose: Convert AgentMessage[] to Message[] before LLM call.

When called: Just before each LLM request.

Key contract:

  • Must not throw or reject
  • Must handle all AgentMessage variants
  • Filter out UI-only messages (notifications, artifacts, etc.)

Example:

convertToLlm: (messages) => messages.filter(m => 
  m.role === "user" || 
  m.role === "assistant" || 
  m.role === "toolResult"
)

transformContext

Location: AgentLoopConfig.transformContext (optional)

Purpose: Manipulate context before LLM conversion.

When called: Before convertToLlm.

Use cases:

  • Context window management (pruning old messages)
  • Injecting external context
  • Message deduplication

Example:

transformContext: async (messages, signal) => {
  if (estimateTokens(messages) > MAX_TOKENS) {
    return pruneOldMessages(messages);
  }
  return messages;
}

2. Lifecycle Hooks

beforeToolCall

Location: AgentLoopConfig.beforeToolCall (optional)

Context:

interface BeforeToolCallContext {
  assistantMessage: AssistantMessage;
  toolCall: AgentToolCall;
  args: unknown;  // Validated against tool schema
  context: AgentContext;
}

Return:

interface BeforeToolCallResult {
  block?: boolean;  // If true, tool won't execute
  reason?: string;  // Error message shown in tool result
}

When called: After args validation, before tool execution.

Use cases:

  • Permission checks (user approval)
  • Rate limiting
  • Context-aware tool blocking

Example:

beforeToolCall: async ({ toolCall, args, context }, signal) => {
  if (toolCall.name === "bash" && signal?.aborted) {
    return { block: true, reason: "Operation aborted" };
  }
  return undefined;  // Allow execution
}

afterToolCall

Location: AgentLoopConfig.afterToolCall (optional)

Context:

interface AfterToolCallContext {
  assistantMessage: AssistantMessage;
  toolCall: AgentToolCall;
  args: unknown;
  result: AgentToolResult<any>;
  isError: boolean;
  context: AgentContext;
}

Return:

interface AfterToolCallResult {
  content?: (TextContent | ImageContent)[];
  details?: unknown;
  isError?: boolean;
  usage?: Usage;
  terminate?: boolean;  // Early termination hint
}

When called: After tool execution, before emitting tool_execution_end.

Use cases:

  • Modify tool results (redact sensitive data)
  • Update usage tracking
  • Trigger early termination

Example:

afterToolCall: async ({ result }, signal) => {
  // Redact sensitive content
  const content = result.content.map(c => {
    if (c.type === "text") {
      return { ...c, text: redactSecrets(c.text) };
    }
    return c;
  });
  
  return { content };
}

shouldStopAfterTurn

Location: AgentLoopConfig.shouldStopAfterTurn (optional)

Context:

interface ShouldStopAfterTurnContext {
  message: AssistantMessage;
  toolResults: ToolResultMessage[];
  context: AgentContext;
  newMessages: AgentMessage[];
}

Return: boolean

When called: After turn_end, before draining steering/follow-up queues.

Use cases:

  • Stop when goal achieved
  • Stop before context gets too large
  • Error recovery

Example:

shouldStopAfterTurn: async ({ message, toolResults, context }) => {
  // Stop if model indicates task complete
  if (message.content.some(c => 
      c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
    return true;
  }
  
  // Stop if context too large
  if (estimateTokens(context.messages) > MAX_TOKENS * 0.8) {
    return true;
  }
  
  return false;
}

prepareNextTurn

Location: AgentLoopConfig.prepareNextTurn (optional)

Context: Same as ShouldStopAfterTurnContext

Return:

interface AgentLoopTurnUpdate {
  context?: AgentContext;
  model?: Model<any>;
  thinkingLevel?: ThinkingLevel;
}

When called: After shouldStopAfterTurn, if not stopping.

Use cases:

  • Update model based on conversation context
  • Switch thinking level
  • Inject new context

Example:

prepareNextTurn: async ({ message, toolResults, context }) => {
  // Switch to higher reasoning for complex tasks
  if (toolResults.length > 3) {
    return {
      thinkingLevel: "high"
    };
  }
  
  return undefined;  // Keep current config
}

3. Queue Draining Hooks

getSteeringMessages

Location: AgentLoopConfig.getSteeringMessages (optional)

Return: Promise<AgentMessage[]>

When called: After turn ends, before next LLM call.

Purpose: Inject messages to interrupt agent mid-workflow.

Mode: "all" or "one-at-a-time" (controls how many messages injected)

Example:

getSteeringMessages: async () => {
  // Check for user input while agent is working
  if (userQueue.length > 0) {
    return userQueue.splice(0, 1);  // one-at-a-time mode
  }
  return [];
}

getFollowUpMessages

Location: AgentLoopConfig.getFollowUpMessages (optional)

Return: Promise<AgentMessage[]>

When called: When agent would stop (no more tool calls, no steering messages).

Purpose: Queue messages for after agent finishes.

Mode: "all" or "one-at-a-time"

Example:

getFollowUpMessages: async () => {
  // Check if user typed while agent was working
  if (followUpQueue.length > 0) {
    return followUpQueue.splice(0, 1);
  }
  return [];
}

AgentHarness Hooks

1. System Prompt Hooks

before_agent_start

Location: AgentHarness.on("before_agent_start")

Event:

{
  type: "before_agent_start";
  prompt: string;
  images?: ImageContent[];
  systemPrompt: string;
  resources: AgentHarnessResources;
}

Return:

{
  messages?: AgentMessage[];
  systemPrompt?: string;
}

When called: Before agent starts, after system prompt generated.

Use cases:

  • Add conversation hints
  • Inject images
  • Modify system prompt

2. Context Hooks

context

Location: AgentHarness.on("context")

Event:

{
  type: "context";
  messages: AgentMessage[];
}

Return:

{
  messages: AgentMessage[];
}

When called: Before convertToLlm.

Use cases:

  • Message filtering
  • Context window management
  • Message augmentation

3. Tool Hooks

tool_call

Location: AgentHarness.on("tool_call")

Event:

{
  type: "tool_call";
  toolCallId: string;
  toolName: string;
  input: Record<string, unknown>;
}

Return:

{
  block?: boolean;
  reason?: string;
}

When called: Before tool execution.

Use cases:

  • Audit logging
  • Approval workflows
  • Input validation

tool_result

Location: AgentHarness.on("tool_result")

Event:

{
  type: "tool_result";
  toolCallId: string;
  toolName: string;
  input: Record<string, unknown>;
  content: (TextContent | ImageContent)[];
  details: unknown;
  isError: boolean;
  usage?: Usage;
}

Return:

{
  content?: (TextContent | ImageContent)[];
  details?: unknown;
  isError?: boolean;
  usage?: Usage;
  terminate?: boolean;
}

When called: After tool execution.

Use cases:

  • Result transformation
  • Usage tracking
  • Early termination

4. Session Hooks

session_before_compact

Location: AgentHarness.on("session_before_compact")

Event:

{
  type: "session_before_compact";
  preparation: BranchPreparation;
  branchEntries: SessionTreeEntry[];
  customInstructions?: string;
  signal: AbortSignal;
}

Return:

{
  cancel?: boolean;
  compaction?: CompactionResult;
}

When called: Before compaction.

Use cases:

  • Skip compaction in certain conditions
  • Provide custom summary
  • Abort compaction

session_before_tree

Location: AgentHarness.on("session_before_tree")

Event:

{
  type: "session_before_tree";
  preparation: {
    targetId: string;
    oldLeafId: string;
    commonAncestorId: string;
    entriesToSummarize: SessionTreeEntry[];
    userWantsSummary: boolean;
    customInstructions?: string;
    replaceInstructions?: boolean;
    label?: string;
  };
  signal: AbortSignal;
}

Return:

{
  cancel?: boolean;
  summary?: {
    summary: string;
    details?: unknown;
    usage?: Usage;
  };
  customInstructions?: string;
  replaceInstructions?: boolean;
}

When called: Before tree navigation (branching).

Use cases:

  • Skip branch summary
  • Provide custom summary
  • Cancel navigation

5. Provider Hooks

before_provider_request

Location: AgentHarness.on("before_provider_request")

Event:

{
  type: "before_provider_request";
  model: Model<any>;
  sessionId: string;
  streamOptions: AgentHarnessStreamOptions;
}

Return:

{
  streamOptions: AgentHarnessStreamOptionsPatch;
}

When called: Just before each LLM request.

Use cases:

  • Add authentication headers
  • Set request metadata
  • Configure caching

before_provider_payload

Location: AgentHarness.on("before_provider_payload")

Event:

{
  type: "before_provider_payload";
  model: Model<any>;
  payload: unknown;
}

Return:

{
  payload: unknown;
}

When called: Just before sending payload to LLM.

Use cases:

  • Payload transformation
  • Debug logging
  • Schema validation

Hook Execution Order

Full Turn Flow

1. AgentHarness.prompt()
   │
   ├─► emit "before_agent_start"
   │    └─► Hook can return new messages/systemPrompt
   │
   ▼
2. AgentLoopConfig creation
   │
   ├─► transformContext hook → AgentLoop.transformContext
   ├─► convertToLlm hook → AgentLoop.convertToLlm
   ├─► beforeToolCall hook → AgentLoop.beforeToolCall
   ├─► afterToolCall hook → AgentLoop.afterToolCall
   ├─► prepareNextTurn hook → AgentLoop.prepareNextTurn
   ├─► shouldStopAfterTurn hook → AgentLoop.shouldStopAfterTurn
   ├─► getSteeringMessages hook → AgentLoop.getSteeringMessages
   └─► getFollowUpMessages hook → AgentLoop.getFollowUpMessages
   │
   ▼
3. streamAssistantResponse()
   │
   ├─► emit "before_provider_request" (harness)
   │    └─► Hook can modify stream options
   ├─► transformContext() (agent)
   ├─► convertToLlm() (agent)
   ├─► streamFn() → LLM call
   └─► Emit message_start/update/end events
   │
   ▼
4. executeToolCalls()
   │
   ├─► For each tool call:
   │    ├─► emit "tool_call" (harness)
   │    │    └─► Hook can block execution
   │    ├─► tool.execute()
   │    └─► emit "tool_result" (harness)
   │         └─► Hook can override result
   │
   ▼
5. turn_end
   │
   ├─► emit "turn_end" (agent)
   ├─► shouldStopAfterTurn() (agent)
   │    └─► Return true to exit
   ├─► prepareNextTurn() (agent)
   │    └─► Hook can update context/model/thinkingLevel
   ├─► Drain steering queue
   └─► Drain follow-up queue
   │
   ├─► If steering/follow-up: repeat from #3
   └─► If no more: agent_end
       │
       └─► emit "agent_end" (agent)

Queue Mode Behavior

"all" Mode

All queued messages are injected at once:

Agent would continue...
  → getFollowUpMessages returns [msg1, msg2, msg3]
    → All three injected together
      → Agent processes all before next turn

"one-at-a-time" Mode

One message injected at a time:

Agent would continue...
  → getFollowUpMessages returns [msg1]
    → msg1 injected
      → Agent processes msg1
        → After turn, getFollowUpMessages returns [msg2]
          → msg2 injected
            → Agent processes msg2
              → ...and so on

Abort Signal Propagation

All hooks receive an optional AbortSignal:

interface BeforeToolCallContext {
  // ... other fields
  // signal is NOT included - use agent.signal instead
}

Agent hooks:

  • transformContext: receives signal
  • beforeToolCall: receives signal
  • afterToolCall: receives signal

Harness hooks:

  • before_agent_start: receives signal
  • context: NO signal
  • tool_call: NO signal
  • tool_result: NO signal
  • session_before_compact: receives signal
  • session_before_tree: receives signal
  • before_provider_request: receives signal
  • before_provider_payload: NO signal

Error Handling

Hook Errors

Agent layer: Hook errors are caught and encoded in tool results:

try {
  const beforeResult = await config.beforeToolCall(...);
  if (beforeResult?.block) {
    return immediateError(beforeResult.reason);
  }
} catch (error) {
  return immediateError(error.message);
}

Harness layer: Hook errors are wrapped and re-thrown:

try {
  const result = await handler(event);
} catch (error) {
  throw normalizeHookError(error);
}

Best Practices

  1. Always handle errors: Wrap async operations in try/catch
  2. Respect abort signals: Check signal.aborted in long operations
  3. Return safe defaults: Return empty arrays/objects on errors
  4. Don't block: Hooks should be fast (no network calls)
  5. Idempotent: Hooks should be safe to run multiple times

Common Patterns

1. Context Window Management

transformContext: async (messages, signal) => {
  if (signal?.aborted) return messages;
  
  const tokenCount = estimateTokens(messages);
  if (tokenCount > MAX_TOKENS * 0.9) {
    return pruneOldestMessages(messages, Math.floor(MAX_TOKENS * 0.3));
  }
  return messages;
}

2. Permission-Gated Tools

beforeToolCall: async ({ toolCall, args }, signal) => {
  if (toolCall.name === "bash" && signal?.aborted) {
    return { block: true, reason: "Operation aborted" };
  }
  
  if (toolCall.name === "bash" && !await canExecuteBash(args)) {
    return { block: true, reason: "Permission denied" };
  }
  
  return undefined;
}

3. Result Redaction

afterToolCall: async ({ result }) => {
  const content = result.content.map(c => {
    if (c.type === "text") {
      return { ...c, text: redactSecrets(c.text) };
    }
    return c;
  });
  
  return { content };
}

4. Early Termination

shouldStopAfterTurn: async ({ message }) => {
  // Check if model indicates completion
  if (message.content.some(c => 
      c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
    return true;
  }
  
  // Check if all tool calls set terminate
  return false;
}

5. Audit Logging

tool_call: async ({ toolCallId, toolName, input }) => {
  console.log(`[TOOL_CALL] ${toolName} (${toolCallId}):`, input);
  return undefined;
}

tool_result: async ({ toolCallId, toolName, content, isError }) => {
  console.log(`[TOOL_RESULT] ${toolName} (${toolCallId}):`, {
    hasError: isError,
    contentLength: content.length
  });
  return undefined;
}

Summary

Hook Layer When Can Block?
convertToLlm Agent Before LLM call No (sync)
transformContext Agent Before convertToLlm Yes (async)
beforeToolCall Agent After validation Yes (async)
afterToolCall Agent After execution Yes (async)
shouldStopAfterTurn Agent After turn_end Yes (async)
prepareNextTurn Agent Before next turn Yes (async)
getSteeringMessages Agent After turn_end Yes (async)
getFollowUpMessages Agent When agent would stop Yes (async)

All hooks are optional and have sensible defaults.