793 lines
16 KiB
Markdown
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.
|