Skip to content

Commit 0d13b73

Browse files
authored
feat: bail support supervisor (#761)
* fix: expose AI SDK tool metadata (e.g. toolCallId, abort signal) via ToolExecuteOptions - #746 * feat: simplify tool execution API by merging OperationContext into ToolExecuteOptions * fix: unit tests * feat: encapsulate tool-specific metadata in toolContext * feat: encapsulate tool-specific metadata in toolContext + prevent AI SDK context collision * feat: add providerOptions support to tools for provider-specific features * feat: add multi-modal tool results support with toModelOutput - #722 * feat: add `onHandoffComplete` hook for early termination in supervisor/subagent workflows * fix: tool return
1 parent c80d18f commit 0d13b73

File tree

9 files changed

+1170
-65
lines changed

9 files changed

+1170
-65
lines changed

.changeset/ripe-apples-push.md

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)