From b74dd98dc3e8410f958206b669f69907ea7335ab Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:13:54 -0800 Subject: [PATCH 1/4] add new flow logger --- packages/core/examples/flowLoggingJourney.ts | 60 ++++ packages/core/lib/v3/flowLogger.ts | 269 ++++++++++++++++++ .../handlers/handlerUtils/actHandlerUtils.ts | 7 + .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 10 + packages/core/lib/v3/understudy/cdp.ts | 3 + packages/core/lib/v3/v3.ts | 24 ++ 6 files changed, 373 insertions(+) create mode 100644 packages/core/examples/flowLoggingJourney.ts create mode 100644 packages/core/lib/v3/flowLogger.ts diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts new file mode 100644 index 000000000..937417978 --- /dev/null +++ b/packages/core/examples/flowLoggingJourney.ts @@ -0,0 +1,60 @@ +import { Stagehand } from "../lib/v3"; + +async function run(): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + "Set OPENAI_API_KEY to a valid OpenAI key before running this demo.", + ); + } + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 2, + model: { modelName: "openai/gpt-4.1-mini", apiKey }, + localBrowserLaunchOptions: { + headless: true, + args: ["--window-size=1280,720"], + }, + disablePino: true, + }); + + try { + await stagehand.init(); + + const [page] = stagehand.context.pages(); + await page.goto("https://example.com/", { waitUntil: "load" }); + + const agent = stagehand.agent({ + systemPrompt: + "You are a QA assistant. Keep answers short and deterministic. Finish quickly.", + }); + const agentResult = await agent.execute( + "Glance at the Example Domain page and confirm that you see the hero text.", + ); + console.log("Agent result:", agentResult); + + const observations = await stagehand.observe( + "Locate the 'More information...' link on this page.", + ); + console.log("Observe result:", observations); + + if (observations.length > 0) { + await stagehand.act(observations[0]); + } else { + await stagehand.act("click the link labeled 'More information...'"); + } + + const extraction = await stagehand.extract( + "Summarize the current page title and URL.", + ); + console.log("Extraction result:", extraction); + } finally { + await stagehand.close({ force: true }).catch(() => {}); + } +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts new file mode 100644 index 000000000..a6b0adb4e --- /dev/null +++ b/packages/core/lib/v3/flowLogger.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { v3Logger } from "./logger"; + +type FlowPrefixOptions = { + includeAction?: boolean; + includeStep?: boolean; + includeTask?: boolean; +}; + +const MAX_ARG_LENGTH = 500; + +let currentTaskId: string | null = null; +let currentStepId: string | null = null; +let currentActionId: string | null = null; +let currentStepLabel: string | null = null; +let currentActionLabel: string | null = null; + +function generateId(label: string): string { + try { + return randomUUID(); + } catch { + const fallback = + (globalThis.crypto as Crypto | undefined)?.randomUUID?.() ?? + `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + return fallback; + } +} + +function truncate(value: string): string { + if (value.length <= MAX_ARG_LENGTH) { + return value; + } + return `${value.slice(0, MAX_ARG_LENGTH)}…`; +} + +function formatValue(value: unknown): string { + if (typeof value === "string") { + return `'${value}'`; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + value === null + ) { + return String(value); + } + if (Array.isArray(value)) { + try { + return truncate(JSON.stringify(value)); + } catch { + return "[unserializable array]"; + } + } + if (typeof value === "object" && value !== null) { + try { + return truncate(JSON.stringify(value)); + } catch { + return "[unserializable object]"; + } + } + if (value === undefined) { + return "undefined"; + } + return truncate(String(value)); +} + +function formatArgs(args?: unknown | unknown[]): string { + if (args === undefined) { + return ""; + } + const normalized = Array.isArray(args) ? args : [args]; + const rendered = normalized + .map((entry) => formatValue(entry)) + .filter((entry) => entry.length > 0); + return rendered.join(", "); +} + +function formatTag(label: string, id: string | null): string { + return `[${label} #${shortId(id)}]`; +} + +function formatCdpTag(sessionId?: string | null): string { + if (!sessionId) return "[CDP]"; + return `[CDP #${shortId(sessionId).toUpperCase()}]`; +} + +function shortId(id: string | null): string { + if (!id) return "-"; + const trimmed = id.slice(-4); + return trimmed; +} + +function ensureTaskContext(): void { + if (!currentTaskId) { + currentTaskId = generateId("task"); + } +} + +function ensureStepContext(defaultLabel?: string): void { + if (defaultLabel) { + currentStepLabel = defaultLabel.toUpperCase(); + } + if (!currentStepLabel) { + currentStepLabel = "STEP"; + } + if (!currentStepId) { + currentStepId = generateId("step"); + } +} + +function ensureActionContext(defaultLabel?: string): void { + if (defaultLabel) { + currentActionLabel = defaultLabel.toUpperCase(); + } + if (!currentActionLabel) { + currentActionLabel = "ACTION"; + } + if (!currentActionId) { + currentActionId = generateId("action"); + } +} + +function buildPrefix({ + includeAction = true, + includeStep = true, + includeTask = true, +}: FlowPrefixOptions = {}): string { + const parts: string[] = []; + if (includeTask) { + ensureTaskContext(); + parts.push(formatTag("TASK", currentTaskId)); + } + if (includeStep) { + ensureStepContext(); + const label = currentStepLabel ?? "STEP"; + parts.push(formatTag(label, currentStepId)); + } + if (includeAction) { + ensureActionContext(); + const actionLabel = currentActionLabel ?? "ACTION"; + parts.push(formatTag(actionLabel, currentActionId)); + } + return parts.join(" "); +} + +export function logTaskProgress({ + invocation, + args, +}: { + invocation: string; + args?: unknown | unknown[]; +}): string { + currentTaskId = generateId("task"); + currentStepId = null; + currentActionId = null; + currentStepLabel = null; + currentActionLabel = null; + + const call = `${invocation}(${formatArgs(args)})`; + const message = `${buildPrefix({ + includeTask: true, + includeStep: false, + includeAction: false, + })} ${call}`; + v3Logger({ + category: "flow", + message, + level: 2, + }); + return currentTaskId; +} + +export function logStepProgress({ + invocation, + args, + label, +}: { + invocation: string; + args?: unknown | unknown[]; + label: string; +}): string { + ensureTaskContext(); + currentStepId = generateId("step"); + currentStepLabel = label.toUpperCase(); + currentActionId = null; + currentActionLabel = null; + + const call = `${invocation}(${formatArgs(args)})`; + const message = `${buildPrefix({ + includeTask: true, + includeStep: true, + includeAction: false, + })} ${call}`; + v3Logger({ + category: "flow", + message, + level: 2, + }); + return currentStepId; +} + +export function logActionProgress({ + actionType, + target, + args, +}: { + actionType: string; + target?: string; + args?: unknown | unknown[]; +}): string { + ensureTaskContext(); + ensureStepContext(); + currentActionId = generateId("action"); + currentActionLabel = actionType.toUpperCase(); + const details: string[] = [`${actionType}`]; + if (target) { + details.push(`target=${target}`); + } + const argString = formatArgs(args); + if (argString) { + details.push(`args=[${argString}]`); + } + + const message = `${buildPrefix({ + includeTask: true, + includeStep: true, + includeAction: true, + })} ${details.join(" ")}`; + v3Logger({ + category: "flow", + message, + level: 2, + }); + return currentActionId; +} + +export function logCdpMessage({ + method, + params, + sessionId, +}: { + method: string; + params?: object; + sessionId?: string | null; +}): void { + const args = params ? formatArgs(params) : ""; + const call = args ? `${method}(${args})` : `${method}()`; + const prefix = buildPrefix({ + includeTask: true, + includeStep: true, + includeAction: true, + }); + const rawMessage = `${prefix} ${formatCdpTag(sessionId)} ${call}`; + const message = + rawMessage.length > 120 ? `${rawMessage.slice(0, 117)}...` : rawMessage; + v3Logger({ + category: "flow", + message, + level: 2, + }); +} + +export function clearFlowContext(): void { + currentTaskId = null; + currentStepId = null; + currentActionId = null; + currentStepLabel = null; + currentActionLabel = null; +} diff --git a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts index 7a8ffb9a3..034fa684f 100644 --- a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts +++ b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts @@ -5,6 +5,7 @@ import { Locator } from "../../understudy/locator"; import { resolveLocatorWithHops } from "../../understudy/deepLocator"; import type { Page } from "../../understudy/page"; import { v3Logger } from "../../logger"; +import { logActionProgress } from "../../flowLogger"; import { StagehandClickError } from "../../types/public/sdkErrors"; export class UnderstudyCommandException extends Error { @@ -73,6 +74,12 @@ export async function performUnderstudyMethod( domSettleTimeoutMs, }; + logActionProgress({ + actionType: method, + target: selectorRaw, + args: Array.from(args), + }); + try { const handler = METHOD_HANDLER_MAP[method] ?? null; diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index 452736ba4..5b6bae4ea 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -13,6 +13,7 @@ import { } from "../types/public/agent"; import { LogLine } from "../types/public/logs"; import { type Action, V3FunctionName } from "../types/public/methods"; +import { logActionProgress } from "../flowLogger"; export class V3CuaAgentHandler { private v3: V3; @@ -160,6 +161,15 @@ export class V3CuaAgentHandler { ): Promise { const page = await this.v3.context.awaitActivePage(); const recording = this.v3.isAgentReplayActive(); + const pointerTarget = + typeof action.x === "number" && typeof action.y === "number" + ? `(${action.x}, ${action.y})` + : action.selector || action.input || action.description; + logActionProgress({ + actionType: action.type, + target: pointerTarget, + args: [action], + }); switch (action.type) { case "click": { const { x, y, button = "left", clickCount } = action; diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index c6e82e3c3..e3655cc14 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -1,6 +1,7 @@ // lib/v3/understudy/cdp.ts import WebSocket from "ws"; import type { Protocol } from "devtools-protocol"; +import { logCdpMessage } from "../flowLogger"; /** * CDP transport & session multiplexer @@ -118,6 +119,7 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); + logCdpMessage({ method, params, sessionId: null }); this.ws.send(JSON.stringify(payload)); return p; } @@ -232,6 +234,7 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); + logCdpMessage({ method, params, sessionId }); this.ws.send(JSON.stringify(payload)); return p; } diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index a5c030fa9..60011a1fa 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -70,6 +70,7 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; +import { logTaskProgress, logStepProgress } from "./flowLogger"; const DEFAULT_MODEL_NAME = "openai/gpt-4.1-mini"; const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; @@ -938,6 +939,11 @@ export class V3 { async act(input: string | Action, options?: ActOptions): Promise { return await withInstanceLogContext(this.instanceId, async () => { + logStepProgress({ + invocation: "stagehand.act", + args: [input, options], + label: "ACT", + }); if (!this.actHandler) throw new StagehandNotInitializedError("act()"); let actResult: ActResult; @@ -1085,6 +1091,11 @@ export class V3 { c?: ExtractOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { + logStepProgress({ + invocation: "stagehand.extract", + args: [a, b, c], + label: "EXTRACT", + }); if (!this.extractHandler) { throw new StagehandNotInitializedError("extract()"); } @@ -1163,6 +1174,11 @@ export class V3 { b?: ObserveOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { + logStepProgress({ + invocation: "stagehand.observe", + args: [a, b], + label: "OBSERVE", + }); if (!this.observeHandler) { throw new StagehandNotInitializedError("observe()"); } @@ -1495,6 +1511,10 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { + logTaskProgress({ + invocation: "agent.execute", + args: [instructionOrOptions], + }); if (options?.integrations && !this.experimental) { throw new ExperimentalNotConfiguredError("MCP integrations"); } @@ -1591,6 +1611,10 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { + logTaskProgress({ + invocation: "agent.execute", + args: [instructionOrOptions], + }); if ((options?.integrations || options?.tools) && !this.experimental) { throw new ExperimentalNotConfiguredError( "MCP integrations and custom tools", From 688eb6347bd58b05013e41a67390c4c60f96ee69 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:17:41 -0800 Subject: [PATCH 2/4] hide unused extract args --- packages/core/lib/v3/flowLogger.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index a6b0adb4e..699bf325c 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -68,7 +68,9 @@ function formatArgs(args?: unknown | unknown[]): string { if (args === undefined) { return ""; } - const normalized = Array.isArray(args) ? args : [args]; + const normalized = (Array.isArray(args) ? args : [args]).filter( + (entry) => entry !== undefined, + ); const rendered = normalized .map((entry) => formatValue(entry)) .filter((entry) => entry.length > 0); From f4a30b00a64541f3e59667511da9a3accc5677b4 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:23:11 -0800 Subject: [PATCH 3/4] fix the lint errors --- packages/core/lib/v3/handlers/v3CuaAgentHandler.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index 5b6bae4ea..7ae7bffef 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -164,7 +164,13 @@ export class V3CuaAgentHandler { const pointerTarget = typeof action.x === "number" && typeof action.y === "number" ? `(${action.x}, ${action.y})` - : action.selector || action.input || action.description; + : typeof action.selector === "string" + ? action.selector + : typeof action.input === "string" + ? action.input + : typeof action.description === "string" + ? action.description + : undefined; logActionProgress({ actionType: action.type, target: pointerTarget, From ea491f33c352601a6dde6dc48ff1ad9a87357698 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:26:17 -0800 Subject: [PATCH 4/4] fix unused label var --- packages/core/lib/v3/flowLogger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 699bf325c..3629d32c1 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -21,7 +21,7 @@ function generateId(label: string): string { } catch { const fallback = (globalThis.crypto as Crypto | undefined)?.randomUUID?.() ?? - `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + `${Date.now()}-${label}-${Math.floor(Math.random() * 1e6)}`; return fallback; } }