From 915a613a581c76d8821319e83affa2fd451c95a1 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 13:49:42 -0800 Subject: [PATCH 01/15] initial commit --- .../examples/v3/agent_messages_and_signal.ts | 178 ++++++++ .../utils/validateExperimentalFeatures.ts | 87 ++++ .../core/lib/v3/handlers/v3AgentHandler.ts | 63 ++- .../lib/v3/tests/agent-abort-signal.spec.ts | 223 ++++++++++ .../agent-experimental-validation.spec.ts | 397 ++++++++++++++++++ .../tests/agent-message-continuation.spec.ts | 135 ++++++ packages/core/lib/v3/types/public/agent.ts | 32 ++ .../core/lib/v3/types/public/sdkErrors.ts | 29 ++ packages/core/lib/v3/v3.ts | 57 ++- .../tests/public-api/public-types.test.ts | 5 + 10 files changed, 1163 insertions(+), 43 deletions(-) create mode 100644 packages/core/examples/v3/agent_messages_and_signal.ts create mode 100644 packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts create mode 100644 packages/core/lib/v3/tests/agent-abort-signal.spec.ts create mode 100644 packages/core/lib/v3/tests/agent-experimental-validation.spec.ts create mode 100644 packages/core/lib/v3/tests/agent-message-continuation.spec.ts diff --git a/packages/core/examples/v3/agent_messages_and_signal.ts b/packages/core/examples/v3/agent_messages_and_signal.ts new file mode 100644 index 000000000..282f09e15 --- /dev/null +++ b/packages/core/examples/v3/agent_messages_and_signal.ts @@ -0,0 +1,178 @@ +/** + * Example: Agent Message Continuation and Abort Signal + * + * This example demonstrates two experimental features: + * 1. Message continuation - continuing a conversation across multiple execute() calls + * 2. Abort signal - cancelling an agent execution mid-run + * + * Note: These features require `experimental: true` in the Stagehand config. + */ + +import chalk from "chalk"; +import { V3 as Stagehand } from "../../lib/v3"; + +async function main() { + console.log( + `\n${chalk.bold("Stagehand Agent - Message Continuation & Abort Signal")}\n`, + ); + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 1, + experimental: true, // Required for messages and signal + }); + + await stagehand.init(); + + try { + const page = stagehand.context.pages()[0]; + await page.goto("https://news.ycombinator.com"); + + const agent = stagehand.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }); + + // ========================================= + // Part 1: Message Continuation + // ========================================= + console.log(chalk.cyan("\n--- Part 1: Message Continuation ---\n")); + + // First execution - ask about the page + console.log(chalk.yellow("First execution: Asking about the page...")); + const result1 = await agent.execute({ + instruction: + "What is this website? Give me a one sentence description. Use close tool with taskComplete: true after answering.", + maxSteps: 5, + }); + + console.log(chalk.green(`Result 1: ${result1.message}`)); + console.log( + chalk.gray(`Messages in conversation: ${result1.messages?.length}`), + ); + + // Second execution - continue the conversation + console.log( + chalk.yellow("\nSecond execution: Following up with context..."), + ); + const result2 = await agent.execute({ + instruction: + "Based on what you just told me, what kind of content would I typically find here? Use close tool with taskComplete: true after answering.", + maxSteps: 5, + messages: result1.messages, // Pass previous messages to continue conversation + }); + + console.log(chalk.green(`Result 2: ${result2.message}`)); + console.log( + chalk.gray(`Messages in conversation: ${result2.messages?.length}`), + ); + + // Third execution - even more context + console.log( + chalk.yellow("\nThird execution: Asking for recommendation..."), + ); + const result3 = await agent.execute({ + instruction: + "Would you recommend this site to a software developer? Yes or no, briefly explain. Use close tool with taskComplete: true.", + maxSteps: 5, + messages: result2.messages, // Continue from result2 + }); + + console.log(chalk.green(`Result 3: ${result3.message}`)); + console.log( + chalk.gray(`Total messages in conversation: ${result3.messages?.length}`), + ); + + // ========================================= + // Part 2: Abort Signal + // ========================================= + console.log(chalk.cyan("\n--- Part 2: Abort Signal ---\n")); + + // Example 2a: Manual abort with AbortController + console.log(chalk.yellow("Testing manual abort...")); + + const controller = new AbortController(); + + // Abort after 3 seconds + const abortTimeout = setTimeout(() => { + console.log(chalk.red("Aborting execution after 3 seconds...")); + controller.abort(); + }, 3000); + + const startTime1 = Date.now(); + try { + await agent.execute({ + instruction: + "Count to 100 slowly, saying each number out loud. Take your time between each number.", + maxSteps: 100, + signal: controller.signal, + }); + clearTimeout(abortTimeout); + console.log(chalk.green("Completed (unexpected)")); + } catch (err) { + clearTimeout(abortTimeout); + const elapsed = Date.now() - startTime1; + console.log(chalk.green(`Aborted successfully after ${elapsed}ms`)); + console.log(chalk.gray(`Error type: ${(err as Error).name}`)); + } + + // Example 2b: Using AbortSignal.timeout() + console.log(chalk.yellow("\nTesting AbortSignal.timeout()...")); + + const startTime2 = Date.now(); + try { + await agent.execute({ + instruction: + "List every country in the world alphabetically, with their capitals.", + maxSteps: 50, + signal: AbortSignal.timeout(2000), // 2 second timeout + }); + console.log(chalk.green("Completed (unexpected)")); + } catch { + const elapsed = Date.now() - startTime2; + console.log(chalk.green(`Timed out as expected after ${elapsed}ms`)); + } + + // ========================================= + // Part 3: Combining Both Features + // ========================================= + console.log(chalk.cyan("\n--- Part 3: Combined Usage ---\n")); + + console.log( + chalk.yellow("Using messages continuation with a timeout signal..."), + ); + + // Start a conversation + const initialResult = await agent.execute({ + instruction: + "What is the first story on this page? Use close tool with taskComplete: true.", + maxSteps: 5, + }); + + console.log(chalk.green(`Initial: ${initialResult.message}`)); + + // Continue with a timeout + try { + const followUp = await agent.execute({ + instruction: + "Now click on that story and tell me what it's about. Use close tool with taskComplete: true after.", + maxSteps: 10, + messages: initialResult.messages, + signal: AbortSignal.timeout(15000), // 15 second timeout + }); + + console.log(chalk.green(`Follow-up: ${followUp.message}`)); + } catch (error) { + console.log( + chalk.red(`Follow-up timed out: ${(error as Error).message}`), + ); + } + + console.log(chalk.green("\n--- Example Complete ---\n")); + } catch (error) { + console.error(chalk.red(`Error: ${error}`)); + } finally { + await stagehand.close(); + } +} + +main().catch(console.error); diff --git a/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts b/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts new file mode 100644 index 000000000..dd0f19f2e --- /dev/null +++ b/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts @@ -0,0 +1,87 @@ +import { + ExperimentalNotConfiguredError, + StagehandInvalidArgumentError, +} from "../../types/public/sdkErrors"; +import type { AgentConfig, AgentExecuteOptionsBase } from "../../types/public"; + +export interface AgentValidationOptions { + /** Whether experimental mode is enabled */ + isExperimental: boolean; + /** Agent config options (integrations, tools, stream, cua, etc.) */ + agentConfig?: Partial; + /** Execute options (callbacks, signal, messages, etc.) */ + executeOptions?: + | (Partial & { callbacks?: unknown }) + | null; + /** Whether this is streaming mode (can be derived from agentConfig.stream) */ + isStreaming?: boolean; +} + +/** + * Validates agent configuration and experimental feature usage. + * + * This utility consolidates all validation checks for both CUA and non-CUA agent paths: + * - Invalid argument errors for CUA (streaming, abort signal, message continuation are not supported) + * - Experimental feature checks for non-CUA (integrations, tools, callbacks, signal, messages, streaming) + * + * Throws StagehandInvalidArgumentError for invalid/unsupported configurations. + * Throws ExperimentalNotConfiguredError if experimental features are used without experimental mode. + */ +export function validateExperimentalFeatures( + options: AgentValidationOptions, +): void { + const { isExperimental, agentConfig, executeOptions, isStreaming } = options; + + // CUA-specific validation: certain features are not available at all + if (agentConfig?.cua) { + const unsupportedFeatures: string[] = []; + + if (agentConfig?.stream) { + unsupportedFeatures.push("streaming"); + } + if (executeOptions?.signal) { + unsupportedFeatures.push("abort signal"); + } + if (executeOptions?.messages) { + unsupportedFeatures.push("message continuation"); + } + + if (unsupportedFeatures.length > 0) { + throw new StagehandInvalidArgumentError( + `${unsupportedFeatures.join(", ")} ${unsupportedFeatures.length === 1 ? "is" : "are"} not supported with CUA (Computer Use Agent) mode.`, + ); + } + } + + // Skip experimental checks if already in experimental mode + if (isExperimental) return; + + const features: string[] = []; + + // Check agent config features + if (agentConfig?.integrations || agentConfig?.tools) { + features.push("MCP integrations and custom tools"); + } + + // Check streaming mode (either explicit or derived from config) - only for non-CUA + if (!agentConfig?.cua && (isStreaming || agentConfig?.stream)) { + features.push("streaming"); + } + + // Check execute options features - only for non-CUA + if (executeOptions && !agentConfig?.cua) { + if (executeOptions.callbacks) { + features.push("callbacks"); + } + if (executeOptions.signal) { + features.push("abort signal"); + } + if (executeOptions.messages) { + features.push("message continuation"); + } + } + + if (features.length > 0) { + throw new ExperimentalNotConfiguredError(`Agent ${features.join(", ")}`); + } +} diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 6648256bf..780931730 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -28,6 +28,7 @@ import { mapToolResultToActions } from "../agent/utils/actionMapping"; import { MissingLLMConfigurationError, StreamingCallbacksInNonStreamingModeError, + AgentAbortError, } from "../types/public/sdkErrors"; export class V3AgentHandler { @@ -71,9 +72,11 @@ export class V3AgentHandler { ); const tools = this.createTools(); const allTools: ToolSet = { ...tools, ...this.mcpTools }; - const messages: ModelMessage[] = [ - { role: "user", content: options.instruction }, - ]; + + // Use provided messages for continuation, or start fresh with the instruction + const messages: ModelMessage[] = options.messages?.length + ? [...options.messages, { role: "user", content: options.instruction }] + : [{ role: "user", content: options.instruction }]; if (!this.llmClient?.getLanguageModel) { throw new MissingLLMConfigurationError(); @@ -175,6 +178,7 @@ export class V3AgentHandler { ): Promise { const startTime = Date.now(); const { + options, maxSteps, systemPrompt, allTools, @@ -184,6 +188,7 @@ export class V3AgentHandler { } = await this.prepareAgent(instructionOrOptions); const callbacks = (instructionOrOptions as AgentExecuteOptions).callbacks; + const abortSignal = options.signal; if (callbacks) { const streamingOnlyCallbacks = [ @@ -219,9 +224,15 @@ export class V3AgentHandler { toolChoice: "auto", prepareStep: callbacks?.prepareStep, onStepFinish: this.createStepHandler(state, callbacks?.onStepFinish), + abortSignal, }); - return this.consolidateMetricsAndResult(startTime, state, result); + return this.consolidateMetricsAndResult( + startTime, + state, + messages, + result, + ); } catch (error) { const errorMessage = error?.message ?? String(error); this.logger({ @@ -229,11 +240,18 @@ export class V3AgentHandler { message: `Error executing agent task: ${errorMessage}`, level: 0, }); + + // Check if this is an abort error and re-throw as AgentAbortError + if (AgentAbortError.isAbortError(error)) { + throw new AgentAbortError(errorMessage); + } + return { success: false, actions: state.actions, message: `Failed to execute task: ${errorMessage}`, completed: false, + messages, // Include messages even on error for conversation context }; } } @@ -242,6 +260,7 @@ export class V3AgentHandler { instructionOrOptions: string | AgentStreamExecuteOptions, ): Promise { const { + options, maxSteps, systemPrompt, allTools, @@ -252,6 +271,7 @@ export class V3AgentHandler { const callbacks = (instructionOrOptions as AgentStreamExecuteOptions) .callbacks as AgentStreamCallbacks | undefined; + const abortSignal = options.signal; const state: AgentState = { collectedReasoning: [], @@ -272,12 +292,18 @@ export class V3AgentHandler { const handleError = (error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger({ - category: "agent", - message: `Error during streaming: ${errorMessage}`, - level: 0, - }); - rejectResult(error); + + // Wrap abort errors in AgentAbortError + if (AgentAbortError.isAbortError(error)) { + rejectResult(new AgentAbortError(errorMessage)); + } else { + this.logger({ + category: "agent", + message: `Error during streaming: ${errorMessage}`, + level: 0, + }); + rejectResult(error); + } }; const streamResult = this.llmClient.streamText({ @@ -305,6 +331,7 @@ export class V3AgentHandler { const result = this.consolidateMetricsAndResult( startTime, state, + messages, event, ); resolveResult(result); @@ -312,6 +339,7 @@ export class V3AgentHandler { handleError(error); } }, + abortSignal, }); const agentStreamResult = streamResult as AgentStreamResult; @@ -322,7 +350,12 @@ export class V3AgentHandler { private consolidateMetricsAndResult( startTime: number, state: AgentState, - result: { text?: string; usage?: LanguageModelUsage }, + inputMessages: ModelMessage[], + result: { + text?: string; + usage?: LanguageModelUsage; + response?: { messages?: ModelMessage[] }; + }, ): AgentResult { if (!state.finalMessage) { const allReasoning = state.collectedReasoning.join(" ").trim(); @@ -342,6 +375,13 @@ export class V3AgentHandler { ); } + // Combine input messages with response messages for full conversation history + const responseMessages = result.response?.messages || []; + const fullMessages: ModelMessage[] = [ + ...inputMessages, + ...responseMessages, + ]; + return { success: state.completed, message: state.finalMessage || "Task execution completed", @@ -356,6 +396,7 @@ export class V3AgentHandler { inference_time_ms: inferenceTimeMs, } : undefined, + messages: fullMessages, }; } diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts new file mode 100644 index 000000000..668359a49 --- /dev/null +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -0,0 +1,223 @@ +import { test, expect } from "@playwright/test"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; +import { AgentAbortError } from "../types/public/sdkErrors"; + +test.describe("Stagehand agent abort signal", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("abort signal stops execution and throws AgentAbortError", async () => { + test.setTimeout(30000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + const controller = new AbortController(); + + // Abort after a short delay + setTimeout(() => controller.abort(), 500); + + const startTime = Date.now(); + + try { + await agent.execute({ + instruction: + "Describe everything on this page in extreme detail. Take your time and be very thorough. Do not use close tool until you have described every single element.", + maxSteps: 50, + signal: controller.signal, + }); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + // Should throw AgentAbortError + expect(error).toBeInstanceOf(AgentAbortError); + expect((error as AgentAbortError).reason).toContain("aborted"); + } + + const elapsed = Date.now() - startTime; + + // Should have stopped relatively quickly (within a few seconds of abort) + // Not waiting for all 50 steps + expect(elapsed).toBeLessThan(15000); + }); + + test("AbortSignal.timeout throws AgentAbortError", async () => { + test.setTimeout(30000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + const startTime = Date.now(); + + try { + await agent.execute({ + instruction: + "Describe everything on this page in extreme detail. Take your time. Do not use close tool until done.", + maxSteps: 50, + signal: AbortSignal.timeout(1000), // 1 second timeout + }); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + // Should throw AgentAbortError + expect(error).toBeInstanceOf(AgentAbortError); + } + + const elapsed = Date.now() - startTime; + + // Should have stopped around the timeout (with some margin) + expect(elapsed).toBeLessThan(10000); + }); + + test("streaming mode throws AgentAbortError on abort", async () => { + test.setTimeout(90000); + + const agent = v3.agent({ + stream: true, + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + // Use AbortSignal.timeout for more reliable abort + const signal = AbortSignal.timeout(2000); // 2 second timeout + + const startTime = Date.now(); + let caughtError: unknown = null; + + try { + const streamResult = await agent.execute({ + instruction: + "Describe everything on this page in extreme detail. Take your time and list every single element.", + maxSteps: 50, + signal, + }); + + // Try to consume the stream + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of streamResult.textStream) { + // Just consume - abort should interrupt this + } + await streamResult.result; + } catch (error) { + caughtError = error; + } + + const elapsed = Date.now() - startTime; + + // Should have thrown AgentAbortError (or completed very quickly) + if (caughtError) { + expect(caughtError).toBeInstanceOf(AgentAbortError); + } + // Should have stopped within reasonable time (not running all 50 steps) + expect(elapsed).toBeLessThan(30000); + }); + + test("execution completes normally without abort signal", async () => { + test.setTimeout(60000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + // No signal provided - should complete normally + const result = await agent.execute({ + instruction: "Use close tool with taskComplete: true immediately.", + maxSteps: 3, + }); + + expect(result.success).toBe(true); + expect(result.completed).toBe(true); + }); + + test("already aborted signal throws AgentAbortError immediately", async () => { + test.setTimeout(10000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + // Create an already aborted controller + const controller = new AbortController(); + controller.abort(); + + try { + await agent.execute({ + instruction: "This should not run.", + maxSteps: 3, + signal: controller.signal, + }); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + // Should throw AgentAbortError immediately + expect(error).toBeInstanceOf(AgentAbortError); + } + }); + + test("can use both messages and signal together", async () => { + test.setTimeout(90000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + // First execution + const result1 = await agent.execute({ + instruction: + "What is the title of this page? Use close tool with taskComplete: true.", + maxSteps: 5, + }); + + expect(result1.messages).toBeDefined(); + + // Second execution with both messages and signal + const controller = new AbortController(); + + // Give enough time for a normal quick task + setTimeout(() => controller.abort(), 10000); + + const result2 = await agent.execute({ + instruction: + "Say 'confirmed' and use close tool with taskComplete: true.", + maxSteps: 3, + messages: result1.messages, + signal: controller.signal, + }); + + // Should complete before timeout + expect(result2.success).toBe(true); + expect(result2.messages).toBeDefined(); + expect(result2.messages!.length).toBeGreaterThan(result1.messages!.length); + }); +}); diff --git a/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts b/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts new file mode 100644 index 000000000..5b05c2a90 --- /dev/null +++ b/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts @@ -0,0 +1,397 @@ +import { test, expect } from "@playwright/test"; +import { z } from "zod"; +import { tool } from "ai"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; +import { + ExperimentalNotConfiguredError, + StagehandInvalidArgumentError, +} from "../types/public/sdkErrors"; + +// Define a mock custom tool for testing +const mockCustomTool = tool({ + description: "A mock tool for testing", + inputSchema: z.object({ + input: z.string().describe("The input string"), + }), + execute: async ({ input }) => { + return `Processed: ${input}`; + }, +}); + +test.describe("Stagehand agent experimental feature validation", () => { + test.describe("Invalid argument errors", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: false, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("throws StagehandInvalidArgumentError when CUA and streaming are both enabled", async () => { + try { + v3.agent({ + cua: true, + stream: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + expect((error as Error).message).toContain("streaming"); + expect((error as Error).message).toContain("not supported with CUA"); + } + }); + + test("throws StagehandInvalidArgumentError for CUA + streaming even with experimental: true", async () => { + // Close the non-experimental instance + await v3.close(); + + // Create an experimental instance + const v3Experimental = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3Experimental.init(); + + try { + v3Experimental.agent({ + cua: true, + stream: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + expect((error as Error).message).toContain("streaming"); + expect((error as Error).message).toContain("not supported with CUA"); + } finally { + await v3Experimental.close(); + } + }); + }); + + test.describe("Experimental feature errors without experimental: true", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: false, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("throws ExperimentalNotConfiguredError for MCP integrations", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + integrations: ["https://mcp.example.com"], + }); + + try { + await agent.execute("test"); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain( + "MCP integrations and custom tools", + ); + } + }); + + test("throws ExperimentalNotConfiguredError for custom tools", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + tools: { + mockCustomTool, + }, + }); + + try { + await agent.execute("test"); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain( + "MCP integrations and custom tools", + ); + } + }); + + test("throws ExperimentalNotConfiguredError for streaming mode", async () => { + try { + const agent = v3.agent({ + stream: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + await agent.execute("test instruction"); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain("streaming"); + } + }); + + test("throws ExperimentalNotConfiguredError for callbacks", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }); + + try { + await agent.execute({ + instruction: "test", + callbacks: { + onStepFinish: async () => {}, + }, + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain("callbacks"); + } + }); + + test("throws ExperimentalNotConfiguredError for abort signal", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }); + + const controller = new AbortController(); + try { + await agent.execute({ + instruction: "test", + signal: controller.signal, + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain("abort signal"); + } + }); + + test("throws ExperimentalNotConfiguredError for message continuation", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }); + + try { + await agent.execute({ + instruction: "test", + messages: [{ role: "user", content: "previous message" }], + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain("message continuation"); + } + }); + + test("throws ExperimentalNotConfiguredError listing multiple features", async () => { + const agent = v3.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }); + + const controller = new AbortController(); + try { + await agent.execute({ + instruction: "test", + callbacks: { onStepFinish: async () => {} }, + signal: controller.signal, + messages: [{ role: "user", content: "previous" }], + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + const message = (error as Error).message; + expect(message).toContain("callbacks"); + expect(message).toContain("abort signal"); + expect(message).toContain("message continuation"); + } + }); + }); + + test.describe("CUA agent unsupported features", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: false, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("throws ExperimentalNotConfiguredError for CUA with integrations", async () => { + // MCP integrations are still an experimental feature check (not unsupported) + try { + v3.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + integrations: ["https://mcp.example.com"], + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ExperimentalNotConfiguredError); + expect((error as Error).message).toContain( + "MCP integrations and custom tools", + ); + } + }); + + test("throws StagehandInvalidArgumentError for CUA with abort signal (not supported)", async () => { + const agent = v3.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + + const controller = new AbortController(); + try { + await agent.execute({ + instruction: "test", + signal: controller.signal, + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + expect((error as Error).message).toContain("abort signal"); + expect((error as Error).message).toContain("not supported with CUA"); + } + }); + + test("throws StagehandInvalidArgumentError for CUA with message continuation (not supported)", async () => { + const agent = v3.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + + try { + await agent.execute({ + instruction: "test", + messages: [{ role: "user", content: "previous message" }], + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + expect((error as Error).message).toContain("message continuation"); + expect((error as Error).message).toContain("not supported with CUA"); + } + }); + + test("throws StagehandInvalidArgumentError for CUA with multiple unsupported features", async () => { + const agent = v3.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + + const controller = new AbortController(); + try { + await agent.execute({ + instruction: "test", + signal: controller.signal, + messages: [{ role: "user", content: "previous message" }], + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + const message = (error as Error).message; + expect(message).toContain("abort signal"); + expect(message).toContain("message continuation"); + expect(message).toContain("are not supported with CUA"); + } + }); + + test("throws StagehandInvalidArgumentError for CUA unsupported features even with experimental: true", async () => { + // Close the non-experimental instance + await v3.close(); + + // Create an experimental instance + const v3Experimental = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3Experimental.init(); + + const agent = v3Experimental.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + }); + + const controller = new AbortController(); + try { + await agent.execute({ + instruction: "test", + signal: controller.signal, + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(StagehandInvalidArgumentError); + expect((error as Error).message).toContain("not supported with CUA"); + } finally { + await v3Experimental.close(); + } + }); + }); + + test.describe("Valid configurations with experimental: true", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("allows CUA without streaming", () => { + expect(() => + v3.agent({ + cua: true, + model: "anthropic/claude-sonnet-4-20250514", + }), + ).not.toThrow(); + }); + + test("allows streaming mode", () => { + expect(() => + v3.agent({ + stream: true, + model: "anthropic/claude-sonnet-4-20250514", + }), + ).not.toThrow(); + }); + + test("allows basic agent without experimental features", () => { + const v3NonExperimental = new V3({ + ...v3TestConfig, + experimental: false, + }); + + // This should work - just creating a basic agent with no experimental features + expect(() => + v3NonExperimental.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/core/lib/v3/tests/agent-message-continuation.spec.ts b/packages/core/lib/v3/tests/agent-message-continuation.spec.ts new file mode 100644 index 000000000..7ffffa160 --- /dev/null +++ b/packages/core/lib/v3/tests/agent-message-continuation.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from "@playwright/test"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; +import type { ModelMessage } from "ai"; + +test.describe("Stagehand agent message continuation", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("execute returns messages in the result", async () => { + test.setTimeout(60000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + const result = await agent.execute({ + instruction: + "What is the title of this page? Use close tool with taskComplete: true after answering.", + maxSteps: 5, + }); + + // Result should contain messages + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages!.length).toBeGreaterThan(0); + + // First message should be the user instruction + const firstMessage = result.messages![0]; + expect(firstMessage.role).toBe("user"); + }); + + test("can continue conversation with previous messages", async () => { + test.setTimeout(120000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + // First execution + const result1 = await agent.execute({ + instruction: + "What is the title of this page? Use close tool with taskComplete: true after answering.", + maxSteps: 5, + }); + + expect(result1.messages).toBeDefined(); + expect(result1.messages!.length).toBeGreaterThan(0); + + // Second execution continuing from first + const result2 = await agent.execute({ + instruction: + "Based on what you just told me, is this a simple or complex website? Use close tool with taskComplete: true after answering.", + maxSteps: 5, + messages: result1.messages, + }); + + expect(result2.messages).toBeDefined(); + // Second result should have more messages (includes first conversation) + expect(result2.messages!.length).toBeGreaterThan(result1.messages!.length); + }); + + test("messages include tool calls and results", async () => { + test.setTimeout(60000); + + const agent = v3.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + const result = await agent.execute({ + instruction: + "Use the ariaTree tool to see the page, then use close tool with taskComplete: true.", + maxSteps: 5, + }); + + expect(result.messages).toBeDefined(); + + // Should have assistant messages with tool calls + const hasAssistantMessage = result.messages!.some( + (m: ModelMessage) => m.role === "assistant", + ); + expect(hasAssistantMessage).toBe(true); + }); + + test("streaming mode also returns messages", async () => { + test.setTimeout(60000); + + const agent = v3.agent({ + stream: true, + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3.context.pages()[0]; + await page.goto("https://example.com"); + + const streamResult = await agent.execute({ + instruction: + "What is this page? Use close tool with taskComplete: true after answering.", + maxSteps: 5, + }); + + // Consume the stream + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of streamResult.textStream) { + // Just consume + } + + const result = await streamResult.result; + + // Result should contain messages + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages!.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/lib/v3/types/public/agent.ts b/packages/core/lib/v3/types/public/agent.ts index 54ed72345..21874c690 100644 --- a/packages/core/lib/v3/types/public/agent.ts +++ b/packages/core/lib/v3/types/public/agent.ts @@ -14,6 +14,9 @@ import { } from "ai"; import { LogLine } from "./logs"; import { ClientOptions } from "./model"; + +// Re-export ModelMessage for consumers who want to use it for conversation continuation +export type { ModelMessage } from "ai"; import { Page as PlaywrightPage } from "playwright-core"; import { Page as PuppeteerPage } from "puppeteer-core"; import { Page as PatchrightPage } from "patchright-core"; @@ -63,6 +66,12 @@ export interface AgentResult { cached_input_tokens?: number; inference_time_ms: number; }; + /** + * The conversation messages from this execution. + * Pass these to a subsequent execute() call via the `messages` option to continue the conversation. + * @experimental + */ + messages?: ModelMessage[]; } export type AgentStreamResult = StreamTextResult & { @@ -207,6 +216,29 @@ export interface AgentExecuteOptionsBase { maxSteps?: number; page?: PlaywrightPage | PuppeteerPage | PatchrightPage | Page; highlightCursor?: boolean; + /** + * Previous conversation messages to continue from. + * Pass the `messages` from a previous AgentResult to continue that conversation. + * @experimental + */ + messages?: ModelMessage[]; + /** + * An AbortSignal that can be used to cancel the agent execution. + * When aborted, the agent will stop and return a partial result. + * @experimental + * + * @example + * ```typescript + * const controller = new AbortController(); + * setTimeout(() => controller.abort(), 30000); // 30 second timeout + * + * const result = await agent.execute({ + * instruction: "...", + * signal: controller.signal + * }); + * ``` + */ + signal?: AbortSignal; } /** diff --git a/packages/core/lib/v3/types/public/sdkErrors.ts b/packages/core/lib/v3/types/public/sdkErrors.ts index 8840aa30e..8371880ea 100644 --- a/packages/core/lib/v3/types/public/sdkErrors.ts +++ b/packages/core/lib/v3/types/public/sdkErrors.ts @@ -331,3 +331,32 @@ export class StreamingCallbacksInNonStreamingModeError extends StagehandError { this.invalidCallbacks = invalidCallbacks; } } + +export class AgentAbortError extends StagehandError { + public readonly reason: string; + + constructor(reason?: string) { + const message = reason + ? `Agent execution was aborted: ${reason}` + : "Agent execution was aborted"; + super(message); + this.reason = reason || "aborted"; + } + + /** + * Check if an error is an abort-related error (either AgentAbortError or native AbortError) + */ + static isAbortError(error: unknown): boolean { + if (error instanceof AgentAbortError) { + return true; + } + if (error instanceof Error) { + return ( + error.name === "AbortError" || + error.message.includes("aborted") || + error.message.includes("abort") + ); + } + return false; + } +} diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 4cd17fc94..ec452ff37 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -60,7 +60,6 @@ import { PatchrightPage, PlaywrightPage, PuppeteerPage, - ExperimentalNotConfiguredError, CuaModelRequiredError, StagehandInvalidArgumentError, StagehandNotInitializedError, @@ -72,6 +71,7 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; +import { validateExperimentalFeatures } from "./agent/utils/validateExperimentalFeatures"; const DEFAULT_MODEL_NAME = "openai/gpt-4.1-mini"; const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; @@ -1511,12 +1511,7 @@ export class V3 { instruction: string; cacheContext: AgentCacheContext | null; }> { - if ((options?.integrations || options?.tools) && !this.experimental) { - throw new ExperimentalNotConfiguredError( - "MCP integrations and custom tools", - ); - } - + // Note: experimental validation is done at the call site before this method const tools = options?.integrations ? await resolveTools(options.integrations, options.tools) : (options?.tools ?? {}); @@ -1611,17 +1606,11 @@ export class V3 { // If CUA is enabled, use the computer-use agent path if (options?.cua) { - if (options?.stream) { - throw new StagehandInvalidArgumentError( - "Streaming is not supported with CUA (Computer Use Agent) mode. Remove either 'stream: true' or 'cua: true' from your agent config.", - ); - } - - if ((options?.integrations || options?.tools) && !this.experimental) { - throw new ExperimentalNotConfiguredError( - "MCP integrations and custom tools", - ); - } + // Validate agent config at creation time (includes CUA+streaming conflict check) + validateExperimentalFeatures({ + isExperimental: this.experimental, + agentConfig: options, + }); const modelToUse = options?.model || { modelName: this.modelName, @@ -1639,9 +1628,15 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { - if (options?.integrations && !this.experimental) { - throw new ExperimentalNotConfiguredError("MCP integrations"); - } + validateExperimentalFeatures({ + isExperimental: this.experimental, + agentConfig: options, + executeOptions: + typeof instructionOrOptions === "object" + ? instructionOrOptions + : null, + }); + const tools = options?.integrations ? await resolveTools(options.integrations, options.tools) : (options?.tools ?? {}); @@ -1741,20 +1736,18 @@ export class V3 { | AgentStreamExecuteOptions, ): Promise => withInstanceLogContext(this.instanceId, async () => { - if ( - typeof instructionOrOptions === "object" && - instructionOrOptions.callbacks && - !this.experimental - ) { - throw new ExperimentalNotConfiguredError("Agent callbacks"); - } + validateExperimentalFeatures({ + isExperimental: this.experimental, + agentConfig: options, + executeOptions: + typeof instructionOrOptions === "object" + ? instructionOrOptions + : null, + isStreaming, + }); // Streaming mode if (isStreaming) { - if (!this.experimental) { - throw new ExperimentalNotConfiguredError("Agent streaming"); - } - const { handler, cacheContext } = await this.prepareAgentExecution( options, instructionOrOptions, diff --git a/packages/core/tests/public-api/public-types.test.ts b/packages/core/tests/public-api/public-types.test.ts index 28b3f7bc9..695dec673 100644 --- a/packages/core/tests/public-api/public-types.test.ts +++ b/packages/core/tests/public-api/public-types.test.ts @@ -183,6 +183,8 @@ describe("Stagehand public API types", () => { maxSteps?: number; page?: Stagehand.AnyPage; highlightCursor?: boolean; + messages?: Stagehand.ModelMessage[]; + signal?: AbortSignal; callbacks?: Stagehand.AgentExecuteCallbacks; }; @@ -197,6 +199,8 @@ describe("Stagehand public API types", () => { maxSteps?: number; page?: Stagehand.AnyPage; highlightCursor?: boolean; + messages?: Stagehand.ModelMessage[]; + signal?: AbortSignal; callbacks?: Stagehand.AgentStreamCallbacks; }; @@ -235,6 +239,7 @@ describe("Stagehand public API types", () => { cached_input_tokens?: number; inference_time_ms: number; }; + messages?: Stagehand.ModelMessage[]; }; it("matches expected type shape", () => { From f8c3554b54be7b2bcba007ab82dde4dfe90f3e4a Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 14:27:49 -0800 Subject: [PATCH 02/15] add abort on close --- .../lib/v3/agent/utils/combineAbortSignals.ts | 48 +++++++ .../core/lib/v3/agent/utils/errorHandling.ts | 47 ++++++ .../core/lib/v3/handlers/v3AgentHandler.ts | 134 ++++++++++++------ .../lib/v3/tests/agent-abort-signal.spec.ts | 47 ++++++ .../core/lib/v3/tests/agent-streaming.spec.ts | 2 +- packages/core/lib/v3/v3.ts | 39 +++-- 6 files changed, 264 insertions(+), 53 deletions(-) create mode 100644 packages/core/lib/v3/agent/utils/combineAbortSignals.ts create mode 100644 packages/core/lib/v3/agent/utils/errorHandling.ts diff --git a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts new file mode 100644 index 000000000..e08e738e0 --- /dev/null +++ b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts @@ -0,0 +1,48 @@ +/** + * Combines multiple AbortSignals into a single signal that aborts when any of them abort. + * + * Uses AbortSignal.any() if available (Node 20+), otherwise falls back to a manual implementation. + * + * @param signals - Array of AbortSignals to combine (undefined signals are filtered out) + * @returns A combined AbortSignal, or undefined if no valid signals provided + */ +export function combineAbortSignals( + ...signals: (AbortSignal | undefined)[] +): AbortSignal | undefined { + const validSignals = signals.filter( + (s): s is AbortSignal => s !== undefined, + ); + + if (validSignals.length === 0) { + return undefined; + } + + if (validSignals.length === 1) { + return validSignals[0]; + } + + // Use AbortSignal.any() if available (Node 20+) + if (typeof AbortSignal.any === "function") { + return AbortSignal.any(validSignals); + } + + // Fallback for older environments + const controller = new AbortController(); + + for (const signal of validSignals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + + signal.addEventListener( + "abort", + () => { + controller.abort(signal.reason); + }, + { once: true }, + ); + } + + return controller.signal; +} diff --git a/packages/core/lib/v3/agent/utils/errorHandling.ts b/packages/core/lib/v3/agent/utils/errorHandling.ts new file mode 100644 index 000000000..810d4fe45 --- /dev/null +++ b/packages/core/lib/v3/agent/utils/errorHandling.ts @@ -0,0 +1,47 @@ +import { AgentAbortError } from "../../types/public/sdkErrors"; + +/** + * Extracts the abort signal from instruction or options. + */ +export function extractAbortSignal( + instructionOrOptions: string | { signal?: AbortSignal }, +): AbortSignal | undefined { + return typeof instructionOrOptions === "object" + ? instructionOrOptions.signal + : undefined; +} + +/** + * Consistently extracts an error message from an unknown error. + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Checks if an error is abort-related (either an abort error type or the signal was aborted). + * Returns the appropriate reason string if it's an abort, or null if not. + * + * @param error - The caught error + * @param abortSignal - The abort signal to check + * @returns The abort reason string if abort-related, null otherwise + */ +export function getAbortErrorReason( + error: unknown, + abortSignal?: AbortSignal, +): string | null { + if (!AgentAbortError.isAbortError(error) && !abortSignal?.aborted) { + return null; + } + + // Prefer the signal's reason if available + if (abortSignal?.reason) { + return String(abortSignal.reason); + } + + // Fall back to the error message + return getErrorMessage(error); +} diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 780931730..402266ea6 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -30,6 +30,11 @@ import { StreamingCallbacksInNonStreamingModeError, AgentAbortError, } from "../types/public/sdkErrors"; +import { + extractAbortSignal, + getErrorMessage, + getAbortErrorReason, +} from "../agent/utils/errorHandling"; export class V3AgentHandler { private v3: V3; @@ -177,43 +182,59 @@ export class V3AgentHandler { instructionOrOptions: string | AgentExecuteOptions, ): Promise { const startTime = Date.now(); - const { - options, - maxSteps, - systemPrompt, - allTools, - messages, - wrappedModel, - initialPageUrl, - } = await this.prepareAgent(instructionOrOptions); - - const callbacks = (instructionOrOptions as AgentExecuteOptions).callbacks; - const abortSignal = options.signal; - - if (callbacks) { - const streamingOnlyCallbacks = [ - "onChunk", - "onFinish", - "onError", - "onAbort", - ]; - const invalidCallbacks = streamingOnlyCallbacks.filter( - (name) => callbacks[name as keyof typeof callbacks] != null, - ); - if (invalidCallbacks.length > 0) { - throw new StreamingCallbacksInNonStreamingModeError(invalidCallbacks); - } - } + + // Extract abort signal early so we can check it in error handling. + // This is needed because when stagehand.close() is called, the abort signal + // is triggered but the resulting error might be something else (e.g., null context). + // By having the signal reference, we can detect abort-related errors regardless + // of their actual error type. + const abortSignal = extractAbortSignal(instructionOrOptions); const state: AgentState = { collectedReasoning: [], actions: [], finalMessage: "", completed: false, - currentPageUrl: initialPageUrl, + currentPageUrl: "", }; + let messages: ModelMessage[] = []; + + // Wrap everything in try-catch to handle abort signals properly. + // When close() aborts the signal, errors can occur at any point (during + // prepareAgent, generateText, etc.). We catch all errors and check if + // the abort signal was the root cause. try { + const { + options, + maxSteps, + systemPrompt, + allTools, + messages: preparedMessages, + wrappedModel, + initialPageUrl, + } = await this.prepareAgent(instructionOrOptions); + + messages = preparedMessages; + state.currentPageUrl = initialPageUrl; + + const callbacks = (instructionOrOptions as AgentExecuteOptions).callbacks; + + if (callbacks) { + const streamingOnlyCallbacks = [ + "onChunk", + "onFinish", + "onError", + "onAbort", + ]; + const invalidCallbacks = streamingOnlyCallbacks.filter( + (name) => callbacks[name as keyof typeof callbacks] != null, + ); + if (invalidCallbacks.length > 0) { + throw new StreamingCallbacksInNonStreamingModeError(invalidCallbacks); + } + } + const result = await this.llmClient.generateText({ model: wrappedModel, system: systemPrompt, @@ -224,7 +245,7 @@ export class V3AgentHandler { toolChoice: "auto", prepareStep: callbacks?.prepareStep, onStepFinish: this.createStepHandler(state, callbacks?.onStepFinish), - abortSignal, + abortSignal: options.signal, }); return this.consolidateMetricsAndResult( @@ -234,24 +255,34 @@ export class V3AgentHandler { result, ); } catch (error) { - const errorMessage = error?.message ?? String(error); + // Re-throw validation errors that should propagate to the caller + if (error instanceof StreamingCallbacksInNonStreamingModeError) { + throw error; + } + + const errorMessage = getErrorMessage(error); this.logger({ category: "agent", message: `Error executing agent task: ${errorMessage}`, level: 0, }); - // Check if this is an abort error and re-throw as AgentAbortError - if (AgentAbortError.isAbortError(error)) { - throw new AgentAbortError(errorMessage); + // Check if this error was caused by an abort signal (either directly or indirectly). + // When stagehand.close() is called, it aborts the signal which may cause various + // errors (e.g., "Cannot read properties of null" when context is nullified). + // We detect these by checking if the signal is aborted and wrap them in AgentAbortError. + const abortReason = getAbortErrorReason(error, abortSignal); + if (abortReason) { + throw new AgentAbortError(abortReason); } + // For non-abort errors, return a failure result instead of throwing return { success: false, actions: state.actions, message: `Failed to execute task: ${errorMessage}`, completed: false, - messages, // Include messages even on error for conversation context + messages, }; } } @@ -259,19 +290,35 @@ export class V3AgentHandler { public async stream( instructionOrOptions: string | AgentStreamExecuteOptions, ): Promise { + // Extract abort signal early so we can check it in error handling. + // See execute() for detailed explanation of why this is needed. + const abortSignal = extractAbortSignal(instructionOrOptions); + // Wrap prepareAgent in try-catch to handle abort signals during preparation. + // When stagehand.close() is called, the context may be nullified before + // prepareAgent completes, causing errors like "Cannot read properties of null". + // We catch these and check if the abort signal was the root cause. + let preparedAgent: Awaited>; + try { + preparedAgent = await this.prepareAgent(instructionOrOptions); + } catch (error) { + const abortReason = getAbortErrorReason(error, abortSignal); + if (abortReason) { + throw new AgentAbortError(abortReason); + } + throw error; + } + const { - options, maxSteps, systemPrompt, allTools, messages, wrappedModel, initialPageUrl, - } = await this.prepareAgent(instructionOrOptions); + } = preparedAgent; const callbacks = (instructionOrOptions as AgentStreamExecuteOptions) .callbacks as AgentStreamCallbacks | undefined; - const abortSignal = options.signal; const state: AgentState = { collectedReasoning: [], @@ -289,17 +336,16 @@ export class V3AgentHandler { rejectResult = reject; }); + // Handle errors during streaming, converting abort-related errors to AgentAbortError. + // This ensures consistent error handling whether abort happens during streaming or preparation. const handleError = (error: unknown) => { - const errorMessage = - error instanceof Error ? error.message : String(error); - - // Wrap abort errors in AgentAbortError - if (AgentAbortError.isAbortError(error)) { - rejectResult(new AgentAbortError(errorMessage)); + const abortReason = getAbortErrorReason(error, abortSignal); + if (abortReason) { + rejectResult(new AgentAbortError(abortReason)); } else { this.logger({ category: "agent", - message: `Error during streaming: ${errorMessage}`, + message: `Error during streaming: ${getErrorMessage(error)}`, level: 0, }); rejectResult(error); diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 668359a49..0dfb3b0fc 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -220,4 +220,51 @@ test.describe("Stagehand agent abort signal", () => { expect(result2.messages).toBeDefined(); expect(result2.messages!.length).toBeGreaterThan(result1.messages!.length); }); + + test("stagehand.close() aborts running agent tasks", async () => { + test.setTimeout(30000); + + // Create a separate instance for this test to avoid interfering with afterEach + const v3Instance = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3Instance.init(); + + const agent = v3Instance.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); + + const page = v3Instance.context.pages()[0]; + await page.goto("https://example.com"); + + const startTime = Date.now(); + + // Start a long-running task and close() after a short delay + const executePromise = agent.execute({ + instruction: + "Describe everything on this page in extreme detail. Take your time and be very thorough. Do not use close tool until you have described every single element.", + maxSteps: 50, + }); + + // Close after a short delay - this should abort the running task + setTimeout(() => { + v3Instance.close().catch(() => {}); + }, 500); + + try { + await executePromise; + // Should not reach here + expect(true).toBe(false); + } catch (error) { + // Should throw AgentAbortError due to close() + expect(error).toBeInstanceOf(AgentAbortError); + expect((error as AgentAbortError).reason).toContain("closing"); + } + + const elapsed = Date.now() - startTime; + + // Should have stopped relatively quickly after close() + expect(elapsed).toBeLessThan(15000); + }); }); diff --git a/packages/core/lib/v3/tests/agent-streaming.spec.ts b/packages/core/lib/v3/tests/agent-streaming.spec.ts index 35020beb3..25f6dc2c3 100644 --- a/packages/core/lib/v3/tests/agent-streaming.spec.ts +++ b/packages/core/lib/v3/tests/agent-streaming.spec.ts @@ -151,7 +151,7 @@ test.describe("Stagehand agent streaming behavior", () => { stream: true, model: "anthropic/claude-haiku-4-5-20251001", }); - }).toThrow("Streaming is not supported with CUA"); + }).toThrow("streaming is not supported with CUA"); }); test("allows cua: true without stream", () => { diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index ec452ff37..c9d0c6ce3 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -72,6 +72,7 @@ import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; import { validateExperimentalFeatures } from "./agent/utils/validateExperimentalFeatures"; +import { combineAbortSignals } from "./agent/utils/combineAbortSignals"; const DEFAULT_MODEL_NAME = "openai/gpt-4.1-mini"; const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; @@ -172,6 +173,7 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; + private _agentAbortController: AbortController = new AbortController(); public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -1262,6 +1264,13 @@ export class V3 { if (this._isClosing && !opts?.force) return; this._isClosing = true; + // Abort any running agent tasks + try { + this._agentAbortController.abort("Stagehand instance is closing"); + } catch { + // ignore abort errors + } + try { // Unhook CDP transport close handler if context exists try { @@ -1305,6 +1314,8 @@ export class V3 { this.ctx = null; this._isClosing = false; this.resetBrowserbaseSessionMetadata(); + // Reset the abort controller for potential reuse + this._agentAbortController = new AbortController(); try { unbindInstanceLogger(this.instanceId); } catch { @@ -1531,11 +1542,22 @@ export class V3 { tools, ); - const resolvedOptions: AgentExecuteOptions | AgentStreamExecuteOptions = + const baseOptions: AgentExecuteOptions | AgentStreamExecuteOptions = typeof instructionOrOptions === "string" ? { instruction: instructionOrOptions } : instructionOrOptions; + // Combine user's signal with instance abort controller for graceful shutdown + const combinedSignal = combineAbortSignals( + this._agentAbortController.signal, + baseOptions.signal, + ); + + const resolvedOptions: AgentExecuteOptions | AgentStreamExecuteOptions = { + ...baseOptions, + signal: combinedSignal, + }; + if (resolvedOptions.page) { const normalizedPage = await this.normalizeToV3Page(resolvedOptions.page); this.ctx!.setActivePage(normalizedPage); @@ -1748,11 +1770,12 @@ export class V3 { // Streaming mode if (isStreaming) { - const { handler, cacheContext } = await this.prepareAgentExecution( - options, - instructionOrOptions, - agentConfigSignature, - ); + const { handler, resolvedOptions, cacheContext } = + await this.prepareAgentExecution( + options, + instructionOrOptions, + agentConfigSignature, + ); if (cacheContext) { const replayed = @@ -1763,7 +1786,7 @@ export class V3 { } const streamResult = await handler.stream( - instructionOrOptions as string | AgentStreamExecuteOptions, + resolvedOptions as AgentStreamExecuteOptions, ); if (cacheContext) { @@ -1811,7 +1834,7 @@ export class V3 { ); } else { result = await handler.execute( - instructionOrOptions as string | AgentExecuteOptions, + resolvedOptions as AgentExecuteOptions, ); } if (recording) { From b4ac3ffecd2184bc120a7ad2b38ac39d194b558e Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 14:39:20 -0800 Subject: [PATCH 03/15] remove example --- .../examples/v3/agent_messages_and_signal.ts | 178 ------------------ 1 file changed, 178 deletions(-) delete mode 100644 packages/core/examples/v3/agent_messages_and_signal.ts diff --git a/packages/core/examples/v3/agent_messages_and_signal.ts b/packages/core/examples/v3/agent_messages_and_signal.ts deleted file mode 100644 index 282f09e15..000000000 --- a/packages/core/examples/v3/agent_messages_and_signal.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Example: Agent Message Continuation and Abort Signal - * - * This example demonstrates two experimental features: - * 1. Message continuation - continuing a conversation across multiple execute() calls - * 2. Abort signal - cancelling an agent execution mid-run - * - * Note: These features require `experimental: true` in the Stagehand config. - */ - -import chalk from "chalk"; -import { V3 as Stagehand } from "../../lib/v3"; - -async function main() { - console.log( - `\n${chalk.bold("Stagehand Agent - Message Continuation & Abort Signal")}\n`, - ); - - const stagehand = new Stagehand({ - env: "LOCAL", - verbose: 1, - experimental: true, // Required for messages and signal - }); - - await stagehand.init(); - - try { - const page = stagehand.context.pages()[0]; - await page.goto("https://news.ycombinator.com"); - - const agent = stagehand.agent({ - model: "anthropic/claude-sonnet-4-20250514", - }); - - // ========================================= - // Part 1: Message Continuation - // ========================================= - console.log(chalk.cyan("\n--- Part 1: Message Continuation ---\n")); - - // First execution - ask about the page - console.log(chalk.yellow("First execution: Asking about the page...")); - const result1 = await agent.execute({ - instruction: - "What is this website? Give me a one sentence description. Use close tool with taskComplete: true after answering.", - maxSteps: 5, - }); - - console.log(chalk.green(`Result 1: ${result1.message}`)); - console.log( - chalk.gray(`Messages in conversation: ${result1.messages?.length}`), - ); - - // Second execution - continue the conversation - console.log( - chalk.yellow("\nSecond execution: Following up with context..."), - ); - const result2 = await agent.execute({ - instruction: - "Based on what you just told me, what kind of content would I typically find here? Use close tool with taskComplete: true after answering.", - maxSteps: 5, - messages: result1.messages, // Pass previous messages to continue conversation - }); - - console.log(chalk.green(`Result 2: ${result2.message}`)); - console.log( - chalk.gray(`Messages in conversation: ${result2.messages?.length}`), - ); - - // Third execution - even more context - console.log( - chalk.yellow("\nThird execution: Asking for recommendation..."), - ); - const result3 = await agent.execute({ - instruction: - "Would you recommend this site to a software developer? Yes or no, briefly explain. Use close tool with taskComplete: true.", - maxSteps: 5, - messages: result2.messages, // Continue from result2 - }); - - console.log(chalk.green(`Result 3: ${result3.message}`)); - console.log( - chalk.gray(`Total messages in conversation: ${result3.messages?.length}`), - ); - - // ========================================= - // Part 2: Abort Signal - // ========================================= - console.log(chalk.cyan("\n--- Part 2: Abort Signal ---\n")); - - // Example 2a: Manual abort with AbortController - console.log(chalk.yellow("Testing manual abort...")); - - const controller = new AbortController(); - - // Abort after 3 seconds - const abortTimeout = setTimeout(() => { - console.log(chalk.red("Aborting execution after 3 seconds...")); - controller.abort(); - }, 3000); - - const startTime1 = Date.now(); - try { - await agent.execute({ - instruction: - "Count to 100 slowly, saying each number out loud. Take your time between each number.", - maxSteps: 100, - signal: controller.signal, - }); - clearTimeout(abortTimeout); - console.log(chalk.green("Completed (unexpected)")); - } catch (err) { - clearTimeout(abortTimeout); - const elapsed = Date.now() - startTime1; - console.log(chalk.green(`Aborted successfully after ${elapsed}ms`)); - console.log(chalk.gray(`Error type: ${(err as Error).name}`)); - } - - // Example 2b: Using AbortSignal.timeout() - console.log(chalk.yellow("\nTesting AbortSignal.timeout()...")); - - const startTime2 = Date.now(); - try { - await agent.execute({ - instruction: - "List every country in the world alphabetically, with their capitals.", - maxSteps: 50, - signal: AbortSignal.timeout(2000), // 2 second timeout - }); - console.log(chalk.green("Completed (unexpected)")); - } catch { - const elapsed = Date.now() - startTime2; - console.log(chalk.green(`Timed out as expected after ${elapsed}ms`)); - } - - // ========================================= - // Part 3: Combining Both Features - // ========================================= - console.log(chalk.cyan("\n--- Part 3: Combined Usage ---\n")); - - console.log( - chalk.yellow("Using messages continuation with a timeout signal..."), - ); - - // Start a conversation - const initialResult = await agent.execute({ - instruction: - "What is the first story on this page? Use close tool with taskComplete: true.", - maxSteps: 5, - }); - - console.log(chalk.green(`Initial: ${initialResult.message}`)); - - // Continue with a timeout - try { - const followUp = await agent.execute({ - instruction: - "Now click on that story and tell me what it's about. Use close tool with taskComplete: true after.", - maxSteps: 10, - messages: initialResult.messages, - signal: AbortSignal.timeout(15000), // 15 second timeout - }); - - console.log(chalk.green(`Follow-up: ${followUp.message}`)); - } catch (error) { - console.log( - chalk.red(`Follow-up timed out: ${(error as Error).message}`), - ); - } - - console.log(chalk.green("\n--- Example Complete ---\n")); - } catch (error) { - console.error(chalk.red(`Error: ${error}`)); - } finally { - await stagehand.close(); - } -} - -main().catch(console.error); From 544f90b8196b86b5c4165a38485bb82caae39d58 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 14:48:47 -0800 Subject: [PATCH 04/15] format --- packages/core/lib/v3/agent/utils/combineAbortSignals.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts index e08e738e0..da167c3b8 100644 --- a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts +++ b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts @@ -9,9 +9,7 @@ export function combineAbortSignals( ...signals: (AbortSignal | undefined)[] ): AbortSignal | undefined { - const validSignals = signals.filter( - (s): s is AbortSignal => s !== undefined, - ); + const validSignals = signals.filter((s): s is AbortSignal => s !== undefined); if (validSignals.length === 0) { return undefined; From ebb3dcba1f67f0e28fe4bb7847855f9145428089 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 14:50:53 -0800 Subject: [PATCH 05/15] changeset --- .changeset/eleven-apples-yell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eleven-apples-yell.md diff --git a/.changeset/eleven-apples-yell.md b/.changeset/eleven-apples-yell.md new file mode 100644 index 000000000..57f428f36 --- /dev/null +++ b/.changeset/eleven-apples-yell.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +Add support for aborting / stopping an agent run & continuing an agent run using messages from prior runs From 46821882b59b0832598abed5f619d6559bf737de Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 15:11:05 -0800 Subject: [PATCH 06/15] address cubic review --- .../lib/v3/agent/utils/combineAbortSignals.ts | 23 +++++++++++++------ .../core/lib/v3/agent/utils/errorHandling.ts | 3 ++- .../utils/validateExperimentalFeatures.ts | 8 +++++-- .../lib/v3/tests/agent-abort-signal.spec.ts | 17 +++++++++----- .../agent-experimental-validation.spec.ts | 19 +++++++++------ .../tests/agent-message-continuation.spec.ts | 23 ++++++++++++++++--- .../public-api/public-error-types.test.ts | 1 + .../tests/public-api/public-types.test.ts | 1 + 8 files changed, 69 insertions(+), 26 deletions(-) diff --git a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts index da167c3b8..20cdd6fe2 100644 --- a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts +++ b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts @@ -27,19 +27,28 @@ export function combineAbortSignals( // Fallback for older environments const controller = new AbortController(); + // Track abort handlers so we can clean them up when one signal aborts + const handlers: Array<{ signal: AbortSignal; handler: () => void }> = []; + + const cleanup = () => { + for (const { signal, handler } of handlers) { + signal.removeEventListener("abort", handler); + } + }; + for (const signal of validSignals) { if (signal.aborted) { controller.abort(signal.reason); return controller.signal; } - signal.addEventListener( - "abort", - () => { - controller.abort(signal.reason); - }, - { once: true }, - ); + const handler = () => { + cleanup(); // Remove all listeners to prevent memory leak + controller.abort(signal.reason); + }; + + handlers.push({ signal, handler }); + signal.addEventListener("abort", handler, { once: true }); } return controller.signal; diff --git a/packages/core/lib/v3/agent/utils/errorHandling.ts b/packages/core/lib/v3/agent/utils/errorHandling.ts index 810d4fe45..5c9b89a53 100644 --- a/packages/core/lib/v3/agent/utils/errorHandling.ts +++ b/packages/core/lib/v3/agent/utils/errorHandling.ts @@ -6,7 +6,8 @@ import { AgentAbortError } from "../../types/public/sdkErrors"; export function extractAbortSignal( instructionOrOptions: string | { signal?: AbortSignal }, ): AbortSignal | undefined { - return typeof instructionOrOptions === "object" + return typeof instructionOrOptions === "object" && + instructionOrOptions !== null ? instructionOrOptions.signal : undefined; } diff --git a/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts b/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts index dd0f19f2e..b8ad53b52 100644 --- a/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts +++ b/packages/core/lib/v3/agent/utils/validateExperimentalFeatures.ts @@ -58,8 +58,12 @@ export function validateExperimentalFeatures( const features: string[] = []; - // Check agent config features - if (agentConfig?.integrations || agentConfig?.tools) { + // Check agent config features (check array length to avoid false positives for empty arrays) + const hasIntegrations = + agentConfig?.integrations && agentConfig.integrations.length > 0; + const hasTools = + agentConfig?.tools && Object.keys(agentConfig.tools).length > 0; + if (hasIntegrations || hasTools) { features.push("MCP integrations and custom tools"); } diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 0dfb3b0fc..51a1838d2 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -104,7 +104,6 @@ test.describe("Stagehand agent abort signal", () => { const signal = AbortSignal.timeout(2000); // 2 second timeout const startTime = Date.now(); - let caughtError: unknown = null; try { const streamResult = await agent.execute({ @@ -120,16 +119,22 @@ test.describe("Stagehand agent abort signal", () => { // Just consume - abort should interrupt this } await streamResult.result; + + // If we reach here without throwing, the test failed to verify abort behavior + throw new Error("Expected AgentAbortError to be thrown due to timeout"); } catch (error) { - caughtError = error; + if ( + error instanceof Error && + error.message === "Expected AgentAbortError to be thrown due to timeout" + ) { + throw error; // Re-throw our own error + } + // Should throw AgentAbortError + expect(error).toBeInstanceOf(AgentAbortError); } const elapsed = Date.now() - startTime; - // Should have thrown AgentAbortError (or completed very quickly) - if (caughtError) { - expect(caughtError).toBeInstanceOf(AgentAbortError); - } // Should have stopped within reasonable time (not running all 50 steps) expect(elapsed).toBeLessThan(30000); }); diff --git a/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts b/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts index 5b05c2a90..9fb861265 100644 --- a/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts +++ b/packages/core/lib/v3/tests/agent-experimental-validation.spec.ts @@ -380,18 +380,23 @@ test.describe("Stagehand agent experimental feature validation", () => { ).not.toThrow(); }); - test("allows basic agent without experimental features", () => { + test("allows basic agent without experimental features", async () => { const v3NonExperimental = new V3({ ...v3TestConfig, experimental: false, }); + await v3NonExperimental.init(); - // This should work - just creating a basic agent with no experimental features - expect(() => - v3NonExperimental.agent({ - model: "anthropic/claude-sonnet-4-20250514", - }), - ).not.toThrow(); + try { + // This should work - just creating a basic agent with no experimental features + expect(() => + v3NonExperimental.agent({ + model: "anthropic/claude-sonnet-4-20250514", + }), + ).not.toThrow(); + } finally { + await v3NonExperimental.close(); + } }); }); }); diff --git a/packages/core/lib/v3/tests/agent-message-continuation.spec.ts b/packages/core/lib/v3/tests/agent-message-continuation.spec.ts index 7ffffa160..ab19bde97 100644 --- a/packages/core/lib/v3/tests/agent-message-continuation.spec.ts +++ b/packages/core/lib/v3/tests/agent-message-continuation.spec.ts @@ -95,11 +95,28 @@ test.describe("Stagehand agent message continuation", () => { expect(result.messages).toBeDefined(); - // Should have assistant messages with tool calls - const hasAssistantMessage = result.messages!.some( + // Verify there are assistant messages + const assistantMessages = result.messages!.filter( (m: ModelMessage) => m.role === "assistant", ); - expect(hasAssistantMessage).toBe(true); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Verify at least one assistant message contains tool calls + const hasToolCalls = assistantMessages.some((m: ModelMessage) => { + if (Array.isArray(m.content)) { + return m.content.some( + (part) => typeof part === "object" && part.type === "tool-call", + ); + } + return false; + }); + expect(hasToolCalls).toBe(true); + + // Verify there are tool result messages + const hasToolResults = result.messages!.some( + (m: ModelMessage) => m.role === "tool", + ); + expect(hasToolResults).toBe(true); }); test("streaming mode also returns messages", async () => { diff --git a/packages/core/tests/public-api/public-error-types.test.ts b/packages/core/tests/public-api/public-error-types.test.ts index b13aa35b9..9ff24e9c1 100644 --- a/packages/core/tests/public-api/public-error-types.test.ts +++ b/packages/core/tests/public-api/public-error-types.test.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from "vitest"; import * as Stagehand from "../../dist"; export const publicErrorTypes = { + AgentAbortError: Stagehand.AgentAbortError, AgentScreenshotProviderError: Stagehand.AgentScreenshotProviderError, BrowserbaseSessionNotFoundError: Stagehand.BrowserbaseSessionNotFoundError, CaptchaTimeoutError: Stagehand.CaptchaTimeoutError, diff --git a/packages/core/tests/public-api/public-types.test.ts b/packages/core/tests/public-api/public-types.test.ts index 695dec673..0874d58e5 100644 --- a/packages/core/tests/public-api/public-types.test.ts +++ b/packages/core/tests/public-api/public-types.test.ts @@ -52,6 +52,7 @@ type ExpectedExportedTypes = { AgentStreamCallbacks: Stagehand.AgentStreamCallbacks; AgentExecuteOptionsBase: Stagehand.AgentExecuteOptionsBase; AgentStreamExecuteOptions: Stagehand.AgentStreamExecuteOptions; + ModelMessage: Stagehand.ModelMessage; // Types from logs.ts LogLevel: Stagehand.LogLevel; LogLine: Stagehand.LogLine; From a5979c317eeb2f348daa55c764eeae12bf5c39cc Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 15:21:12 -0800 Subject: [PATCH 07/15] update test, and abort signal handling --- .../lib/v3/agent/utils/combineAbortSignals.ts | 1 + .../lib/v3/tests/agent-abort-signal.spec.ts | 38 ------------------- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts index 20cdd6fe2..f21893c75 100644 --- a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts +++ b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts @@ -38,6 +38,7 @@ export function combineAbortSignals( for (const signal of validSignals) { if (signal.aborted) { + cleanup(); // Remove handlers added to previous signals in this loop controller.abort(signal.reason); return controller.signal; } diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 51a1838d2..32f7384ca 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -187,44 +187,6 @@ test.describe("Stagehand agent abort signal", () => { } }); - test("can use both messages and signal together", async () => { - test.setTimeout(90000); - - const agent = v3.agent({ - model: "anthropic/claude-haiku-4-5-20251001", - }); - - const page = v3.context.pages()[0]; - await page.goto("https://example.com"); - - // First execution - const result1 = await agent.execute({ - instruction: - "What is the title of this page? Use close tool with taskComplete: true.", - maxSteps: 5, - }); - - expect(result1.messages).toBeDefined(); - - // Second execution with both messages and signal - const controller = new AbortController(); - - // Give enough time for a normal quick task - setTimeout(() => controller.abort(), 10000); - - const result2 = await agent.execute({ - instruction: - "Say 'confirmed' and use close tool with taskComplete: true.", - maxSteps: 3, - messages: result1.messages, - signal: controller.signal, - }); - - // Should complete before timeout - expect(result2.success).toBe(true); - expect(result2.messages).toBeDefined(); - expect(result2.messages!.length).toBeGreaterThan(result1.messages!.length); - }); test("stagehand.close() aborts running agent tasks", async () => { test.setTimeout(30000); From f02957cf4cd0258df92afc0f24b8a579f75f7b88 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 15:24:03 -0800 Subject: [PATCH 08/15] format --- packages/core/lib/v3/tests/agent-abort-signal.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 32f7384ca..c37798cfc 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -187,7 +187,6 @@ test.describe("Stagehand agent abort signal", () => { } }); - test("stagehand.close() aborts running agent tasks", async () => { test.setTimeout(30000); From 955adb7c1ca6ff46d0a806c6210223d924ad4913 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 15:34:28 -0800 Subject: [PATCH 09/15] update timeouts --- .../lib/v3/tests/agent-abort-signal.spec.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index c37798cfc..776c3f4ce 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -19,7 +19,7 @@ test.describe("Stagehand agent abort signal", () => { }); test("abort signal stops execution and throws AgentAbortError", async () => { - test.setTimeout(30000); + test.setTimeout(60000); const agent = v3.agent({ model: "anthropic/claude-haiku-4-5-20251001", @@ -31,7 +31,7 @@ test.describe("Stagehand agent abort signal", () => { const controller = new AbortController(); // Abort after a short delay - setTimeout(() => controller.abort(), 500); + setTimeout(() => controller.abort(), 1000); const startTime = Date.now(); @@ -54,11 +54,11 @@ test.describe("Stagehand agent abort signal", () => { // Should have stopped relatively quickly (within a few seconds of abort) // Not waiting for all 50 steps - expect(elapsed).toBeLessThan(15000); + expect(elapsed).toBeLessThan(30000); }); test("AbortSignal.timeout throws AgentAbortError", async () => { - test.setTimeout(30000); + test.setTimeout(60000); const agent = v3.agent({ model: "anthropic/claude-haiku-4-5-20251001", @@ -74,7 +74,7 @@ test.describe("Stagehand agent abort signal", () => { instruction: "Describe everything on this page in extreme detail. Take your time. Do not use close tool until done.", maxSteps: 50, - signal: AbortSignal.timeout(1000), // 1 second timeout + signal: AbortSignal.timeout(2000), // 2 second timeout }); // Should not reach here expect(true).toBe(false); @@ -86,11 +86,11 @@ test.describe("Stagehand agent abort signal", () => { const elapsed = Date.now() - startTime; // Should have stopped around the timeout (with some margin) - expect(elapsed).toBeLessThan(10000); + expect(elapsed).toBeLessThan(20000); }); test("streaming mode throws AgentAbortError on abort", async () => { - test.setTimeout(90000); + test.setTimeout(180000); const agent = v3.agent({ stream: true, @@ -101,7 +101,7 @@ test.describe("Stagehand agent abort signal", () => { await page.goto("https://example.com"); // Use AbortSignal.timeout for more reliable abort - const signal = AbortSignal.timeout(2000); // 2 second timeout + const signal = AbortSignal.timeout(4000); // 4 second timeout const startTime = Date.now(); @@ -136,11 +136,11 @@ test.describe("Stagehand agent abort signal", () => { const elapsed = Date.now() - startTime; // Should have stopped within reasonable time (not running all 50 steps) - expect(elapsed).toBeLessThan(30000); + expect(elapsed).toBeLessThan(60000); }); test("execution completes normally without abort signal", async () => { - test.setTimeout(60000); + test.setTimeout(120000); const agent = v3.agent({ model: "anthropic/claude-haiku-4-5-20251001", @@ -160,7 +160,7 @@ test.describe("Stagehand agent abort signal", () => { }); test("already aborted signal throws AgentAbortError immediately", async () => { - test.setTimeout(10000); + test.setTimeout(20000); const agent = v3.agent({ model: "anthropic/claude-haiku-4-5-20251001", @@ -188,7 +188,7 @@ test.describe("Stagehand agent abort signal", () => { }); test("stagehand.close() aborts running agent tasks", async () => { - test.setTimeout(30000); + test.setTimeout(60000); // Create a separate instance for this test to avoid interfering with afterEach const v3Instance = new V3({ @@ -216,7 +216,7 @@ test.describe("Stagehand agent abort signal", () => { // Close after a short delay - this should abort the running task setTimeout(() => { v3Instance.close().catch(() => {}); - }, 500); + }, 1000); try { await executePromise; @@ -231,6 +231,6 @@ test.describe("Stagehand agent abort signal", () => { const elapsed = Date.now() - startTime; // Should have stopped relatively quickly after close() - expect(elapsed).toBeLessThan(15000); + expect(elapsed).toBeLessThan(30000); }); }); From 2db278fd30d5c09c7da53849d46054f84da2b04e Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 16:09:14 -0800 Subject: [PATCH 10/15] adjust abort handling in stream, and update test --- .../core/lib/v3/handlers/v3AgentHandler.ts | 10 + .../lib/v3/tests/agent-abort-signal.spec.ts | 277 +++++++++--------- 2 files changed, 152 insertions(+), 135 deletions(-) diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 402266ea6..d92b0e0bb 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -385,6 +385,16 @@ export class V3AgentHandler { handleError(error); } }, + onAbort: (event) => { + if (callbacks?.onAbort) { + callbacks.onAbort(event); + } + // Reject the result promise with AgentAbortError when stream is aborted + const reason = abortSignal?.reason + ? String(abortSignal.reason) + : "Stream was aborted"; + rejectResult(new AgentAbortError(reason)); + }, abortSignal, }); diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 776c3f4ce..d32e7308d 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -18,7 +18,7 @@ test.describe("Stagehand agent abort signal", () => { await v3?.close?.().catch(() => {}); }); - test("abort signal stops execution and throws AgentAbortError", async () => { + test("non-streaming: abort signal stops execution and throws AgentAbortError", async () => { test.setTimeout(60000); const agent = v3.agent({ @@ -30,137 +30,170 @@ test.describe("Stagehand agent abort signal", () => { const controller = new AbortController(); - // Abort after a short delay - setTimeout(() => controller.abort(), 1000); + // Abort after 500ms - should be enough for the LLM to start but not finish + setTimeout(() => controller.abort(), 500); - const startTime = Date.now(); - - try { - await agent.execute({ + await expect( + agent.execute({ instruction: - "Describe everything on this page in extreme detail. Take your time and be very thorough. Do not use close tool until you have described every single element.", + "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", maxSteps: 50, signal: controller.signal, - }); - // Should not reach here - expect(true).toBe(false); - } catch (error) { - // Should throw AgentAbortError - expect(error).toBeInstanceOf(AgentAbortError); - expect((error as AgentAbortError).reason).toContain("aborted"); - } - - const elapsed = Date.now() - startTime; - - // Should have stopped relatively quickly (within a few seconds of abort) - // Not waiting for all 50 steps - expect(elapsed).toBeLessThan(30000); + }), + ).rejects.toThrow(AgentAbortError); }); - test("AbortSignal.timeout throws AgentAbortError", async () => { + test("streaming: abort signal stops stream and rejects result with AgentAbortError", async () => { test.setTimeout(60000); const agent = v3.agent({ + stream: true, model: "anthropic/claude-haiku-4-5-20251001", }); const page = v3.context.pages()[0]; await page.goto("https://example.com"); - const startTime = Date.now(); + const controller = new AbortController(); - try { - await agent.execute({ - instruction: - "Describe everything on this page in extreme detail. Take your time. Do not use close tool until done.", - maxSteps: 50, - signal: AbortSignal.timeout(2000), // 2 second timeout - }); - // Should not reach here - expect(true).toBe(false); - } catch (error) { - // Should throw AgentAbortError - expect(error).toBeInstanceOf(AgentAbortError); - } + // Abort after 500ms + setTimeout(() => controller.abort(), 500); - const elapsed = Date.now() - startTime; + const streamResult = await agent.execute({ + instruction: + "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", + maxSteps: 50, + signal: controller.signal, + }); - // Should have stopped around the timeout (with some margin) - expect(elapsed).toBeLessThan(20000); + // Handle both stream consumption and result promise together + // The result promise will reject with AgentAbortError when aborted + const consumeStream = async () => { + for await (const _ of streamResult.textStream) { + // Just consume chunks until stream ends + } + }; + + // Both should complete - stream ends and result rejects + const [, resultError] = await Promise.allSettled([ + consumeStream(), + streamResult.result, + ]); + + // The result should have rejected with AgentAbortError + expect(resultError.status).toBe("rejected"); + expect((resultError as PromiseRejectedResult).reason).toBeInstanceOf( + AgentAbortError, + ); }); - test("streaming mode throws AgentAbortError on abort", async () => { - test.setTimeout(180000); + test("non-streaming: already aborted signal throws AgentAbortError immediately", async () => { + test.setTimeout(10000); const agent = v3.agent({ - stream: true, model: "anthropic/claude-haiku-4-5-20251001", }); const page = v3.context.pages()[0]; await page.goto("https://example.com"); - // Use AbortSignal.timeout for more reliable abort - const signal = AbortSignal.timeout(4000); // 4 second timeout + // Create an already aborted controller + const controller = new AbortController(); + controller.abort(); - const startTime = Date.now(); + await expect( + agent.execute({ + instruction: "This should not run.", + maxSteps: 3, + signal: controller.signal, + }), + ).rejects.toThrow(AgentAbortError); + }); - try { - const streamResult = await agent.execute({ - instruction: - "Describe everything on this page in extreme detail. Take your time and list every single element.", - maxSteps: 50, - signal, - }); + test("non-streaming: stagehand.close() aborts running agent tasks", async () => { + test.setTimeout(60000); - // Try to consume the stream - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of streamResult.textStream) { - // Just consume - abort should interrupt this - } - await streamResult.result; - - // If we reach here without throwing, the test failed to verify abort behavior - throw new Error("Expected AgentAbortError to be thrown due to timeout"); - } catch (error) { - if ( - error instanceof Error && - error.message === "Expected AgentAbortError to be thrown due to timeout" - ) { - throw error; // Re-throw our own error - } - // Should throw AgentAbortError - expect(error).toBeInstanceOf(AgentAbortError); - } + // Create a separate instance for this test + const v3Instance = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3Instance.init(); - const elapsed = Date.now() - startTime; + const agent = v3Instance.agent({ + model: "anthropic/claude-haiku-4-5-20251001", + }); - // Should have stopped within reasonable time (not running all 50 steps) - expect(elapsed).toBeLessThan(60000); + const page = v3Instance.context.pages()[0]; + await page.goto("https://example.com"); + + // Start a long-running task + const executePromise = agent.execute({ + instruction: + "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", + maxSteps: 50, + }); + + // Close after 500ms - this should abort the running task + setTimeout(() => { + v3Instance.close().catch(() => {}); + }, 500); + + await expect(executePromise).rejects.toThrow(AgentAbortError); }); - test("execution completes normally without abort signal", async () => { - test.setTimeout(120000); + test("streaming: stagehand.close() stops stream and rejects result with AgentAbortError", async () => { + test.setTimeout(60000); - const agent = v3.agent({ + // Create a separate instance for this test + const v3Instance = new V3({ + ...v3TestConfig, + experimental: true, + }); + await v3Instance.init(); + + const agent = v3Instance.agent({ + stream: true, model: "anthropic/claude-haiku-4-5-20251001", }); - const page = v3.context.pages()[0]; + const page = v3Instance.context.pages()[0]; await page.goto("https://example.com"); - // No signal provided - should complete normally - const result = await agent.execute({ - instruction: "Use close tool with taskComplete: true immediately.", - maxSteps: 3, + // Start a long-running streaming task + const streamResult = await agent.execute({ + instruction: + "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", + maxSteps: 50, }); - expect(result.success).toBe(true); - expect(result.completed).toBe(true); + // Close after 500ms - this should abort the running task + setTimeout(() => { + v3Instance.close().catch(() => {}); + }, 500); + + // Handle both stream consumption and result promise together + const consumeStream = async () => { + for await (const _ of streamResult.textStream) { + // Just consume chunks until stream ends + } + }; + + // Both should complete - stream ends and result rejects + const [, resultError] = await Promise.allSettled([ + consumeStream(), + streamResult.result, + ]); + + // The result should have rejected with AgentAbortError + expect(resultError.status).toBe("rejected"); + expect((resultError as PromiseRejectedResult).reason).toBeInstanceOf( + AgentAbortError, + ); }); - test("already aborted signal throws AgentAbortError immediately", async () => { - test.setTimeout(20000); + test("non-streaming: execution completes normally without abort signal", async () => { + test.setTimeout(60000); const agent = v3.agent({ model: "anthropic/claude-haiku-4-5-20251001", @@ -169,68 +202,42 @@ test.describe("Stagehand agent abort signal", () => { const page = v3.context.pages()[0]; await page.goto("https://example.com"); - // Create an already aborted controller - const controller = new AbortController(); - controller.abort(); + // No signal provided - should complete normally + const result = await agent.execute({ + instruction: "Use the close tool with taskComplete: true immediately.", + maxSteps: 3, + }); - try { - await agent.execute({ - instruction: "This should not run.", - maxSteps: 3, - signal: controller.signal, - }); - // Should not reach here - expect(true).toBe(false); - } catch (error) { - // Should throw AgentAbortError immediately - expect(error).toBeInstanceOf(AgentAbortError); - } + expect(result.success).toBe(true); + expect(result.completed).toBe(true); }); - test("stagehand.close() aborts running agent tasks", async () => { + test("streaming: execution completes normally without abort signal", async () => { test.setTimeout(60000); - // Create a separate instance for this test to avoid interfering with afterEach - const v3Instance = new V3({ - ...v3TestConfig, - experimental: true, - }); - await v3Instance.init(); - - const agent = v3Instance.agent({ + const agent = v3.agent({ + stream: true, model: "anthropic/claude-haiku-4-5-20251001", }); - const page = v3Instance.context.pages()[0]; + const page = v3.context.pages()[0]; await page.goto("https://example.com"); - const startTime = Date.now(); - - // Start a long-running task and close() after a short delay - const executePromise = agent.execute({ - instruction: - "Describe everything on this page in extreme detail. Take your time and be very thorough. Do not use close tool until you have described every single element.", - maxSteps: 50, + // No signal provided - should complete normally + const streamResult = await agent.execute({ + instruction: "Use the close tool with taskComplete: true immediately.", + maxSteps: 3, }); - // Close after a short delay - this should abort the running task - setTimeout(() => { - v3Instance.close().catch(() => {}); - }, 1000); - - try { - await executePromise; - // Should not reach here - expect(true).toBe(false); - } catch (error) { - // Should throw AgentAbortError due to close() - expect(error).toBeInstanceOf(AgentAbortError); - expect((error as AgentAbortError).reason).toContain("closing"); + // Consume the stream first + for await (const _ of streamResult.textStream) { + // Just consume } - const elapsed = Date.now() - startTime; + // Now get the final result + const result = await streamResult.result; - // Should have stopped relatively quickly after close() - expect(elapsed).toBeLessThan(30000); + expect(result.success).toBe(true); + expect(result.completed).toBe(true); }); }); From 58223c42d2c221fe082b912d7ff9d4c98785f947 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 16:12:28 -0800 Subject: [PATCH 11/15] lint --- packages/core/lib/v3/tests/agent-abort-signal.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index d32e7308d..5f0711a9a 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -69,6 +69,7 @@ test.describe("Stagehand agent abort signal", () => { // Handle both stream consumption and result promise together // The result promise will reject with AgentAbortError when aborted const consumeStream = async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of streamResult.textStream) { // Just consume chunks until stream ends } @@ -174,6 +175,7 @@ test.describe("Stagehand agent abort signal", () => { // Handle both stream consumption and result promise together const consumeStream = async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of streamResult.textStream) { // Just consume chunks until stream ends } @@ -230,6 +232,7 @@ test.describe("Stagehand agent abort signal", () => { }); // Consume the stream first + // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of streamResult.textStream) { // Just consume } From 2196ff50e74e4ddeb93af9eed7163c917e5b349b Mon Sep 17 00:00:00 2001 From: tkattkat Date: Wed, 3 Dec 2025 16:23:09 -0800 Subject: [PATCH 12/15] update streaming test --- packages/core/lib/v3/tests/agent-streaming.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/lib/v3/tests/agent-streaming.spec.ts b/packages/core/lib/v3/tests/agent-streaming.spec.ts index 25f6dc2c3..bc8951e63 100644 --- a/packages/core/lib/v3/tests/agent-streaming.spec.ts +++ b/packages/core/lib/v3/tests/agent-streaming.spec.ts @@ -48,6 +48,13 @@ test.describe("Stagehand agent streaming behavior", () => { // result should be a promise expect(streamResult.result).toBeInstanceOf(Promise); + + // Consume stream to avoid unhandled rejection on close + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of streamResult.textStream) { + // Just consume + } + await streamResult.result; }); test("textStream yields chunks incrementally", async () => { From 646e67968c8438d57f5ab9b2dd159c0667001921 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Fri, 5 Dec 2025 14:51:40 -0800 Subject: [PATCH 13/15] simplify abort logic --- .../lib/v3/agent/utils/combineAbortSignals.ts | 56 --------- .../core/lib/v3/agent/utils/errorHandling.ts | 48 ------- .../core/lib/v3/handlers/v3AgentHandler.ts | 117 +++++++----------- .../lib/v3/tests/agent-abort-signal.spec.ts | 83 ------------- packages/core/lib/v3/v3.ts | 24 +--- 5 files changed, 47 insertions(+), 281 deletions(-) delete mode 100644 packages/core/lib/v3/agent/utils/combineAbortSignals.ts delete mode 100644 packages/core/lib/v3/agent/utils/errorHandling.ts diff --git a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts b/packages/core/lib/v3/agent/utils/combineAbortSignals.ts deleted file mode 100644 index f21893c75..000000000 --- a/packages/core/lib/v3/agent/utils/combineAbortSignals.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Combines multiple AbortSignals into a single signal that aborts when any of them abort. - * - * Uses AbortSignal.any() if available (Node 20+), otherwise falls back to a manual implementation. - * - * @param signals - Array of AbortSignals to combine (undefined signals are filtered out) - * @returns A combined AbortSignal, or undefined if no valid signals provided - */ -export function combineAbortSignals( - ...signals: (AbortSignal | undefined)[] -): AbortSignal | undefined { - const validSignals = signals.filter((s): s is AbortSignal => s !== undefined); - - if (validSignals.length === 0) { - return undefined; - } - - if (validSignals.length === 1) { - return validSignals[0]; - } - - // Use AbortSignal.any() if available (Node 20+) - if (typeof AbortSignal.any === "function") { - return AbortSignal.any(validSignals); - } - - // Fallback for older environments - const controller = new AbortController(); - - // Track abort handlers so we can clean them up when one signal aborts - const handlers: Array<{ signal: AbortSignal; handler: () => void }> = []; - - const cleanup = () => { - for (const { signal, handler } of handlers) { - signal.removeEventListener("abort", handler); - } - }; - - for (const signal of validSignals) { - if (signal.aborted) { - cleanup(); // Remove handlers added to previous signals in this loop - controller.abort(signal.reason); - return controller.signal; - } - - const handler = () => { - cleanup(); // Remove all listeners to prevent memory leak - controller.abort(signal.reason); - }; - - handlers.push({ signal, handler }); - signal.addEventListener("abort", handler, { once: true }); - } - - return controller.signal; -} diff --git a/packages/core/lib/v3/agent/utils/errorHandling.ts b/packages/core/lib/v3/agent/utils/errorHandling.ts deleted file mode 100644 index 5c9b89a53..000000000 --- a/packages/core/lib/v3/agent/utils/errorHandling.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AgentAbortError } from "../../types/public/sdkErrors"; - -/** - * Extracts the abort signal from instruction or options. - */ -export function extractAbortSignal( - instructionOrOptions: string | { signal?: AbortSignal }, -): AbortSignal | undefined { - return typeof instructionOrOptions === "object" && - instructionOrOptions !== null - ? instructionOrOptions.signal - : undefined; -} - -/** - * Consistently extracts an error message from an unknown error. - */ -export function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -/** - * Checks if an error is abort-related (either an abort error type or the signal was aborted). - * Returns the appropriate reason string if it's an abort, or null if not. - * - * @param error - The caught error - * @param abortSignal - The abort signal to check - * @returns The abort reason string if abort-related, null otherwise - */ -export function getAbortErrorReason( - error: unknown, - abortSignal?: AbortSignal, -): string | null { - if (!AgentAbortError.isAbortError(error) && !abortSignal?.aborted) { - return null; - } - - // Prefer the signal's reason if available - if (abortSignal?.reason) { - return String(abortSignal.reason); - } - - // Fall back to the error message - return getErrorMessage(error); -} diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index d92b0e0bb..ba5c4e28c 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -30,11 +30,10 @@ import { StreamingCallbacksInNonStreamingModeError, AgentAbortError, } from "../types/public/sdkErrors"; -import { - extractAbortSignal, - getErrorMessage, - getAbortErrorReason, -} from "../agent/utils/errorHandling"; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} export class V3AgentHandler { private v3: V3; @@ -182,13 +181,10 @@ export class V3AgentHandler { instructionOrOptions: string | AgentExecuteOptions, ): Promise { const startTime = Date.now(); - - // Extract abort signal early so we can check it in error handling. - // This is needed because when stagehand.close() is called, the abort signal - // is triggered but the resulting error might be something else (e.g., null context). - // By having the signal reference, we can detect abort-related errors regardless - // of their actual error type. - const abortSignal = extractAbortSignal(instructionOrOptions); + const signal = + typeof instructionOrOptions === "object" + ? instructionOrOptions.signal + : undefined; const state: AgentState = { collectedReasoning: [], @@ -260,6 +256,17 @@ export class V3AgentHandler { throw error; } + // Re-throw abort errors wrapped in AgentAbortError for consistent error typing. + // Check both error type and signal state since abort may cause various error types. + if (AgentAbortError.isAbortError(error) || signal?.aborted) { + const reason = signal?.reason + ? String(signal.reason) + : getErrorMessage(error); + throw error instanceof AgentAbortError + ? error + : new AgentAbortError(reason); + } + const errorMessage = getErrorMessage(error); this.logger({ category: "agent", @@ -267,15 +274,6 @@ export class V3AgentHandler { level: 0, }); - // Check if this error was caused by an abort signal (either directly or indirectly). - // When stagehand.close() is called, it aborts the signal which may cause various - // errors (e.g., "Cannot read properties of null" when context is nullified). - // We detect these by checking if the signal is aborted and wrap them in AgentAbortError. - const abortReason = getAbortErrorReason(error, abortSignal); - if (abortReason) { - throw new AgentAbortError(abortReason); - } - // For non-abort errors, return a failure result instead of throwing return { success: false, @@ -290,32 +288,15 @@ export class V3AgentHandler { public async stream( instructionOrOptions: string | AgentStreamExecuteOptions, ): Promise { - // Extract abort signal early so we can check it in error handling. - // See execute() for detailed explanation of why this is needed. - const abortSignal = extractAbortSignal(instructionOrOptions); - // Wrap prepareAgent in try-catch to handle abort signals during preparation. - // When stagehand.close() is called, the context may be nullified before - // prepareAgent completes, causing errors like "Cannot read properties of null". - // We catch these and check if the abort signal was the root cause. - let preparedAgent: Awaited>; - try { - preparedAgent = await this.prepareAgent(instructionOrOptions); - } catch (error) { - const abortReason = getAbortErrorReason(error, abortSignal); - if (abortReason) { - throw new AgentAbortError(abortReason); - } - throw error; - } - const { + options, maxSteps, systemPrompt, allTools, messages, wrappedModel, initialPageUrl, - } = preparedAgent; + } = await this.prepareAgent(instructionOrOptions); const callbacks = (instructionOrOptions as AgentStreamExecuteOptions) .callbacks as AgentStreamCallbacks | undefined; @@ -336,22 +317,6 @@ export class V3AgentHandler { rejectResult = reject; }); - // Handle errors during streaming, converting abort-related errors to AgentAbortError. - // This ensures consistent error handling whether abort happens during streaming or preparation. - const handleError = (error: unknown) => { - const abortReason = getAbortErrorReason(error, abortSignal); - if (abortReason) { - rejectResult(new AgentAbortError(abortReason)); - } else { - this.logger({ - category: "agent", - message: `Error during streaming: ${getErrorMessage(error)}`, - level: 0, - }); - rejectResult(error); - } - }; - const streamResult = this.llmClient.streamText({ model: wrappedModel, system: systemPrompt, @@ -366,36 +331,46 @@ export class V3AgentHandler { if (callbacks?.onError) { callbacks.onError(event); } - handleError(event.error); + // Convert abort errors to AgentAbortError for consistent error typing + if (AgentAbortError.isAbortError(event.error)) { + rejectResult( + event.error instanceof AgentAbortError + ? event.error + : new AgentAbortError(getErrorMessage(event.error)), + ); + } else { + this.logger({ + category: "agent", + message: `Error during streaming: ${getErrorMessage(event.error)}`, + level: 0, + }); + rejectResult(event.error); + } }, onChunk: callbacks?.onChunk, onFinish: (event) => { if (callbacks?.onFinish) { callbacks.onFinish(event); } - try { - const result = this.consolidateMetricsAndResult( - startTime, - state, - messages, - event, - ); - resolveResult(result); - } catch (error) { - handleError(error); - } + const result = this.consolidateMetricsAndResult( + startTime, + state, + messages, + event, + ); + resolveResult(result); }, onAbort: (event) => { if (callbacks?.onAbort) { callbacks.onAbort(event); } // Reject the result promise with AgentAbortError when stream is aborted - const reason = abortSignal?.reason - ? String(abortSignal.reason) + const reason = options.signal?.reason + ? String(options.signal.reason) : "Stream was aborted"; rejectResult(new AgentAbortError(reason)); }, - abortSignal, + abortSignal: options.signal, }); const agentStreamResult = streamResult as AgentStreamResult; diff --git a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts index 5f0711a9a..f75efd848 100644 --- a/packages/core/lib/v3/tests/agent-abort-signal.spec.ts +++ b/packages/core/lib/v3/tests/agent-abort-signal.spec.ts @@ -111,89 +111,6 @@ test.describe("Stagehand agent abort signal", () => { ).rejects.toThrow(AgentAbortError); }); - test("non-streaming: stagehand.close() aborts running agent tasks", async () => { - test.setTimeout(60000); - - // Create a separate instance for this test - const v3Instance = new V3({ - ...v3TestConfig, - experimental: true, - }); - await v3Instance.init(); - - const agent = v3Instance.agent({ - model: "anthropic/claude-haiku-4-5-20251001", - }); - - const page = v3Instance.context.pages()[0]; - await page.goto("https://example.com"); - - // Start a long-running task - const executePromise = agent.execute({ - instruction: - "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", - maxSteps: 50, - }); - - // Close after 500ms - this should abort the running task - setTimeout(() => { - v3Instance.close().catch(() => {}); - }, 500); - - await expect(executePromise).rejects.toThrow(AgentAbortError); - }); - - test("streaming: stagehand.close() stops stream and rejects result with AgentAbortError", async () => { - test.setTimeout(60000); - - // Create a separate instance for this test - const v3Instance = new V3({ - ...v3TestConfig, - experimental: true, - }); - await v3Instance.init(); - - const agent = v3Instance.agent({ - stream: true, - model: "anthropic/claude-haiku-4-5-20251001", - }); - - const page = v3Instance.context.pages()[0]; - await page.goto("https://example.com"); - - // Start a long-running streaming task - const streamResult = await agent.execute({ - instruction: - "Describe every visual element on this page in extreme detail. Do not use the close tool until you have described at least 100 different elements.", - maxSteps: 50, - }); - - // Close after 500ms - this should abort the running task - setTimeout(() => { - v3Instance.close().catch(() => {}); - }, 500); - - // Handle both stream consumption and result promise together - const consumeStream = async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of streamResult.textStream) { - // Just consume chunks until stream ends - } - }; - - // Both should complete - stream ends and result rejects - const [, resultError] = await Promise.allSettled([ - consumeStream(), - streamResult.result, - ]); - - // The result should have rejected with AgentAbortError - expect(resultError.status).toBe("rejected"); - expect((resultError as PromiseRejectedResult).reason).toBeInstanceOf( - AgentAbortError, - ); - }); - test("non-streaming: execution completes normally without abort signal", async () => { test.setTimeout(60000); diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index c9d0c6ce3..fb86e1f7e 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -72,7 +72,6 @@ import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; import { validateExperimentalFeatures } from "./agent/utils/validateExperimentalFeatures"; -import { combineAbortSignals } from "./agent/utils/combineAbortSignals"; const DEFAULT_MODEL_NAME = "openai/gpt-4.1-mini"; const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; @@ -173,7 +172,6 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; - private _agentAbortController: AbortController = new AbortController(); public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -1264,13 +1262,6 @@ export class V3 { if (this._isClosing && !opts?.force) return; this._isClosing = true; - // Abort any running agent tasks - try { - this._agentAbortController.abort("Stagehand instance is closing"); - } catch { - // ignore abort errors - } - try { // Unhook CDP transport close handler if context exists try { @@ -1314,8 +1305,6 @@ export class V3 { this.ctx = null; this._isClosing = false; this.resetBrowserbaseSessionMetadata(); - // Reset the abort controller for potential reuse - this._agentAbortController = new AbortController(); try { unbindInstanceLogger(this.instanceId); } catch { @@ -1542,22 +1531,11 @@ export class V3 { tools, ); - const baseOptions: AgentExecuteOptions | AgentStreamExecuteOptions = + const resolvedOptions: AgentExecuteOptions | AgentStreamExecuteOptions = typeof instructionOrOptions === "string" ? { instruction: instructionOrOptions } : instructionOrOptions; - // Combine user's signal with instance abort controller for graceful shutdown - const combinedSignal = combineAbortSignals( - this._agentAbortController.signal, - baseOptions.signal, - ); - - const resolvedOptions: AgentExecuteOptions | AgentStreamExecuteOptions = { - ...baseOptions, - signal: combinedSignal, - }; - if (resolvedOptions.page) { const normalizedPage = await this.normalizeToV3Page(resolvedOptions.page); this.ctx!.setActivePage(normalizedPage); From d3298713f121ba32e869b61525ee2bc9ce849b68 Mon Sep 17 00:00:00 2001 From: tkattkat Date: Fri, 5 Dec 2025 15:11:01 -0800 Subject: [PATCH 14/15] remove old comment --- packages/core/lib/v3/handlers/v3AgentHandler.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index ba5c4e28c..6df4bb569 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -196,10 +196,6 @@ export class V3AgentHandler { let messages: ModelMessage[] = []; - // Wrap everything in try-catch to handle abort signals properly. - // When close() aborts the signal, errors can occur at any point (during - // prepareAgent, generateText, etc.). We catch all errors and check if - // the abort signal was the root cause. try { const { options, From 9d2a4a6d121ad74e088149599e44816f22dd7cfd Mon Sep 17 00:00:00 2001 From: tkattkat Date: Fri, 5 Dec 2025 15:14:16 -0800 Subject: [PATCH 15/15] remove error handling that is no longer necessary --- .../core/lib/v3/handlers/v3AgentHandler.ts | 24 +++++++------------ .../core/lib/v3/types/public/sdkErrors.ts | 17 ------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 6df4bb569..081004a6a 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -252,15 +252,10 @@ export class V3AgentHandler { throw error; } - // Re-throw abort errors wrapped in AgentAbortError for consistent error typing. - // Check both error type and signal state since abort may cause various error types. - if (AgentAbortError.isAbortError(error) || signal?.aborted) { - const reason = signal?.reason - ? String(signal.reason) - : getErrorMessage(error); - throw error instanceof AgentAbortError - ? error - : new AgentAbortError(reason); + // Re-throw abort errors wrapped in AgentAbortError for consistent error typing + if (signal?.aborted) { + const reason = signal.reason ? String(signal.reason) : "aborted"; + throw new AgentAbortError(reason); } const errorMessage = getErrorMessage(error); @@ -328,12 +323,11 @@ export class V3AgentHandler { callbacks.onError(event); } // Convert abort errors to AgentAbortError for consistent error typing - if (AgentAbortError.isAbortError(event.error)) { - rejectResult( - event.error instanceof AgentAbortError - ? event.error - : new AgentAbortError(getErrorMessage(event.error)), - ); + if (options.signal?.aborted) { + const reason = options.signal.reason + ? String(options.signal.reason) + : "aborted"; + rejectResult(new AgentAbortError(reason)); } else { this.logger({ category: "agent", diff --git a/packages/core/lib/v3/types/public/sdkErrors.ts b/packages/core/lib/v3/types/public/sdkErrors.ts index 08b213861..56efe3850 100644 --- a/packages/core/lib/v3/types/public/sdkErrors.ts +++ b/packages/core/lib/v3/types/public/sdkErrors.ts @@ -363,21 +363,4 @@ export class AgentAbortError extends StagehandError { super(message); this.reason = reason || "aborted"; } - - /** - * Check if an error is an abort-related error (either AgentAbortError or native AbortError) - */ - static isAbortError(error: unknown): boolean { - if (error instanceof AgentAbortError) { - return true; - } - if (error instanceof Error) { - return ( - error.name === "AbortError" || - error.message.includes("aborted") || - error.message.includes("abort") - ); - } - return false; - } }