Skip to content

Commit 45bcef0

Browse files
Agent callbacks (#1316)
## Summary Adds callback support to the Stagehand agent for both streaming and non-streaming execution modes, allowing users to hook into various stages of agent execution. ## Changes ### New Types (`lib/v3/types/public/agent.ts`) Added callback interfaces for agent execution: - **`AgentCallbacks`** - Base callbacks shared between modes: - `prepareStep` - Modify settings before each LLM step - `onStepFinish` - Called after each step completes - **`AgentExecuteCallbacks`** - Non-streaming mode callbacks (extends `AgentCallbacks`) - **`AgentStreamCallbacks`** - Streaming mode callbacks (extends `AgentCallbacks`): - `onChunk` - Called for each streamed chunk - `onFinish` - Called when stream completes - `onError` - Called on stream errors - `onAbort` - Called when stream is aborted - **`AgentExecuteOptionsBase`** - Base options without callbacks - **`AgentExecuteOptions`** - Non-streaming options with `AgentExecuteCallbacks` - **`AgentStreamExecuteOptions`** - Streaming options with `AgentStreamCallbacks` ### Handler Updates (`lib/v3/handlers/v3AgentHandler.ts`) - Modified `createStepHandler` to accept optional user callback - Updated `execute()` to pass callbacks to `generateText` - Updated `stream()` to pass callbacks to `streamText` ### Type Safety Added compile-time enforcement that streaming-only callbacks (`onChunk`, `onFinish`, `onError`, `onAbort`) can only be used with `stream: true`: ```typescript // Works - streaming callbacks with stream: true const agent = stagehand.agent({ stream: true }); await agent.execute({ instruction: "...", callbacks: { onChunk: async (chunk) => console.log(chunk) } }); // Compile error - streaming callbacks without stream: true const agent = stagehand.agent({ stream: false }); await agent.execute({ instruction: "...", callbacks: { onChunk: async (chunk) => console.log(chunk) } // Error: "This callback requires 'stream: true' in AgentConfig..." }); ``` ## Type Castings Explained Several type assertions were necessary due to TypeScript's limitations with conditional types: ### 1. Callback extraction in handlers ```typescript const callbacks = (instructionOrOptions as AgentExecuteOptions).callbacks as | AgentExecuteCallbacks | undefined; ``` **Why:** `instructionOrOptions` can be `string | AgentExecuteOptions`. When it's a string, there are no callbacks. We cast after the `prepareAgent` call because at that point we know it's been resolved to options. ### 2. Streaming vs non-streaming branch in v3.ts ```typescript result = await handler.execute( instructionOrOptions as string | AgentExecuteOptions, ); ``` **Why:** The implementation signature accepts `string | AgentExecuteOptions | AgentStreamExecuteOptions` to satisfy both overloads, but within the non-streaming branch we know it's the non-streaming type. TypeScript can't narrow based on the `isStreaming` runtime check. ### 3. Error fallback in stream() ```typescript return { textStream: (async function* () {})(), result: resultPromise, } as unknown as AgentStreamResult; ``` **Why:** When `prepareAgent` fails in streaming mode, we return a minimal object with just `textStream` and `result`. This doesn't satisfy all properties of `StreamTextResult`, but the `result` promise will reject with the actual error. The double cast (`as unknown as`) is needed because TypeScript knows this partial object doesn't match the full type. ## Usage Example ```typescript const agent = stagehand.agent({ stream: true, model: "anthropic/claude-sonnet-4-20250514", }); const result = await agent.execute({ instruction: "Search for something", maxSteps: 20, callbacks: { prepareStep: async (ctx) => { console.log("Preparing step..."); return ctx; }, onStepFinish: async (event) => { console.log(`Step finished: ${event.finishReason}`); if (event.toolCalls) { for (const tc of event.toolCalls) { console.log(`Tool used: ${tc.toolName}`); } } }, onChunk: async (chunk) => { // Process each chunk }, onFinish: (event) => { console.log(`Completed in ${event.steps.length} steps`); }, }, }); for await (const chunk of result.textStream) { process.stdout.write(chunk); } const finalResult = await result.result; console.log(finalResult.message); ``` ## Testing - Added `agent-callbacks.spec.ts` with tests for: - Non-streaming callbacks (`onStepFinish`, `prepareStep`) - Streaming callbacks (`onChunk`, `onFinish`, `prepareStep`, `onStepFinish`) - Combined callback usage - Tool call information in callbacks <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds lifecycle callbacks to the Stagehand agent for both non-streaming and streaming modes, letting users hook into steps, chunks, finish, and errors. Strong type safety and runtime validation prevent using streaming-only callbacks without stream: true; callbacks remain behind experimental. - **New Features** - Added runtime errors in non-streaming mode when onChunk/onFinish/onError/onAbort are provided, with clear messages instructing to set stream: true. <sup>Written for commit 15c08d1. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 90530b7 commit 45bcef0

File tree

9 files changed

+776
-23
lines changed

9 files changed

+776
-23
lines changed

.changeset/shiny-wings-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
Add support for callbacks in stagehand agent

packages/core/lib/v3/cache/AgentCache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
AgentResult,
2121
AgentStreamResult,
2222
AgentConfig,
23-
AgentExecuteOptions,
23+
AgentExecuteOptionsBase,
2424
Logger,
2525
} from "../types/public";
2626
import type { Page } from "../understudy/page";
@@ -74,7 +74,7 @@ export class AgentCache {
7474
}
7575

