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

793 lines
16 KiB
Markdown

# 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**:
```typescript
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**:
```typescript
transformContext: async (messages, signal) => {
if (estimateTokens(messages) > MAX_TOKENS) {
return pruneOldMessages(messages);
}
return messages;
}
```
---
### 2. Lifecycle Hooks
#### `beforeToolCall`
**Location**: `AgentLoopConfig.beforeToolCall` (optional)
**Context**:
```typescript
interface BeforeToolCallContext {
assistantMessage: AssistantMessage;
toolCall: AgentToolCall;
args: unknown; // Validated against tool schema
context: AgentContext;
}
```
**Return**:
```typescript
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**:
```typescript
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**:
```typescript
interface AfterToolCallContext {
assistantMessage: AssistantMessage;
toolCall: AgentToolCall;
args: unknown;
result: AgentToolResult<any>;
isError: boolean;
context: AgentContext;
}
```
**Return**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
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**:
```typescript
{
type: "before_agent_start";
prompt: string;
images?: ImageContent[];
systemPrompt: string;
resources: AgentHarnessResources;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "context";
messages: AgentMessage[];
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "tool_call";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "tool_result";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
content: (TextContent | ImageContent)[];
details: unknown;
isError: boolean;
usage?: Usage;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "session_before_compact";
preparation: BranchPreparation;
branchEntries: SessionTreeEntry[];
customInstructions?: string;
signal: AbortSignal;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "session_before_tree";
preparation: {
targetId: string;
oldLeafId: string;
commonAncestorId: string;
entriesToSummarize: SessionTreeEntry[];
userWantsSummary: boolean;
customInstructions?: string;
replaceInstructions?: boolean;
label?: string;
};
signal: AbortSignal;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "before_provider_request";
model: Model<any>;
sessionId: string;
streamOptions: AgentHarnessStreamOptions;
}
```
**Return**:
```typescript
{
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**:
```typescript
{
type: "before_provider_payload";
model: Model<any>;
payload: unknown;
}
```
**Return**:
```typescript
{
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`:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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.