|
| 1 | +--- |
| 2 | +"@voltagent/core": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat: add `onHandoffComplete` hook for early termination in supervisor/subagent workflows |
| 6 | + |
| 7 | +## The Problem |
| 8 | + |
| 9 | +When using the supervisor/subagent pattern, subagents **always** return to the supervisor for processing, even when they generate final outputs (like JSON structures or reports) that need no additional handling. This causes unnecessary token consumption. |
| 10 | + |
| 11 | +**Current flow**: |
| 12 | + |
| 13 | +``` |
| 14 | +Supervisor → SubAgent (generates 2K token JSON) → Supervisor (processes JSON) → User |
| 15 | + ↑ Wastes ~2K tokens |
| 16 | +``` |
| 17 | + |
| 18 | +**Example impact**: |
| 19 | + |
| 20 | +- Current: ~2,650 tokens per request |
| 21 | +- With bail: ~560 tokens per request |
| 22 | +- Savings: **79%** (~2,000 tokens / ~$0.020 per request) |
| 23 | + |
| 24 | +## The Solution |
| 25 | + |
| 26 | +Added `onHandoffComplete` hook that allows supervisors to intercept subagent results and optionally **bail** (skip supervisor processing) when the subagent produces final output. |
| 27 | + |
| 28 | +**New flow**: |
| 29 | + |
| 30 | +``` |
| 31 | +Supervisor → SubAgent → bail() → User ✅ |
| 32 | +``` |
| 33 | + |
| 34 | +## API |
| 35 | + |
| 36 | +The hook receives a `bail()` function that can be called to terminate early: |
| 37 | + |
| 38 | +```typescript |
| 39 | +const supervisor = new Agent({ |
| 40 | + name: "Workout Supervisor", |
| 41 | + subAgents: [exerciseAgent, workoutBuilder], |
| 42 | + hooks: { |
| 43 | + onHandoffComplete: async ({ agent, result, bail, context }) => { |
| 44 | + // Workout Builder produces final JSON - no processing needed |
| 45 | + if (agent.name === "Workout Builder") { |
| 46 | + context.logger?.info("Final output received, bailing"); |
| 47 | + bail(); // Skip supervisor, return directly to user |
| 48 | + return; |
| 49 | + } |
| 50 | + |
| 51 | + // Large result - bail to save tokens |
| 52 | + if (result.length > 2000) { |
| 53 | + context.logger?.warn("Large result, bailing to save tokens"); |
| 54 | + bail(); |
| 55 | + return; |
| 56 | + } |
| 57 | + |
| 58 | + // Transform and bail |
| 59 | + if (agent.name === "Report Generator") { |
| 60 | + const transformed = `# Final Report\n\n${result}\n\n---\nGenerated at: ${new Date().toISOString()}`; |
| 61 | + bail(transformed); // Bail with transformed result |
| 62 | + return; |
| 63 | + } |
| 64 | + |
| 65 | + // Default: continue to supervisor for processing |
| 66 | + }, |
| 67 | + }, |
| 68 | +}); |
| 69 | +``` |
| 70 | + |
| 71 | +## Hook Arguments |
| 72 | + |
| 73 | +```typescript |
| 74 | +interface OnHandoffCompleteHookArgs { |
| 75 | + agent: Agent; // Target agent (subagent) |
| 76 | + sourceAgent: Agent; // Source agent (supervisor) |
| 77 | + result: string; // Subagent's output |
| 78 | + messages: UIMessage[]; // Full conversation messages |
| 79 | + usage?: UsageInfo; // Token usage info |
| 80 | + context: OperationContext; // Operation context |
| 81 | + bail: (transformedResult?: string) => void; // Call to bail |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +## Features |
| 86 | + |
| 87 | +- ✅ **Clean API**: No return value needed, just call `bail()` |
| 88 | +- ✅ **True early termination**: Supervisor execution stops immediately, no LLM calls wasted |
| 89 | +- ✅ **Conditional bail**: Decide based on agent, result content, size, etc. |
| 90 | +- ✅ **Optional transformation**: `bail(newResult)` to transform before bailing |
| 91 | +- ✅ **Observability**: Automatic logging and OpenTelemetry events with visual indicators |
| 92 | +- ✅ **Backward compatible**: Existing code works without changes |
| 93 | +- ✅ **Error handling**: Hook errors logged, flow continues normally |
| 94 | + |
| 95 | +## How Bail Works (Implementation Details) |
| 96 | + |
| 97 | +When `bail()` is called in the `onHandoffComplete` hook: |
| 98 | + |
| 99 | +**1. Hook Level** (`packages/core/src/agent/subagent/index.ts`): |
| 100 | + |
| 101 | +- Sets `bailed: true` flag in handoff return value |
| 102 | +- Adds OpenTelemetry span attributes to both supervisor and subagent spans |
| 103 | +- Logs the bail event with metadata |
| 104 | + |
| 105 | +**2. Tool Level** (`delegate_task` tool): |
| 106 | + |
| 107 | +- Includes `bailed: true` in tool result structure |
| 108 | +- Adds note: "One or more subagents produced final output. No further processing needed." |
| 109 | + |
| 110 | +**3. Step Handler Level** (`createStepHandler` in `agent.ts`): |
| 111 | + |
| 112 | +- Detects bail during step execution when tool results arrive |
| 113 | +- Creates `BailError` and aborts execution via `abortController.abort(bailError)` |
| 114 | +- Stores bailed result in `systemContext` for retrieval |
| 115 | +- **Works for both `generateText` and `streamText`** |
| 116 | + |
| 117 | +**4. Catch Block Level** (method-specific handling): |
| 118 | + |
| 119 | +- **generateText**: Catches `BailError`, retrieves bailed result from `systemContext`, applies guardrails, calls hooks, returns as successful generation |
| 120 | +- **streamText**: `onError` catches `BailError` gracefully (not logged as error), `onFinish` retrieves and uses bailed result |
| 121 | + |
| 122 | +This unified abort-based implementation ensures true early termination for all generation methods. |
| 123 | + |
| 124 | +### Stream Support (NEW) |
| 125 | + |
| 126 | +**For `streamText` supervisors:** |
| 127 | + |
| 128 | +When a subagent bails during streaming, the supervisor stream is immediately aborted using a `BailError`: |
| 129 | + |
| 130 | +1. **Detection during streaming** (`createStepHandler`): |
| 131 | + - Tool results are checked in `onStepFinish` handler |
| 132 | + - If `bailed: true` found, `BailError` is created and stream is aborted via `abortController.abort(bailError)` |
| 133 | + - Bailed result stored in `systemContext` for retrieval in `onFinish` |
| 134 | + |
| 135 | +2. **Graceful error handling** (`streamText` onError): |
| 136 | + - `BailError` is detected and handled gracefully (not logged as error) |
| 137 | + - Error hooks are NOT called for bail |
| 138 | + - Stream abort is treated as successful early termination |
| 139 | + |
| 140 | +3. **Final result** (`streamText` onFinish): |
| 141 | + - Bailed result retrieved from `systemContext` |
| 142 | + - Output guardrails applied to bailed result |
| 143 | + - `onEnd` hook called with bailed result |
| 144 | + |
| 145 | +**Benefits for streaming:** |
| 146 | + |
| 147 | +- ✅ Stream stops immediately when bail detected (no wasted supervisor chunks) |
| 148 | +- ✅ No unnecessary LLM calls after bail |
| 149 | +- ✅ Works with `fullStreamEventForwarding` - subagent chunks already forwarded |
| 150 | +- ✅ Clean abort semantic with `BailError` class |
| 151 | +- ✅ Graceful handling - not treated as error |
| 152 | + |
| 153 | +**Supported methods:** |
| 154 | + |
| 155 | +- ✅ `generateText` - Aborts execution during step handler, catches `BailError` and returns bailed result |
| 156 | +- ✅ `streamText` - Aborts stream during step handler, handles `BailError` in `onError` and `onFinish` |
| 157 | +- ❌ `generateObject` - No tool support, bail not applicable |
| 158 | +- ❌ `streamObject` - No tool support, bail not applicable |
| 159 | + |
| 160 | +**Key difference from initial implementation:** |
| 161 | + |
| 162 | +- ❌ **OLD**: Post-execution check in `generateText` (after AI SDK completes) - redundant |
| 163 | +- ✅ **NEW**: Unified abort mechanism in `createStepHandler` - works for both methods, stops execution immediately |
| 164 | + |
| 165 | +## Use Cases |
| 166 | + |
| 167 | +Perfect for scenarios where specialized subagents generate final outputs: |
| 168 | + |
| 169 | +1. **JSON/Structured data generators**: Workout builders, report generators |
| 170 | +2. **Large content producers**: Document creators, data exports |
| 171 | +3. **Token optimization**: Skip processing for expensive results |
| 172 | +4. **Business logic**: Conditional routing based on result characteristics |
| 173 | + |
| 174 | +## Observability |
| 175 | + |
| 176 | +When bail occurs, both logging and OpenTelemetry tracking provide full visibility: |
| 177 | + |
| 178 | +**Logging:** |
| 179 | + |
| 180 | +- Log event: `Supervisor bailed after handoff` |
| 181 | +- Includes: supervisor name, subagent name, result length, transformation status |
| 182 | + |
| 183 | +**OpenTelemetry:** |
| 184 | + |
| 185 | +- Span event: `supervisor.handoff.bailed` (for timeline events) |
| 186 | +- Span attributes added to **both supervisor and subagent spans**: |
| 187 | + - `bailed`: `true` |
| 188 | + - `bail.supervisor`: supervisor agent name (on subagent span) |
| 189 | + - `bail.subagent`: subagent name (on supervisor span) |
| 190 | + - `bail.transformed`: `true` if result was transformed |
| 191 | + |
| 192 | +**Console Visualization:** |
| 193 | +Bailed subagents are visually distinct in the observability react-flow view: |
| 194 | + |
| 195 | +- Purple border with shadow (`border-purple-500 shadow-purple-600/50`) |
| 196 | +- "⚡ BAILED" badge in the header (shows "⚡ BAILED (T)" if transformed) |
| 197 | +- Tooltip showing which supervisor initiated the bail |
| 198 | +- Node opacity remains at 1.0 (fully visible) |
| 199 | +- Status badge shows "BAILED" with purple styling instead of error |
| 200 | +- Details panel shows "Early Termination" info section with supervisor info |
| 201 | + |
| 202 | +## Type Safety Improvements |
| 203 | + |
| 204 | +Also improved type safety by replacing `usage?: any` with proper `UsageInfo` type: |
| 205 | + |
| 206 | +```typescript |
| 207 | +export type UsageInfo = { |
| 208 | + promptTokens: number; |
| 209 | + completionTokens: number; |
| 210 | + totalTokens: number; |
| 211 | + cachedInputTokens?: number; |
| 212 | + reasoningTokens?: number; |
| 213 | +}; |
| 214 | +``` |
| 215 | + |
| 216 | +This provides: |
| 217 | + |
| 218 | +- ✅ Better autocomplete in IDEs |
| 219 | +- ✅ Compile-time type checking |
| 220 | +- ✅ Clear documentation of available fields |
| 221 | + |
| 222 | +## Breaking Changes |
| 223 | + |
| 224 | +None - this is a purely additive feature. The `UsageInfo` type structure is fully compatible with existing code. |
0 commit comments