7676
sanitizeExecuteOptions(
77-
options?: AgentExecuteOptions,
77+
options?: AgentExecuteOptionsBase,
7878
): SanitizedAgentExecuteOptions {
7979
if (!options) return {};
8080
const sanitized: SanitizedAgentExecuteOptions = {};

packages/core/lib/v3/handlers/v3AgentHandler.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,27 @@ import {
88
stepCountIs,
99
type LanguageModelUsage,
1010
type StepResult,
11+
type GenerateTextOnStepFinishCallback,
12+
type StreamTextOnStepFinishCallback,
1113
} from "ai";
1214
import { processMessages } from "../agent/utils/messageProcessing";
1315
import { LLMClient } from "../llm/LLMClient";
1416
import {
1517
AgentExecuteOptions,
18+
AgentStreamExecuteOptions,
19+
AgentExecuteOptionsBase,
1620
AgentResult,
1721
AgentContext,
1822
AgentState,
1923
AgentStreamResult,
24+
AgentStreamCallbacks,
2025
} from "../types/public/agent";
2126
import { V3FunctionName } from "../types/public/methods";
2227
import { mapToolResultToActions } from "../agent/utils/actionMapping";
23-
import { MissingLLMConfigurationError } from "../types/public/sdkErrors";
28+
import {
29+
MissingLLMConfigurationError,
30+
StreamingCallbacksInNonStreamingModeError,
31+
} from "../types/public/sdkErrors";
2432

2533
export class V3AgentHandler {
2634
private v3: V3;
@@ -47,7 +55,7 @@ export class V3AgentHandler {
4755
}
4856

4957
private async prepareAgent(
50-
instructionOrOptions: string | AgentExecuteOptions,
58+
instructionOrOptions: string | AgentExecuteOptionsBase,
5159
): Promise<AgentContext> {
5260
try {
5361
const options =
@@ -102,7 +110,12 @@ export class V3AgentHandler {
102110
}
103111
}
104112

105-
private createStepHandler(state: AgentState) {
113+
private createStepHandler(
114+
state: AgentState,
115+
userCallback?:
116+
| GenerateTextOnStepFinishCallback<ToolSet>
117+
| StreamTextOnStepFinishCallback<ToolSet>,
118+
) {
106119
return async (event: StepResult<ToolSet>) => {
107120
this.logger({
108121
category: "agent",
@@ -150,6 +163,10 @@ export class V3AgentHandler {
150163
}
151164
state.currentPageUrl = (await this.v3.context.awaitActivePage()).url();
152165
}
166+
167+
if (userCallback) {
168+
await userCallback(event);
169+
}
153170
};
154171
}
155172

@@ -166,6 +183,23 @@ export class V3AgentHandler {
166183
initialPageUrl,
167184
} = await this.prepareAgent(instructionOrOptions);
168185

186+
const callbacks = (instructionOrOptions as AgentExecuteOptions).callbacks;
187+
188+
if (callbacks) {
189+
const streamingOnlyCallbacks = [
190+
"onChunk",
191+
"onFinish",
192+
"onError",
193+
"onAbort",
194+
];
195+
const invalidCallbacks = streamingOnlyCallbacks.filter(
196+
(name) => callbacks[name as keyof typeof callbacks] != null,
197+
);
198+
if (invalidCallbacks.length > 0) {
199+
throw new StreamingCallbacksInNonStreamingModeError(invalidCallbacks);
200+
}
201+
}
202+
169203
const state: AgentState = {
170204
collectedReasoning: [],
171205
actions: [],
@@ -183,7 +217,8 @@ export class V3AgentHandler {
183217
stopWhen: (result) => this.handleStop(result, maxSteps),
184218
temperature: 1,
185219
toolChoice: "auto",
186-
onStepFinish: this.createStepHandler(state),
220+
prepareStep: callbacks?.prepareStep,
221+
onStepFinish: this.createStepHandler(state, callbacks?.onStepFinish),
187222
});
188223

189224
return this.consolidateMetricsAndResult(startTime, state, result);
@@ -204,7 +239,7 @@ export class V3AgentHandler {
204239
}
205240

206241
public async stream(
207-
instructionOrOptions: string | AgentExecuteOptions,
242+
instructionOrOptions: string | AgentStreamExecuteOptions,
208243
): Promise<AgentStreamResult> {
209244
const {
210245
maxSteps,
@@ -215,6 +250,9 @@ export class V3AgentHandler {
215250
initialPageUrl,
216251
} = await this.prepareAgent(instructionOrOptions);
217252

253+
const callbacks = (instructionOrOptions as AgentStreamExecuteOptions)
254+
.callbacks as AgentStreamCallbacks | undefined;
255+
218256
const state: AgentState = {
219257
collectedReasoning: [],
220258
actions: [],
@@ -250,11 +288,19 @@ export class V3AgentHandler {
250288
stopWhen: (result) => this.handleStop(result, maxSteps),
251289
temperature: 1,
252290
toolChoice: "auto",
253-
onStepFinish: this.createStepHandler(state),
254-
onError: ({ error }) => {
255-
handleError(error);
291+
prepareStep: callbacks?.prepareStep,
292+
onStepFinish: this.createStepHandler(state, callbacks?.onStepFinish),
293+
onError: (event) => {
294+
if (callbacks?.onError) {
295+
callbacks.onError(event);
296+
}
297+
handleError(event.error);
256298
},
299+
onChunk: callbacks?.onChunk,
257300
onFinish: (event) => {
301+
if (callbacks?.onFinish) {
302+
callbacks.onFinish(event);
303+
}
258304
try {
259305
const result = this.consolidateMetricsAndResult(
260306
startTime,

0 commit comments

Comments
 (0)