mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🔨 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:
@@ -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).
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user