🔨 chore: add the agent runtime tools call hooks (#13874)

* feat: add the agent runtime tools call hooks

* feat: add more agent runtime hooks

* fix: add the lost hooks

* fix: add the agent runtimes hooks test

* fix: slove some error

* fix: change the as any to hooksEvent

* fix: slove the lint error

* fix: slove the lint error

* fix: slove the lint error

* fix: clean the code

* fix: change the toolCallCounts into all mode & add all hooks into qstash runtime way

* 🐛 fix: harden beforeToolCall mock validation and remove userId fallbacks

- dispatchBeforeToolCall returns { content, isMocked } instead of { content } | null
  for explicit mock detection (avoids falsy content edge cases)
- mock() rejects invalid content: empty string, undefined, object, array, number, null
- Remove all `userId: ctx.userId || ''` fallbacks — userId absence should surface, not silently degrade
- beforeToolCall adds separate dispatch() observation path for QStash webhook delivery
- Add BeforeToolCallObservationEvent type for production webhook payload
- Add 3 unit tests for mock content validation edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LiJian
2026-04-24 19:09:11 +08:00
committed by GitHub
parent 600f10fcea
commit 0e11d3d9c0
12 changed files with 1680 additions and 17 deletions
+209
View File
@@ -0,0 +1,209 @@
---
name: agent-runtime-hooks
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
user-invocable: false
---
# Agent Runtime Hooks
Lifecycle hooks for observing and intercepting agent execution. Hooks are registered per-operation via `execAgent({ hooks })` and dispatched by `HookDispatcher`.
## 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 sub-agent completes
│ │ └─ 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` | Type definitions (AgentHookType, all event interfaces) |
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
## Registration Flow
```ts
const hooks: AgentHook[] = [
{ id: 'my-hook', type: 'afterStep', handler: async (event) => { ... } },
];
await aiAgentService.execAgent({ agentId, prompt, hooks });
// Internally: hookDispatcher.register(operationId, hooks)
// Cleanup: hookDispatcher.unregister(operationId)
```
## Hook Reference
### Step Level
**`beforeStep`** — Before each step. `event: AgentHookEvent`
**`afterStep`** — After each step. `event: AgentHookEvent` (content, toolsCalling, totalCost, etc.)
**`onComplete`** — Terminal state. `event: AgentHookEvent` (reason: done/error/interrupted/max_steps/cost_limit)
**`onError`** — Error occurred. `event: AgentHookEvent` (errorMessage, errorDetail)
### Tool Call Level
**`beforeToolCall`** — Before tool executes. **Supports mocking** via `event.mock()`.
```ts
// event: ToolCallHookEvent
{
(identifier, apiName, args, callIndex, stepIndex, operationId, mock);
}
// Mock example:
event.mock({ content: '{"error":"rate limited"}' });
```
Dispatch method: `hookDispatcher.dispatchBeforeToolCall()` (returns mock result or null).
**`afterToolCall`** — After tool completes. Observation only.
```ts
// event: AfterToolCallHookEvent
{
(identifier, apiName, args, callIndex, content, success, mocked, executionTimeMs, stepIndex);
}
```
**`onToolCallError`** — Tool threw an exception (catch block, not just `success=false`).
```ts
// event: ToolCallErrorHookEvent
{
(identifier, apiName, args, callIndex, error, stepIndex);
}
```
### Human Intervention
**`beforeHumanIntervention`** — Before agent pauses for approval.
```ts
// event: BeforeHumanInterventionHookEvent
{ operationId, stepIndex, pendingTools: [{ identifier, apiName }] }
```
**`afterHumanIntervention`** — After approve/reject, agent resumes.
```ts
// event: AfterHumanInterventionHookEvent
{ operationId, action: 'approve' | 'reject' | 'rejectAndContinue', toolCallId?, rejectionReason? }
```
**`onStopByHumanIntervention`** — User rejected, agent halted.
```ts
// event: StopByHumanInterventionHookEvent
{ operationId, toolCallId?, rejectionReason? }
```
### Context Compression
**`beforeCompact`** — Before compression starts.
```ts
// event: BeforeCompactHookEvent
{
(operationId, stepIndex, messageCount, tokenCount);
}
```
**`afterCompact`** — After compression completes.
```ts
// event: AfterCompactHookEvent
{
(operationId, stepIndex, groupId, messagesBefore, messagesAfter, summary);
}
```
**`onCompactError`** — Compression failed.
```ts
// event: CompactErrorHookEvent
{
(operationId, stepIndex, tokenCount, error);
}
```
### Sub-Agent (CallAgent)
**`beforeCallAgent`** — Before calling sub-agent. Dispatched on **parent** operation.
```ts
// event: BeforeCallAgentHookEvent
{
(operationId, agentId, instruction);
}
```
**`afterCallAgent`** — Sub-agent completed. Dispatched on **parent** operation.
```ts
// event: AfterCallAgentHookEvent
{
(operationId, agentId, subOperationId, threadId, success);
}
```
**`onCallAgentError`** — Sub-agent failed. Dispatched on **parent** operation.
```ts
// event: CallAgentErrorHookEvent
{
(operationId, agentId, error);
}
```
Note: CallAgent hooks require `parentOperationId` in `ExecSubAgentTaskParams`.
## Design Notes
- **Fire-and-forget**: All handlers return `Promise<void>`. Errors are non-fatal.
- **Exception**: `beforeToolCall` supports mock via `event.mock()` — uses `dispatchBeforeToolCall()` which returns the mock result.
- **Sequential**: Same-type hooks run in registration order.
- **Local only**: `beforeToolCall` mock only works in local mode (in-memory hooks). Webhook mode does not support mocking.
- **Scoped per operation**: Auto-cleaned via `hookDispatcher.unregister()` on completion.
- **Sandbox/MCP**: No separate hooks — they go through `executeTool`, so `beforeToolCall`/`afterToolCall` cover them. Use `event.identifier` to filter.
## Real-World Example: agent-evals
See `devtools/agent-evals/helpers/runner.ts``createEvalHooks()` uses `afterStep`, `onComplete`, `afterToolCall`, and `beforeToolCall` (for mock).
+155 -1
View File
@@ -11,9 +11,21 @@
*/
export type AgentHookType =
| 'afterStep' // After each step completes
| 'afterToolCall' // After a tool call completes (observation only)
| 'beforeStep' // Before each step executes
| 'beforeToolCall' // Before a tool call executes (supports mocking via event.mock())
| 'beforeCallAgent' // Before calling a sub-agent
| 'afterCallAgent' // After sub-agent completes
| 'beforeCompact' // Before context compression starts
| 'beforeHumanIntervention' // Before agent pauses for human approval
| 'afterCompact' // After context compression completes
| 'afterHumanIntervention' // After human approves/rejects and agent resumes
| 'onCallAgentError' // Sub-agent execution failed
| 'onCompactError' // Context compression failed
| 'onComplete' // Operation reaches terminal state (done/error/interrupted)
| 'onError'; // Error during execution
| 'onStopByHumanIntervention' // Human rejected and agent halted
| 'onError' // Error during execution
| 'onToolCallError'; // Tool call threw an exception (not just success=false)
/**
* Unified event payload passed to hook handlers and webhook payloads
@@ -90,3 +102,145 @@ export interface AgentHookEvent {
userId: string;
}
/**
* Event payload for beforeToolCall hooks.
* Call `mock()` to skip real tool execution and return a fake result.
*/
export interface ToolCallHookEvent {
apiName: string;
args: Record<string, any>;
callIndex: number;
identifier: string;
mock: (result: { content: string }) => void;
operationId: string;
stepIndex: number;
}
/**
* Event payload for beforeToolCall observation dispatch (webhook/logging).
* Same fields as ToolCallHookEvent but without mock() — used for production webhook delivery.
*/
export interface BeforeToolCallObservationEvent {
apiName: string;
args: Record<string, any>;
callIndex: number;
identifier: string;
operationId: string;
stepIndex: number;
userId?: string;
}
export interface AfterToolCallHookEvent {
apiName: string;
args: Record<string, any>;
callIndex: number;
content: string;
executionTimeMs: number;
identifier: string;
mocked: boolean;
operationId: string;
stepIndex: number;
success: boolean;
userId?: string;
}
export interface ToolCallErrorHookEvent {
apiName: string;
args: Record<string, any>;
callIndex: number;
error: string;
identifier: string;
operationId: string;
stepIndex: number;
userId?: string;
}
export interface BeforeCompactHookEvent {
messageCount: number;
operationId: string;
stepIndex: number;
tokenCount: number;
userId?: string;
}
export interface AfterCompactHookEvent {
groupId: string;
messagesAfter: number;
messagesBefore: number;
operationId: string;
stepIndex: number;
summary: string;
userId?: string;
}
export interface CompactErrorHookEvent {
error: string;
operationId: string;
stepIndex: number;
tokenCount: number;
userId?: string;
}
export interface BeforeHumanInterventionHookEvent {
operationId: string;
pendingTools: Array<{ apiName: string; identifier: string }>;
stepIndex: number;
userId?: string;
}
export interface AfterHumanInterventionHookEvent {
action: 'approve' | 'reject' | 'rejectAndContinue';
operationId: string;
rejectionReason?: string;
toolCallId?: string;
userId?: string;
}
export interface StopByHumanInterventionHookEvent {
operationId: string;
rejectionReason?: string;
toolCallId?: string;
userId?: string;
}
export interface BeforeCallAgentHookEvent {
agentId: string;
instruction: string;
operationId: string;
userId?: string;
}
export interface AfterCallAgentHookEvent {
agentId: string;
operationId: string;
subOperationId: string;
success: boolean;
threadId: string;
userId?: string;
}
export interface CallAgentErrorHookEvent {
agentId: string;
error: string;
operationId: string;
userId?: string;
}
/**
* Union of all hook event types for dispatch methods that accept any hook event.
*/
export type AnyHookEvent =
| AfterCallAgentHookEvent
| AfterCompactHookEvent
| AfterHumanInterventionHookEvent
| AfterToolCallHookEvent
| AgentHookEvent
| BeforeCallAgentHookEvent
| BeforeCompactHookEvent
| BeforeHumanInterventionHookEvent
| BeforeToolCallObservationEvent
| CallAgentErrorHookEvent
| CompactErrorHookEvent
| StopByHumanInterventionHookEvent
| ToolCallErrorHookEvent;
@@ -188,6 +188,8 @@ export interface ExecSubAgentTaskParams {
instruction: string;
/** The parent message ID (Supervisor's tool call message or task message) */
parentMessageId: string;
/** Parent operation ID for dispatching callAgent hooks */
parentOperationId?: string;
/** Timeout in milliseconds (optional) */
timeout?: number;
/** Task title (shown in UI, used as thread title) */
@@ -39,6 +39,7 @@ import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering'
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
@@ -200,6 +201,7 @@ export interface RuntimeExecutorContext {
botPlatformContext?: any;
discordContext?: any;
evalContext?: EvalContext;
hookDispatcher?: HookDispatcher;
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
messageModel: MessageModel;
operationId: string;
@@ -1103,6 +1105,23 @@ export const createRuntimeExecutors = (
};
}
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'beforeCompact',
{
messageCount: messagesToCompress.length,
operationId,
stepIndex,
tokenCount: currentTokenCount,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
try {
const dbMessages = await ctx.messageModel.query(
{
@@ -1269,6 +1288,25 @@ export const createRuntimeExecutors = (
type: 'compression_complete',
});
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'afterCompact',
{
groupId: compressionResult.messageGroupId,
messagesAfter: compressedMessages.length,
messagesBefore: messagesToCompress.length,
operationId,
stepIndex,
summary: summaryContent.slice(0, 500),
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
return {
events,
newState,
@@ -1294,6 +1332,23 @@ export const createRuntimeExecutors = (
error,
);
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'onCompactError',
{
error: error instanceof Error ? error.message : String(error),
operationId,
stepIndex,
tokenCount: currentTokenCount,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
events.push({ error, type: 'compression_error' });
return {
@@ -1335,12 +1390,22 @@ export const createRuntimeExecutors = (
type: 'tool_start',
});
// payload is { parentMessageId, toolCalling: ChatToolPayload }
const chatToolPayload: ChatToolPayload = payload.toolCalling;
const toolName = `${chatToolPayload.identifier}/${chatToolPayload.apiName}`;
const existingToolStats = state.usage?.tools?.byTool?.find((t) => t.name === toolName);
const callIndex = (existingToolStats?.calls ?? 0) + 1;
let parsedArgs: Record<string, any> = {};
try {
// payload is { parentMessageId, toolCalling: ChatToolPayload }
const chatToolPayload: ChatToolPayload = payload.toolCalling;
const toolName = `${chatToolPayload.identifier}/${chatToolPayload.apiName}`;
parsedArgs =
typeof chatToolPayload.arguments === 'string'
? JSON.parse(chatToolPayload.arguments)
: (chatToolPayload.arguments ?? {});
} catch {}
try {
// Check if this is a client-side function tool — pause instead of executing
const toolSource =
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
@@ -1401,8 +1466,46 @@ export const createRuntimeExecutors = (
chatToolPayload.executor === 'client' &&
typeof streamManager.sendToolExecute === 'function';
let toolCallMocked = false;
const hookResult = ctx.hookDispatcher
? await (async () => {
// 1. dispatch for observation (webhook in production, local handler logging)
ctx
.hookDispatcher!.dispatch(
operationId,
'beforeToolCall',
{
apiName: chatToolPayload.apiName,
args: parsedArgs,
callIndex,
identifier: chatToolPayload.identifier,
operationId,
stepIndex,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
// 2. dispatchBeforeToolCall for mock support (local-only)
return ctx.hookDispatcher!.dispatchBeforeToolCall(operationId, {
apiName: chatToolPayload.apiName,
args: parsedArgs,
callIndex,
identifier: chatToolPayload.identifier,
stepIndex,
});
})()
: null;
let execution: { result: ToolExecutionResultResponse; attempts: number };
if (canDispatchToClient) {
if (hookResult?.isMocked) {
log(`[${operationLogId}] Tool ${toolName} mocked by beforeToolCall hook`);
toolCallMocked = true;
execution = {
attempts: 0,
result: { content: hookResult.content, executionTime: 0, success: true },
};
} else if (canDispatchToClient) {
log(`[${operationLogId}] Dispatching tool ${toolName} to client via Agent Gateway`);
const dispatchResult = await dispatchClientTool(chatToolPayload, {
operationId,
@@ -1443,6 +1546,28 @@ export const createRuntimeExecutors = (
const executionResult = execution.result;
const executionTime = executionResult.executionTime;
const isSuccess = executionResult.success;
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'afterToolCall',
{
apiName: chatToolPayload.apiName,
args: parsedArgs,
callIndex,
content: executionResult.content,
executionTimeMs: executionTime,
identifier: chatToolPayload.identifier,
mocked: toolCallMocked,
operationId,
stepIndex,
success: isSuccess,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
log(
`[${operationLogId}] Executing ${toolName} in ${executionTime}ms, result: %O`,
executionResult,
@@ -1623,6 +1748,26 @@ export const createRuntimeExecutors = (
// running the agent on a broken conversation chain. See LOBE-7158.
if (isPersistFatal(error)) throw error;
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'onToolCallError',
{
apiName: chatToolPayload.apiName,
args: parsedArgs,
callIndex,
error: error instanceof Error ? error.message : String(error),
identifier: chatToolPayload.identifier,
operationId,
stepIndex,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
// Publish tool execution error event
await streamManager.publishStreamEvent(operationId, {
data: formatErrorEventData(error, 'tool_execution'),
@@ -1723,6 +1868,19 @@ export const createRuntimeExecutors = (
type: 'tool_start',
});
const batchToolName = `${chatToolPayload.identifier}/${chatToolPayload.apiName}`;
const batchExistingStats = state.usage?.tools?.byTool?.find(
(t) => t.name === batchToolName,
);
const batchCallIndex = (batchExistingStats?.calls ?? 0) + 1;
let batchParsedArgs: Record<string, any> = {};
try {
batchParsedArgs =
typeof chatToolPayload.arguments === 'string'
? JSON.parse(chatToolPayload.arguments)
: (chatToolPayload.arguments ?? {});
} catch {}
try {
log(`[${operationLogId}] Executing tool ${toolName} ...`);
// Build effective manifest map (operation + step-level activations)
@@ -1741,8 +1899,44 @@ export const createRuntimeExecutors = (
chatToolPayload.executor === 'client' &&
typeof streamManager.sendToolExecute === 'function';
let batchToolCallMocked = false;
const batchHookResult = ctx.hookDispatcher
? await (async () => {
ctx
.hookDispatcher!.dispatch(
operationId,
'beforeToolCall',
{
apiName: chatToolPayload.apiName,
args: batchParsedArgs,
callIndex: batchCallIndex,
identifier: chatToolPayload.identifier,
operationId,
stepIndex,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
return ctx.hookDispatcher!.dispatchBeforeToolCall(operationId, {
apiName: chatToolPayload.apiName,
args: batchParsedArgs,
callIndex: batchCallIndex,
identifier: chatToolPayload.identifier,
stepIndex,
});
})()
: null;
let execution: { result: ToolExecutionResultResponse; attempts: number };
if (canDispatchToClient) {
if (batchHookResult?.isMocked) {
log(`[${operationLogId}] Tool ${toolName} mocked by beforeToolCall hook`);
batchToolCallMocked = true;
execution = {
attempts: 0,
result: { content: batchHookResult.content, executionTime: 0, success: true },
};
} else if (canDispatchToClient) {
log(`[${operationLogId}] Dispatching tool ${toolName} to client via Agent Gateway`);
const dispatchResult = await dispatchClientTool(chatToolPayload, {
operationId,
@@ -1784,6 +1978,28 @@ export const createRuntimeExecutors = (
const executionResult = execution.result;
const executionTime = executionResult.executionTime;
const isSuccess = executionResult.success;
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'afterToolCall',
{
apiName: chatToolPayload.apiName,
args: batchParsedArgs,
callIndex: batchCallIndex,
content: executionResult.content,
executionTimeMs: executionTime,
identifier: chatToolPayload.identifier,
mocked: batchToolCallMocked,
operationId,
stepIndex,
success: isSuccess,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
log(
`[${operationLogId}] Executed ${toolName} in ${executionTime}ms, success: ${isSuccess}`,
);
@@ -1871,6 +2087,26 @@ export const createRuntimeExecutors = (
throw error;
}
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'onToolCallError',
{
apiName: chatToolPayload.apiName,
args: batchParsedArgs,
callIndex: batchCallIndex,
error: error instanceof Error ? error.message : String(error),
identifier: chatToolPayload.identifier,
operationId,
stepIndex,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
console.error(`[${operationLogId}] Tool execution failed for ${toolName}:`, error);
// Publish error event
@@ -2099,6 +2335,25 @@ export const createRuntimeExecutors = (
type: 'step_start',
});
if (ctx.hookDispatcher) {
ctx.hookDispatcher
.dispatch(
operationId,
'beforeHumanIntervention',
{
operationId,
pendingTools: pendingToolsCalling.map((t: any) => ({
apiName: t.apiName,
identifier: t.identifier,
})),
stepIndex,
userId: ctx.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
}
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'waiting_for_human';
@@ -3524,4 +3524,254 @@ describe('RuntimeExecutors', () => {
}
});
});
describe('hooks integration', () => {
const createToolState = (overrides?: Partial<AgentState>): AgentState => ({
cost: createMockCost(),
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
maxSteps: 100,
messages: [],
metadata: { agentId: 'agent-123', topicId: 'topic-123' },
operationId: 'op-123',
status: 'running',
stepCount: 0,
toolManifestMap: {},
usage: createMockUsage(),
...overrides,
});
const createToolInstruction = (overrides?: any) => ({
payload: {
parentMessageId: 'parent-msg',
toolCalling: {
apiName: 'search_tweets',
arguments: '{"query":"test"}',
id: 'tc-1',
identifier: 'twitter',
type: 'default' as const,
},
...overrides,
},
type: 'call_tool' as const,
});
describe('call_tool hooks', () => {
it('should dispatch beforeToolCall and afterToolCall hooks', async () => {
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi.fn().mockResolvedValue(null),
};
const ctxWithHooks = { ...ctx, hookDispatcher: mockDispatcher as any };
const executors = createRuntimeExecutors(ctxWithHooks);
await executors.call_tool!(createToolInstruction(), createToolState());
expect(mockDispatcher.dispatchBeforeToolCall).toHaveBeenCalledWith(
'op-123',
expect.objectContaining({
apiName: 'search_tweets',
callIndex: 1,
identifier: 'twitter',
}),
);
// afterToolCall dispatched via dispatch()
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'op-123',
'afterToolCall',
expect.objectContaining({
apiName: 'search_tweets',
identifier: 'twitter',
mocked: false,
success: true,
}),
undefined,
);
});
it('should skip real execution when beforeToolCall returns mock', async () => {
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi
.fn()
.mockResolvedValue({ content: '{"mocked":true}', isMocked: true }),
};
const ctxWithHooks = { ...ctx, hookDispatcher: mockDispatcher as any };
const executors = createRuntimeExecutors(ctxWithHooks);
await executors.call_tool!(createToolInstruction(), createToolState());
// Real tool should NOT have been called
expect(mockToolExecutionService.executeTool).not.toHaveBeenCalled();
// afterToolCall should report mocked: true
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'op-123',
'afterToolCall',
expect.objectContaining({ mocked: true, success: true }),
undefined,
);
// Tool message should be persisted with mock content
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
content: '{"mocked":true}',
role: 'tool',
}),
);
});
it('should dispatch onToolCallError when tool throws', async () => {
mockToolExecutionService.executeTool.mockRejectedValue(new Error('Connection refused'));
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi.fn().mockResolvedValue(null),
};
const ctxWithHooks = { ...ctx, hookDispatcher: mockDispatcher as any };
const executors = createRuntimeExecutors(ctxWithHooks);
await executors.call_tool!(createToolInstruction(), createToolState());
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'op-123',
'onToolCallError',
expect.objectContaining({
apiName: 'search_tweets',
error: 'Connection refused',
identifier: 'twitter',
}),
undefined,
);
});
it('should derive callIndex from state.usage.tools.byTool', async () => {
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi.fn().mockResolvedValue(null),
};
const ctxWithHooks = { ...ctx, hookDispatcher: mockDispatcher as any };
const executors = createRuntimeExecutors(ctxWithHooks);
// First call: no prior usage → callIndex = 1
const state1 = createToolState();
await executors.call_tool!(createToolInstruction(), state1);
expect(mockDispatcher.dispatchBeforeToolCall).toHaveBeenCalledWith(
'op-123',
expect.objectContaining({ callIndex: 1 }),
);
// Second call: state reflects 1 prior call → callIndex = 2
const state2 = createToolState({
usage: {
...createMockUsage(),
tools: {
...createMockUsage().tools,
byTool: [{ calls: 1, errors: 0, name: 'twitter/search_tweets', totalTimeMs: 100 }],
totalCalls: 1,
},
},
});
await executors.call_tool!(createToolInstruction(), state2);
expect(mockDispatcher.dispatchBeforeToolCall).toHaveBeenLastCalledWith(
'op-123',
expect.objectContaining({ callIndex: 2 }),
);
});
it('should work without hookDispatcher (backward compat)', async () => {
const executors = createRuntimeExecutors(ctx); // no hookDispatcher
const result = await executors.call_tool!(createToolInstruction(), createToolState());
expect(result).toBeDefined();
expect(mockToolExecutionService.executeTool).toHaveBeenCalled();
});
});
describe('compress_context hooks', () => {
it('should dispatch beforeCompact and afterCompact hooks', async () => {
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi.fn().mockResolvedValue(null),
};
const ctxWithHooks = {
...ctx,
hookDispatcher: mockDispatcher as any,
topicId: 'topic-123',
};
const executors = createRuntimeExecutors(ctxWithHooks);
const state = createToolState({ metadata: { agentId: 'agent-123', topicId: 'topic-123' } });
const instruction = {
payload: {
currentTokenCount: 5000,
messages: [
{ content: 'hello', id: 'msg-1', role: 'user' },
{ content: 'hi there', id: 'msg-2', role: 'assistant' },
],
},
type: 'compress_context' as const,
};
await executors.compress_context!(instruction, state);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'op-123',
'beforeCompact',
expect.objectContaining({ tokenCount: 5000 }),
undefined,
);
});
});
describe('request_human_approve hooks', () => {
it('should dispatch beforeHumanIntervention hook', async () => {
const mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
dispatchBeforeToolCall: vi.fn().mockResolvedValue(null),
};
const ctxWithHooks = { ...ctx, hookDispatcher: mockDispatcher as any };
const executors = createRuntimeExecutors(ctxWithHooks);
const state = createToolState({
messages: [{ content: '', id: 'asst-1', role: 'assistant' }],
status: 'running',
});
const instruction = {
pendingToolsCalling: [
{
apiName: 'post_tweet',
arguments: '{}',
id: 'tc-1',
identifier: 'twitter',
type: 'default' as const,
},
],
type: 'request_human_approve' as const,
};
await executors.request_human_approve!(instruction, state);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'op-123',
'beforeHumanIntervention',
expect.objectContaining({
pendingTools: [{ apiName: 'post_tweet', identifier: 'twitter' }],
}),
undefined, // serializedHooks from state.metadata._hooks
);
});
});
});
});
@@ -1470,6 +1470,7 @@ export class AgentRuntimeService {
discordContext: metadata?.discordContext,
userTimezone: metadata?.userTimezone,
evalContext: metadata?.evalContext,
hookDispatcher,
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
messageModel: this.messageModel,
operationId,
@@ -1600,6 +1601,21 @@ export class AgentRuntimeService {
// running when this was the last one.
newState.status = newState.pendingToolsCalling.length > 0 ? 'waiting_for_human' : 'running';
// Dispatch afterHumanIntervention hook (approved)
hookDispatcher
.dispatch(
state.metadata?.operationId ?? '',
'afterHumanIntervention',
{
action: 'approve',
operationId: state.metadata?.operationId ?? '',
toolCallId: approvedToolCall.id,
userId: state.metadata?.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
const nextContext: AgentRuntimeContext = {
payload: {
approvedToolCall,
@@ -1655,6 +1671,23 @@ export class AgentRuntimeService {
// pendingToolsCalling is non-empty would cause executeStep to run
// runtime.step immediately, resuming the LLM with an unresolved
// batch — see LOBE-7151 review P1.
// Dispatch afterHumanIntervention hook (rejectAndContinue)
hookDispatcher
.dispatch(
state.metadata?.operationId ?? '',
'afterHumanIntervention',
{
action: 'rejectAndContinue',
operationId: state.metadata?.operationId ?? '',
rejectionReason,
toolCallId: rejectedToolCallId,
userId: state.metadata?.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
if (newState.pendingToolsCalling.length > 0) {
newState.status = 'waiting_for_human';
return { newState, nextContext: undefined };
@@ -1666,6 +1699,22 @@ export class AgentRuntimeService {
// B: halt. Use interrupted + reason='human_rejected' to reuse the
// existing terminal-state plumbing (early-exit, completion hooks, etc).
// Dispatch onStopByHumanIntervention hook
hookDispatcher
.dispatch(
state.metadata?.operationId ?? '',
'onStopByHumanIntervention',
{
operationId: state.metadata?.operationId ?? '',
rejectionReason,
toolCallId: rejectedToolCallId,
userId: state.metadata?.userId,
},
state.metadata?._hooks,
)
.catch(() => {});
newState.status = 'interrupted';
newState.interruption = {
canResume: false,
@@ -127,7 +127,7 @@ describe('Hooks integration — afterStep event carries step presentation data',
const dispatchSpy = vi
.spyOn(hookDispatcher, 'dispatch')
.mockImplementation(async (_opId, type, event) => {
if (type === 'afterStep') capturedEvents.push(event);
if (type === 'afterStep') capturedEvents.push(event as AgentHookEvent);
});
await service.executeStep({
@@ -220,7 +220,7 @@ describe('Hooks integration — afterStep event carries step presentation data',
const dispatchSpy = vi
.spyOn(hookDispatcher, 'dispatch')
.mockImplementation(async (_opId, type, event) => {
if (type === 'afterStep') capturedEvents.push(event);
if (type === 'afterStep') capturedEvents.push(event as AgentHookEvent);
});
await service.executeStep({
@@ -275,7 +275,7 @@ describe('Hooks integration — onComplete event for early-terminal states', ()
const dispatchSpy = vi
.spyOn(hookDispatcher, 'dispatch')
.mockImplementation(async (_opId, type, event) => {
if (type === 'onComplete') capturedEvents.push(event);
if (type === 'onComplete') capturedEvents.push(event as AgentHookEvent);
});
await service.executeStep({
@@ -342,7 +342,7 @@ describe('Hooks integration — afterStep event is compatible with renderStepPro
const dispatchSpy = vi
.spyOn(hookDispatcher, 'dispatch')
.mockImplementation(async (_opId, type, event) => {
if (type === 'afterStep') capturedEvents.push(event);
if (type === 'afterStep') capturedEvents.push(event as AgentHookEvent);
});
await service.executeStep({
@@ -8,7 +8,9 @@ import type {
AgentHookEvent,
AgentHookType,
AgentHookWebhook,
AnyHookEvent,
SerializedHook,
ToolCallHookEvent,
} from './types';
const log = debug('lobe-server:hook-dispatcher');
@@ -93,7 +95,7 @@ export class HookDispatcher {
async dispatch(
operationId: string,
type: AgentHookType,
event: AgentHookEvent,
event: AnyHookEvent,
serializedHooks?: SerializedHook[],
): Promise<void> {
const isQueueMode = isQueueAgentRuntimeEnabled();
@@ -105,7 +107,7 @@ export class HookDispatcher {
for (const hook of hooks) {
try {
log('[%s][%s] Dispatching local hook: %s', operationId, type, hook.id);
await hook.handler(event);
await hook.handler(event as AgentHookEvent);
} catch (error) {
log('[%s][%s] Hook error (non-fatal): %s %O', operationId, type, hook.id, error);
// Hook errors should NOT affect main execution flow
@@ -128,9 +130,13 @@ export class HookDispatcher {
hook.webhook.url,
);
// Strip finalState from webhook payload (too large, local-only)
const { finalState: _, ...webhookEvent } = event;
// Webhook delivery only applies to step-level hooks (AgentHookEvent)
const webhookPayload = { ...event };
if ('finalState' in webhookPayload) {
delete (webhookPayload as { finalState?: unknown }).finalState;
}
await deliverWebhook(hook.webhook, {
...webhookEvent,
...webhookPayload,
hookId: hook.id,
hookType: type,
...hook.webhook.body,
@@ -148,6 +154,49 @@ export class HookDispatcher {
}
}
/**
* Dispatch beforeToolCall hooks with mock support.
* Returns mock result if any handler called event.mock(), otherwise null.
*/
async dispatchBeforeToolCall(
operationId: string,
event: Omit<ToolCallHookEvent, 'mock' | 'operationId'>,
): Promise<{ content: string; isMocked: true } | null> {
const hooks = this.hooks.get(operationId)?.filter((h) => h.type === 'beforeToolCall') || [];
if (hooks.length === 0) return null;
let isMocked = false;
let mockedContent = '';
const toolCallEvent: ToolCallHookEvent = {
...event,
mock: (result) => {
// Only accept non-empty string content
if (typeof result?.content === 'string' && result.content.length > 0) {
isMocked = true;
mockedContent = result.content;
} else {
log(
'[%s][beforeToolCall] mock() called with invalid content (must be non-empty string), ignoring',
operationId,
);
}
},
operationId,
};
for (const hook of hooks) {
try {
log('[%s][beforeToolCall] Dispatching: %s', operationId, hook.id);
await hook.handler(toolCallEvent as any);
} catch (error) {
log('[%s][beforeToolCall] Hook error (non-fatal): %s %O', operationId, hook.id, error);
}
}
return isMocked ? { content: mockedContent, isMocked: true } : null;
}
/**
* Get serialized hooks for an operation (for production mode persistence)
*/
@@ -297,5 +297,643 @@ describe('HookDispatcher', () => {
}),
);
});
it('should dispatch onToolCallError hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'tool-error', type: 'onToolCallError' }]);
await dispatcher.dispatch(operationId, 'onToolCallError', {
apiName: 'search_tweets',
args: { query: 'test' },
callIndex: 1,
error: 'Network timeout',
identifier: 'twitter',
operationId,
stepIndex: 2,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
apiName: 'search_tweets',
error: 'Network timeout',
identifier: 'twitter',
}),
);
});
it('should dispatch afterToolCall hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'after-tool', type: 'afterToolCall' }]);
await dispatcher.dispatch(operationId, 'afterToolCall', {
apiName: 'search_tweets',
args: { query: 'test' },
callIndex: 1,
content: '{"tweets":[]}',
executionTimeMs: 150,
identifier: 'twitter',
mocked: false,
operationId,
stepIndex: 1,
success: true,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
apiName: 'search_tweets',
identifier: 'twitter',
success: true,
}),
);
});
});
describe('dispatchBeforeToolCall', () => {
it('should return null when no beforeToolCall hooks registered', async () => {
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
});
it('should return mock result when handler calls mock()', async () => {
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: '{"mocked":true}' });
},
id: 'mock-hook',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: { query: 'test' },
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toEqual({ content: '{"mocked":true}', isMocked: true });
});
it('should return null when handler does not call mock()', async () => {
dispatcher.register(operationId, [
{
handler: async () => {
// observe only, no mock
},
id: 'observe-hook',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
});
it('should pass correct event fields to handler', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'check-fields', type: 'beforeToolCall' }]);
await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'post_tweet',
args: { text: 'hello' },
callIndex: 3,
identifier: 'twitter',
stepIndex: 5,
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
apiName: 'post_tweet',
args: { text: 'hello' },
callIndex: 3,
identifier: 'twitter',
mock: expect.any(Function),
operationId,
stepIndex: 5,
}),
);
});
it('should not throw if handler throws (non-fatal)', async () => {
dispatcher.register(operationId, [
{
handler: async () => {
throw new Error('hook failed');
},
id: 'failing-hook',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
});
it('should reject mock with empty string content', async () => {
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: '' });
},
id: 'empty-mock',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
});
it('should reject mock with undefined content', async () => {
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: undefined });
},
id: 'undefined-mock',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
});
it('should reject mock with non-string content (object, array, number)', async () => {
for (const badContent of [{}, [], 42, null]) {
const d = new HookDispatcher();
d.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: badContent });
},
id: 'bad-mock',
type: 'beforeToolCall',
},
]);
const result = await d.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toBeNull();
}
});
});
describe('compact hooks', () => {
it('should dispatch beforeCompact hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'before-compact', type: 'beforeCompact' }]);
await dispatcher.dispatch(operationId, 'beforeCompact', {
messageCount: 20,
operationId,
stepIndex: 3,
tokenCount: 8000,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ messageCount: 20, tokenCount: 8000 }),
);
});
it('should dispatch afterCompact hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'after-compact', type: 'afterCompact' }]);
await dispatcher.dispatch(operationId, 'afterCompact', {
groupId: 'grp_123',
messagesAfter: 3,
messagesBefore: 20,
operationId,
stepIndex: 3,
summary: 'The conversation covered...',
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ messagesBefore: 20, messagesAfter: 3, groupId: 'grp_123' }),
);
});
it('should dispatch onCompactError hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'compact-error', type: 'onCompactError' }]);
await dispatcher.dispatch(operationId, 'onCompactError', {
error: 'LLM compression call failed',
operationId,
stepIndex: 3,
tokenCount: 8000,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ error: 'LLM compression call failed', tokenCount: 8000 }),
);
});
});
describe('human intervention hooks', () => {
it('should dispatch beforeHumanIntervention hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [
{ handler, id: 'before-hi', type: 'beforeHumanIntervention' },
]);
await dispatcher.dispatch(operationId, 'beforeHumanIntervention', {
operationId,
pendingTools: [{ apiName: 'search_tweets', identifier: 'twitter' }],
stepIndex: 2,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
pendingTools: [{ apiName: 'search_tweets', identifier: 'twitter' }],
}),
);
});
it('should dispatch afterHumanIntervention hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [
{ handler, id: 'after-hi', type: 'afterHumanIntervention' },
]);
await dispatcher.dispatch(operationId, 'afterHumanIntervention', {
action: 'approve',
operationId,
toolCallId: 'call_123',
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ action: 'approve', toolCallId: 'call_123' }),
);
});
it('should dispatch onStopByHumanIntervention hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [
{ handler, id: 'stop-hi', type: 'onStopByHumanIntervention' },
]);
await dispatcher.dispatch(operationId, 'onStopByHumanIntervention', {
operationId,
rejectionReason: 'Not safe to execute',
toolCallId: 'call_456',
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ rejectionReason: 'Not safe to execute' }),
);
});
});
describe('dispatchBeforeToolCall — edge cases', () => {
it('should use the last mock() call when multiple handlers call mock()', async () => {
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: '{"first":true}' });
},
id: 'mock-1',
type: 'beforeToolCall',
},
{
handler: async (event: any) => {
event.mock({ content: '{"second":true}' });
},
id: 'mock-2',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(result).toEqual({ content: '{"second":true}', isMocked: true });
});
it('should return mock when only one of multiple handlers calls mock()', async () => {
const observeHandler = vi.fn();
dispatcher.register(operationId, [
{ handler: observeHandler, id: 'observe', type: 'beforeToolCall' },
{
handler: async (event: any) => {
event.mock({ content: '{"mocked":true}' });
},
id: 'mocker',
type: 'beforeToolCall',
},
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(observeHandler).toHaveBeenCalled();
expect(result).toEqual({ content: '{"mocked":true}', isMocked: true });
});
it('should only mock in local mode, not production mode', async () => {
vi.mocked(isQueueAgentRuntimeEnabled).mockReturnValue(true);
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: '{"mocked":true}' });
},
id: 'mock-hook',
type: 'beforeToolCall',
},
]);
// dispatchBeforeToolCall only runs in local mode
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
// In local mode this would return the mock, but hooks are still in-memory
// so it should still work (dispatchBeforeToolCall doesn't check queue mode)
expect(result).toEqual({ content: '{"mocked":true}', isMocked: true });
});
it('should not affect other hook types when beforeToolCall is registered', async () => {
const afterStepHandler = vi.fn();
const onCompleteHandler = vi.fn();
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: 'mock' });
},
id: 'tool-mock',
type: 'beforeToolCall',
},
{ handler: afterStepHandler, id: 'after-step', type: 'afterStep' },
{ handler: onCompleteHandler, id: 'complete', type: 'onComplete' },
]);
// beforeToolCall should not trigger afterStep or onComplete
await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(afterStepHandler).not.toHaveBeenCalled();
expect(onCompleteHandler).not.toHaveBeenCalled();
// afterStep should still work independently
await dispatcher.dispatch(operationId, 'afterStep', makeEvent({ stepIndex: 0 }));
expect(afterStepHandler).toHaveBeenCalledTimes(1);
});
it('should call handlers even after a previous handler throws', async () => {
const mockHandler = vi.fn().mockImplementation(async (event: any) => {
event.mock({ content: '{"recovered":true}' });
});
dispatcher.register(operationId, [
{
handler: async () => {
throw new Error('first handler fails');
},
id: 'failing',
type: 'beforeToolCall',
},
{ handler: mockHandler, id: 'recovering', type: 'beforeToolCall' },
]);
const result = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(mockHandler).toHaveBeenCalled();
expect(result).toEqual({ content: '{"recovered":true}', isMocked: true });
});
});
describe('callAgent hooks', () => {
it('should dispatch beforeCallAgent hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'before-call', type: 'beforeCallAgent' }]);
await dispatcher.dispatch(operationId, 'beforeCallAgent', {
agentId: 'sub-agent-1',
instruction: 'Analyze this data',
operationId,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'sub-agent-1', instruction: 'Analyze this data' }),
);
});
it('should dispatch afterCallAgent hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'after-call', type: 'afterCallAgent' }]);
await dispatcher.dispatch(operationId, 'afterCallAgent', {
agentId: 'sub-agent-1',
operationId,
subOperationId: 'op_sub_123',
success: true,
threadId: 'thread_123',
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'sub-agent-1', success: true, threadId: 'thread_123' }),
);
});
it('should dispatch onCallAgentError hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'call-error', type: 'onCallAgentError' }]);
await dispatcher.dispatch(operationId, 'onCallAgentError', {
agentId: 'sub-agent-1',
error: 'Sub-agent timed out',
operationId,
userId: 'user_test',
} as any);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'sub-agent-1', error: 'Sub-agent timed out' }),
);
});
});
describe('hooks safety guarantees', () => {
it('all observation hooks should not affect execution flow (handler errors are swallowed)', async () => {
const observationTypes = [
'afterToolCall',
'onToolCallError',
'beforeCompact',
'afterCompact',
'onCompactError',
'beforeHumanIntervention',
'afterHumanIntervention',
'onStopByHumanIntervention',
'beforeCallAgent',
'afterCallAgent',
'onCallAgentError',
] as const;
for (const type of observationTypes) {
const throwingHandler = vi.fn().mockRejectedValue(new Error(`${type} hook crashed`));
dispatcher.register(operationId, [{ handler: throwingHandler, id: `crash-${type}`, type }]);
// Should never throw — errors are swallowed
await expect(
dispatcher.dispatch(operationId, type, makeEvent(), undefined),
).resolves.toBeUndefined();
}
});
it('dispatchBeforeToolCall should only work in local mode (in-memory hooks)', async () => {
// Register a mock hook
dispatcher.register(operationId, [
{
handler: async (event: any) => {
event.mock({ content: '{"mocked":true}' });
},
id: 'mock-hook',
type: 'beforeToolCall',
},
]);
// Local mode: mock works
const localResult = await dispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(localResult).toEqual({ content: '{"mocked":true}', isMocked: true });
// dispatchBeforeToolCall does NOT use serializedHooks — it only reads
// from this.hooks (in-memory). In QStash mode where a different worker
// executes the step, this.hooks would be empty, so mock cannot fire.
// This is by design — mock is local-only.
const otherDispatcher = new HookDispatcher();
const remoteResult = await otherDispatcher.dispatchBeforeToolCall(operationId, {
apiName: 'search',
args: {},
callIndex: 1,
identifier: 'twitter',
stepIndex: 0,
});
expect(remoteResult).toBeNull(); // No hooks registered → no mock
});
it('observation hooks should work in production mode via serializedHooks', async () => {
vi.mocked(isQueueAgentRuntimeEnabled).mockReturnValue(true);
global.fetch = vi.fn().mockResolvedValue({ status: 200 });
dispatcher.register(operationId, [
{
handler: vi.fn(),
id: 'tool-webhook',
type: 'afterToolCall',
webhook: { url: 'https://example.com/afterToolCall' },
},
]);
const serialized = dispatcher.getSerializedHooks(operationId);
await dispatcher.dispatch(
operationId,
'afterToolCall',
{
apiName: 'search',
args: {},
callIndex: 1,
content: 'result',
executionTimeMs: 100,
identifier: 'twitter',
mocked: false,
operationId,
stepIndex: 0,
success: true,
userId: 'user_test',
},
serialized,
);
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/afterToolCall',
expect.objectContaining({ method: 'POST' }),
);
});
});
});
@@ -7,7 +7,24 @@
import type { AgentHookEvent, AgentHookType } from '@lobechat/agent-runtime';
export type { AgentHookEvent, AgentHookType } from '@lobechat/agent-runtime';
export type {
AfterCallAgentHookEvent,
AfterCompactHookEvent,
AfterHumanInterventionHookEvent,
AfterToolCallHookEvent,
AgentHookEvent,
AgentHookType,
AnyHookEvent,
BeforeCallAgentHookEvent,
BeforeCompactHookEvent,
BeforeHumanInterventionHookEvent,
BeforeToolCallObservationEvent,
CallAgentErrorHookEvent,
CompactErrorHookEvent,
StopByHumanInterventionHookEvent,
ToolCallErrorHookEvent,
ToolCallHookEvent,
} from '@lobechat/agent-runtime';
// ── Server-side Hook Types ───────────────────────────────
+39 -1
View File
@@ -58,6 +58,7 @@ import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { AgentRuntimeServiceOptions } from '@/server/services/agentRuntime';
import { AgentRuntimeService } from '@/server/services/agentRuntime';
import { getAbortError, isAbortError, throwIfAborted } from '@/server/services/agentRuntime/abort';
import { hookDispatcher } from '@/server/services/agentRuntime/hooks';
import { type AgentHook } from '@/server/services/agentRuntime/hooks/types';
import { type StepLifecycleCallbacks } from '@/server/services/agentRuntime/types';
import { DocumentService } from '@/server/services/document';
@@ -1633,7 +1634,8 @@ export class AiAgentService {
* 3. Store operationId in Thread metadata
*/
async execSubAgentTask(params: ExecSubAgentTaskParams): Promise<ExecSubAgentTaskResult> {
const { groupId, topicId, parentMessageId, agentId, instruction, title } = params;
const { groupId, topicId, parentMessageId, agentId, instruction, title, parentOperationId } =
params;
log(
'execSubAgentTask: agentId=%s, groupId=%s, topicId=%s, instruction=%s',
@@ -1643,6 +1645,18 @@ export class AiAgentService {
instruction.slice(0, 50),
);
// Dispatch beforeCallAgent hook on parent operation
if (parentOperationId) {
hookDispatcher
.dispatch(parentOperationId, 'beforeCallAgent', {
agentId,
instruction: instruction.slice(0, 200),
operationId: parentOperationId,
userId: this.userId,
})
.catch(() => {});
}
// 1. Create Thread for isolated task execution
const thread = await this.threadModel.create({
agentId,
@@ -1705,6 +1719,30 @@ export class AiAgentService {
},
status: ThreadStatus.Failed,
});
// Dispatch onCallAgentError hook
if (parentOperationId) {
hookDispatcher
.dispatch(parentOperationId, 'onCallAgentError', {
agentId,
error: result.error || 'Sub-agent execution failed',
operationId: parentOperationId,
userId: this.userId,
})
.catch(() => {});
}
} else if (parentOperationId) {
// Dispatch afterCallAgent hook
hookDispatcher
.dispatch(parentOperationId, 'afterCallAgent', {
agentId,
operationId: parentOperationId,
subOperationId: result.operationId,
success: true,
threadId: thread.id,
userId: this.userId,
})
.catch(() => {});
}
return {
+2
View File
@@ -62,6 +62,8 @@ export interface ExecSubAgentTaskParams {
groupId?: string;
instruction: string;
parentMessageId: string;
/** Parent operation ID for dispatching callAgent hooks */
parentOperationId?: string;
timeout?: number;
/** Task title (shown in UI, used as thread title) */
title?: string;