AI agent skill · by @lobehub
Agent Runtime Hooks
Use for agent lifecycle hooks, tool mocks, intervention, sub-agent calls and context compression.
Archived 2026-10-23 · Source updated 2026-10-01. Check the original repository for the latest version.
SKILL.md instructions
# Agent Runtime Hooks
Register lifecycle hooks through `execAgent({ hooks })`. `HookDispatcher` stores them per operation and dispatches them in registration order.
## Hook Types
16 hook types across 5 categories:
```
execAgent({ hooks })
│
├─ beforeStep ──────────── Before each step executes
│ │
│ ├─ [call_llm] LLM inference
│ │
│ ├─ [call_tool]
│ │ ├─ beforeToolCall ── Before tool executes (supports mocking)
│ │ ├─ (tool execution)
│ │ ├─ afterToolCall ─── After tool completes (observation only)
│ │ └─ onToolCallError ─ Tool threw an exception
│ │
│ ├─ [request_human_approve]
│ │ ├─ beforeHumanIntervention ── Before agent pauses
│ │ ├─ afterHumanIntervention ─── After approve/reject + resume
│ │ └─ onStopByHumanIntervention ── User rejected, agent halted
│ │
│ ├─ [compress_context]
│ │ ├─ beforeCompact ──── Before compression starts
│ │ ├─ afterCompact ───── After compression completes
│ │ └─ onCompactError ─── Compression failed
│ │
│ ├─ [callAgent] (via execSubAgentTask)
│ │ ├─ beforeCallAgent ── Before sub-agent starts
│ │ ├─ afterCallAgent ─── After creation/start returns
│ │ └─ onCallAgentError ── Sub-agent failed
│ │
│ └─ afterStep ──────────── After step completes
│
├─ (next step...)
│
├─ onComplete ───────────── Operation reaches terminal state
└─ onError ──────────────── Error during execution
```
## Key Files
| File | Role |
| ---------------------------------------------------------------------- | --------------------------------------------------------- |
| `packages/agent-runtime/src/types/hooks.ts` | Event types and required/optional fields |
| `apps/server/src/services/agentRuntime/hooks/types.ts` | Registration and webhook types |
| `apps/server/src/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, local mocks, HTTP/QStash delivery |
| `apps/server/src/services/agentRuntime/hooks/webhookPayload.ts` | Event projection and email enrichment |
| `apps/server/src/modules/AgentRuntime/adapters/toolCallHookContext.ts` | Shared tool-event context builder |
| `apps/server/src/modules/AgentRuntime/adapters/ServerToolTransport.ts` | Tool notifications and execution results |
| `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` | Tool, compression and human-intervention execution |
| `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` | Step events and human-intervention continuation |
| `apps/server/src/services/agentRuntime/CompletionLifecycle.ts` | Terminal events |
| `apps/server/src/services/aiAgent/subAgentRuns.ts` | Sub-agent events |
## Registration
```ts
const hooks: AgentHook[] = [
{
id: 'observe-step',
type: 'afterStep',
handler: async (event) => {
console.log(event.operationId, event.stepIndex);
},
},
];
await aiAgentService.execAgent({ agentId, prompt, hooks });
```
Completion cleans up registrations through `hookDispatcher.unregister(operationId)`.
### Webhook Hooks
Webhook-only hooks deliver in both local and queue modes. For hooks with both `handler` and `webhook`, local dispatch calls the handler; queue dispatch uses the serialized webhook. Only webhook configuration is persisted, so functions are unavailable after a process restart.
```ts
const hook: AgentHook = {
id: 'tool-notification',
type: 'afterToolCall',
matcher: '^fs/readFile$',
webhook: {
url: 'https://example.com/hooks',
delivery: 'fetch',
timeout: 5, // seconds; default 30
headers: { Authorization: 'Bearer ${HOOK_TOKEN}' },
allowedEnvVars: ['HOOK_TOKEN'],
},
};
```
- `matcher` is a regex against `${identifier}/${apiName}`, supported only on tool events. Omitted, empty, or `*` matches all tools.
- Header environment templates resolve only at send time from `allowedEnvVars`; persist templates, never resolved secrets.
- Notifications ignore response content and may return HTTP 204.
- `beforeToolCall` with `responseHandling: 'toolCall'` awaits an HTTP control response before tool execution. Return HTTP 200 JSON with `{ "decision": "allow" }` or `{ "decision": "deny", "reason": "禁止执行该操作" }`. The type is `{ decision: 'allow' } | { decision: 'deny'; reason?: string }`; a deny without reason uses `Blocked by beforeToolCall hook.`. Denials preserve the reason in the tool result/card with classification `hook_denied`.
- Empty bodies, HTTP 204 or other non-200 status, invalid JSON, missing/invalid decisions, and responses containing only the old nested `hookSpecificOutput` format are protocol errors. They follow the control hook's `onError: 'continue' | 'block'` policy (default `continue`); an empty response is never an allow decision.
- Extra response fields are allowed and discarded. Only `decision` and a deny's optional string `reason` are consumed; `updatedInput`/`additionalContext` do not change tool arguments or conversation context.
- Configuration: `packages/types/src/agentHook.ts`; response parsing: `packages/types/src/agentHookResponse.ts`; HTTP delivery: `apps/server/src/services/agentRuntime/hooks/httpWebhook.ts`.
## Events
| Event | Timing and payload |
| --------------------------- | ------------------------------------------------------------------------------------------ |
| `beforeStep` | Before a step; `AgentHookEvent` |
| `afterStep` | After a step; content, tool calls/results and usage totals |
| `onComplete` | Terminal state; reason such as `done`, `error`, `interrupted`, `max_steps` or `cost_limit` |
| `onError` | Operation error; `errorMessage`, `errorDetail` and available error metadata |
| `beforeToolCall` | Before tool execution; shared tool context and local `mock()` callback |
| `afterToolCall` | After tool execution; structured `result` and `mocked` |
| `onToolCallError` | Tool execution throws; shared tool context and `error` |
| `beforeHumanIntervention` | Before approval; `pendingTools`, `operationId`, `stepIndex` |
| `afterHumanIntervention` | Approval decision and continuation; `action`, optional `toolCallId` and `rejectionReason` |
| `onStopByHumanIntervention` | Human rejection stops the run; optional `toolCallId` and `rejectionReason` |
| `beforeCompact` | Before compression; `messageCount`, `tokenCount`, `stepIndex` |
| `afterCompact` | After compression; `groupId`, `messagesBefore`, `messagesAfter`, `summary` |
| `onCompactError` | Compression error; `error`, `tokenCount`, `stepIndex` |
| `beforeCallAgent` | Before sub-agent creation; `agentId`, `instruction` |
| `afterCallAgent` | Creation/start returns; `agentId`, `subOperationId`, `success`, optional `threadId` |
| `onCallAgentError` | Sub-agent call fails; `agentId`, `error` |
CallAgent events dispatch on the parent operation identified by `parentOperationId`. An isolated child supplies `threadId`; shared group members use the shared conversation. Child completion is reported by the child's `onComplete` event.
## Tool Context
Use `buildToolCallHookContext()` for the three tool events.
- Required fields: native `toolCallId`, `assistantMessageId`, `identifier`, `apiName`, effective `args`, `callIndex`, `stepIndex`, `operationId` and `executor`.
- Optional associations come from the run context: agent, topic, session, thread, group, task, workspace, document, source/tool message and parent operation IDs.
- `userId` uses `runtime.userId ?? origin.userId`.
- `toolSource` identifies the tool's origin; `executor` identifies the server/client dispatch route. `executionTarget` and `activeDeviceId` describe the run's execution plan and device selection.
- `afterToolCall.result` carries `content`, `success`, optional `executionTime` and error/state data such as `state.type: 'blocked'`. `mocked` marks results supplied by a local hook.
Sandbox and MCP tools use the same event path. Filter with `identifier`, `apiName` or `toolSource`.
### Local Mocking
`dispatchBeforeToolCall()` exposes `event.mock(result)`. The first accepted mock wins and short-circuits the remaining mock handlers. The method returns `{ isMocked: true, result }` for a mock, or `null` to continue execution.
```ts
event.mock({ content: '{"items":[]}', success: true });
```
Tool observation payloads use `BeforeToolCallObservationEvent`; the callback belongs to the in-memory handler event. `dispatchBeforeToolCall()` does not deliver webhooks.
## Webhook Payloads
`createWebhookPayloadBuilder()` selects `eventFields`, adds `hookId`/`hookType`, then merges `webhook.body`. The body determines the final value of overlapping fields. Tool-result payloads use `redactResultForEvents()` to trim raw skill Work data; Work registration retains the full in-process result. `finalState` is available to local handlers; serialized payloads carry the event's data fields.
When email is selected, the builder resolves `userEmail` from the final effective `userId`, replacing supplied email values. Email-only projections use the event ID. An explicit invalid ID, missing email or lookup error leaves email omitted.
Email queries share a per-dispatcher cache of up to 1,000 users for five minutes. Query timeouts belong to the database layer. The builder's optional fourth `{ signal }` argument lets a waiter cancel independently; cancellation returns `undefined` so the caller can stop delivery. Fetch and QStash use the enriched payload.
## Delivery Errors
Dispatch awaits each handler or webhook. Ordinary errors are logged and dispatch continues. Webhooks with `fallback: 'none'` accumulate a `CriticalHookDeliveryError`, which is thrown after sibling hooks have run. QStash delivery defaults to a fetch fallback; use `fallback: 'none'` for QStash-signed endpoints.