From 00aae088a12eef9660b2c3720768824db87e49a8 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:13:54 -0800 Subject: [PATCH 01/32] 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 a5f2bc155..11a2eeb9a 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 33b67721b..dd8d0b371 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; @@ -162,6 +163,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 e564cebbb..41e92d7f1 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -72,6 +72,7 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; +import { logTaskProgress, logStepProgress } from "./flowLogger"; import { createTimeoutGuard } from "./handlers/handlerUtils/timeoutGuard"; import { ActTimeoutError } from "./types/public/sdkErrors"; @@ -966,6 +967,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; @@ -1122,6 +1128,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()"); } @@ -1201,6 +1212,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()"); } @@ -1650,6 +1666,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"); } @@ -1752,6 +1772,10 @@ export class V3 { | AgentStreamExecuteOptions, ): Promise => withInstanceLogContext(this.instanceId, async () => { + logTaskProgress({ + invocation: "agent.execute", + args: [instructionOrOptions], + }); if ( typeof instructionOrOptions === "object" && instructionOrOptions.callbacks && From 9c7d138be9b16be93222989bc2e74672eff1d007 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:17:41 -0800 Subject: [PATCH 02/32] 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 06472d6a6e15c893b0a629c0d61e04236029a0a2 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:23:11 -0800 Subject: [PATCH 03/32] 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 dd8d0b371..e0b69d942 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -166,7 +166,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 9d06f314aa28cab00ea25882cd3b417bccc06a01 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 18 Nov 2025 11:26:17 -0800 Subject: [PATCH 04/32] 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; } } From b788e4dd8564e760a952d035d980075ce08a6c04 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:46:38 +0000 Subject: [PATCH 05/32] Write flow logs to configurable file paths instead of stdout - Add SessionFileLogger class that writes to session-specific directories - Read BROWSERBASE_CONFIG_DIR from env (defaults to ./.browserbase) - Create session directory structure: {configDir}/sessions/{sessionId}/ - Create convenience symlink: {configDir}/sessions/latest - Write to 5 separate log files: - session.json (sanitized V3Options with secrets masked) - agent_events.log (TASK level - agent.execute) - stagehand_events.log (STEP level - act/observe/extract) - understudy_events.log (ACTION level - CLICK/HOVER/etc) - cdp_events.log (CDP MSG level) - All filesystem operations are async, non-blocking, and fail silently - Logs still also written to stdout via v3Logger for backwards compatibility - Add .browserbase/ to .gitignore - Update flowLoggingJourney example to show session log directory Co-authored-by: Nick Sweeting --- .gitignore | 1 + packages/core/examples/flowLoggingJourney.ts | 16 ++ packages/core/lib/v3/flowLogger.ts | 44 +++ packages/core/lib/v3/sessionFileLogger.ts | 269 +++++++++++++++++++ packages/core/lib/v3/v3.ts | 27 +- 5 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 packages/core/lib/v3/sessionFileLogger.ts diff --git a/.gitignore b/.gitignore index 6fa1d5aad..f9a77a016 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ screenshot.png .env downloads/ dist/ +.browserbase/ packages/evals/**/public packages/core/lib/dom/build/ packages/core/lib/v3/dom/build/ diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts index 937417978..2def3fc2f 100644 --- a/packages/core/examples/flowLoggingJourney.ts +++ b/packages/core/examples/flowLoggingJourney.ts @@ -1,4 +1,5 @@ import { Stagehand } from "../lib/v3"; +import { getSessionFileLogger } from "../lib/v3/flowLogger"; async function run(): Promise { const apiKey = process.env.OPENAI_API_KEY; @@ -8,6 +9,9 @@ async function run(): Promise { ); } + // Set custom config dir if desired + // process.env.BROWSERBASE_CONFIG_DIR = "/tmp/my-stagehand-logs"; + const stagehand = new Stagehand({ env: "LOCAL", verbose: 2, @@ -22,6 +26,18 @@ async function run(): Promise { try { await stagehand.init(); + // Get the session file logger to see where logs are being written + const fileLogger = getSessionFileLogger(); + if (fileLogger) { + console.log("\nšŸ—‚ļø Session logs are being written to:"); + console.log(" ", fileLogger.getSessionDir()); + console.log(" └── agent_events.log"); + console.log(" └── stagehand_events.log"); + console.log(" └── understudy_events.log"); + console.log(" └── cdp_events.log"); + console.log(" └── session.json\n"); + } + const [page] = stagehand.context.pages(); await page.goto("https://example.com/", { waitUntil: "load" }); diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 3629d32c1..8e6db6768 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { v3Logger } from "./logger"; +import type { SessionFileLogger } from "./sessionFileLogger"; type FlowPrefixOptions = { includeAction?: boolean; @@ -14,6 +15,7 @@ let currentStepId: string | null = null; let currentActionId: string | null = null; let currentStepLabel: string | null = null; let currentActionLabel: string | null = null; +let sessionFileLogger: SessionFileLogger | null = null; function generateId(label: string): string { try { @@ -145,6 +147,20 @@ function buildPrefix({ return parts.join(" "); } +/** + * Set the session file logger for writing logs to files + */ +export function setSessionFileLogger(logger: SessionFileLogger | null): void { + sessionFileLogger = logger; +} + +/** + * Get the current session file logger + */ +export function getSessionFileLogger(): SessionFileLogger | null { + return sessionFileLogger; +} + export function logTaskProgress({ invocation, args, @@ -164,6 +180,13 @@ export function logTaskProgress({ includeStep: false, includeAction: false, })} ${call}`; + + // Write to file if available + if (sessionFileLogger) { + sessionFileLogger.writeAgentLog(message); + } + + // Also log via v3Logger for backwards compatibility v3Logger({ category: "flow", message, @@ -193,6 +216,13 @@ export function logStepProgress({ includeStep: true, includeAction: false, })} ${call}`; + + // Write to file if available + if (sessionFileLogger) { + sessionFileLogger.writeStagehandLog(message); + } + + // Also log via v3Logger for backwards compatibility v3Logger({ category: "flow", message, @@ -228,6 +258,13 @@ export function logActionProgress({ includeStep: true, includeAction: true, })} ${details.join(" ")}`; + + // Write to file if available + if (sessionFileLogger) { + sessionFileLogger.writeUnderstudyLog(message); + } + + // Also log via v3Logger for backwards compatibility v3Logger({ category: "flow", message, @@ -255,6 +292,13 @@ export function logCdpMessage({ const rawMessage = `${prefix} ${formatCdpTag(sessionId)} ${call}`; const message = rawMessage.length > 120 ? `${rawMessage.slice(0, 117)}...` : rawMessage; + + // Write to file if available + if (sessionFileLogger) { + sessionFileLogger.writeCdpLog(message); + } + + // Also log via v3Logger for backwards compatibility v3Logger({ category: "flow", message, diff --git a/packages/core/lib/v3/sessionFileLogger.ts b/packages/core/lib/v3/sessionFileLogger.ts new file mode 100644 index 000000000..1cde8b4d1 --- /dev/null +++ b/packages/core/lib/v3/sessionFileLogger.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { V3Options } from "./types/public"; + +interface SessionFileLoggerConfig { + sessionId: string; + configDir: string; + v3Options?: V3Options; +} + +interface LogFile { + path: string; + stream: fs.WriteStream | null; +} + +/** + * SessionFileLogger manages writing logs to session-specific files. + * All filesystem operations are async and fail silently to avoid blocking execution. + */ +export class SessionFileLogger { + private sessionId: string; + private sessionDir: string; + private configDir: string; + private logFiles: { + agent: LogFile; + stagehand: LogFile; + understudy: LogFile; + cdp: LogFile; + }; + private initialized = false; + + constructor(config: SessionFileLoggerConfig) { + this.sessionId = config.sessionId; + this.configDir = config.configDir; + this.sessionDir = path.join( + this.configDir, + "sessions", + this.sessionId, + ); + + // Initialize log file paths (but don't create streams yet) + this.logFiles = { + agent: { path: path.join(this.sessionDir, "agent_events.log"), stream: null }, + stagehand: { path: path.join(this.sessionDir, "stagehand_events.log"), stream: null }, + understudy: { path: path.join(this.sessionDir, "understudy_events.log"), stream: null }, + cdp: { path: path.join(this.sessionDir, "cdp_events.log"), stream: null }, + }; + + // Initialize asynchronously (non-blocking) + this.initAsync(config.v3Options).catch(() => { + // Fail silently + }); + } + + private async initAsync(v3Options?: V3Options): Promise { + try { + // Create session directory + await fs.promises.mkdir(this.sessionDir, { recursive: true }); + + // Create session.json with sanitized options + if (v3Options) { + const sanitizedOptions = this.sanitizeOptions(v3Options); + const sessionJsonPath = path.join(this.sessionDir, "session.json"); + await fs.promises.writeFile( + sessionJsonPath, + JSON.stringify(sanitizedOptions, null, 2), + "utf-8", + ); + } + + // Create symlink to latest session + const latestLink = path.join(this.configDir, "sessions", "latest"); + try { + // Remove existing symlink if it exists + try { + await fs.promises.unlink(latestLink); + } catch { + // Ignore if doesn't exist + } + // Create new symlink (relative path for portability) + await fs.promises.symlink(this.sessionId, latestLink, "dir"); + } catch { + // Symlink creation can fail on Windows or due to permissions + // Fail silently + } + + // Create write streams for log files + for (const [, logFile] of Object.entries(this.logFiles)) { + try { + logFile.stream = fs.createWriteStream(logFile.path, { flags: "a" }); + // Don't wait for drain events - let Node.js buffer handle it + } catch { + // Fail silently if stream creation fails + } + } + + this.initialized = true; + } catch { + // Fail silently - logging should never crash the application + } + } + + /** + * Sanitize V3Options by replacing sensitive values with ****** + */ + private sanitizeOptions(options: V3Options): Record { + const sanitized: Record = { ...options }; + + // List of keys that may contain sensitive data + const sensitiveKeys = [ + "apiKey", + "api_key", + "apikey", + "key", + "secret", + "token", + "password", + "passwd", + "pwd", + "credential", + "credentials", + "auth", + "authorization", + ]; + + const sanitizeValue = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sanitizeValue); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { + result[key] = "******"; + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeValue(value); + } else { + result[key] = value; + } + } + return result; + }; + + return sanitizeValue(sanitized) as Record; + } + + /** + * Write a log line to the agent events log file + */ + writeAgentLog(message: string): void { + this.writeToFile(this.logFiles.agent, message); + } + + /** + * Write a log line to the stagehand events log file + */ + writeStagehandLog(message: string): void { + this.writeToFile(this.logFiles.stagehand, message); + } + + /** + * Write a log line to the understudy events log file + */ + writeUnderstudyLog(message: string): void { + this.writeToFile(this.logFiles.understudy, message); + } + + /** + * Write a log line to the CDP events log file + */ + writeCdpLog(message: string): void { + this.writeToFile(this.logFiles.cdp, message); + } + + /** + * Write to a log file asynchronously (non-blocking) + */ + private writeToFile(logFile: LogFile, message: string): void { + if (!this.initialized || !logFile.stream) { + return; // Silently skip if not initialized + } + + try { + // Non-blocking write - don't await or check for drain + // Node.js will buffer and handle backpressure internally + logFile.stream.write(message + "\n", (err) => { + if (err) { + // Fail silently - logging errors should not crash the app + } + }); + } catch { + // Fail silently + } + } + + /** + * Close all log streams (call on shutdown) + */ + async close(): Promise { + const closePromises: Promise[] = []; + + for (const [, logFile] of Object.entries(this.logFiles)) { + if (logFile.stream) { + closePromises.push( + new Promise((resolve) => { + logFile.stream!.end(() => { + logFile.stream = null; + resolve(); + }); + }), + ); + } + } + + try { + await Promise.all(closePromises); + } catch { + // Fail silently + } + } + + /** + * Get the session directory path + */ + getSessionDir(): string { + return this.sessionDir; + } + + /** + * Get the session ID + */ + getSessionId(): string { + return this.sessionId; + } +} + +/** + * Get the config directory from environment or use default + */ +export function getConfigDir(): string { + const fromEnv = process.env.BROWSERBASE_CONFIG_DIR; + if (fromEnv) { + return path.resolve(fromEnv); + } + // Default to .browserbase in current working directory + return path.resolve(process.cwd(), ".browserbase"); +} + +/** + * Create a session file logger instance + */ +export function createSessionFileLogger( + sessionId: string, + v3Options?: V3Options, +): SessionFileLogger { + const configDir = getConfigDir(); + return new SessionFileLogger({ + sessionId, + configDir, + v3Options, + }); +} diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 41e92d7f1..ff0eaa683 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -72,7 +72,15 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; -import { logTaskProgress, logStepProgress } from "./flowLogger"; +import { + logTaskProgress, + logStepProgress, + setSessionFileLogger, +} from "./flowLogger"; +import { + createSessionFileLogger, + SessionFileLogger, +} from "./sessionFileLogger"; import { createTimeoutGuard } from "./handlers/handlerUtils/timeoutGuard"; import { ActTimeoutError } from "./types/public/sdkErrors"; @@ -175,6 +183,7 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; + private sessionFileLogger: SessionFileLogger | null = null; public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -311,6 +320,11 @@ export class V3 { }); this.opts = opts; + + // Initialize session file logger + this.sessionFileLogger = createSessionFileLogger(this.instanceId, opts); + setSessionFileLogger(this.sessionFileLogger); + // Track instance for global process guard handling V3._instances.add(this); } @@ -1290,6 +1304,17 @@ export class V3 { this._isClosing = true; try { + // Close session file logger + if (this.sessionFileLogger) { + try { + await this.sessionFileLogger.close(); + setSessionFileLogger(null); + this.sessionFileLogger = null; + } catch { + // Fail silently + } + } + // Unhook CDP transport close handler if context exists try { if (this.ctx?.conn && this._onCdpClosed) { From b289fa845bceec5a390f8e780980d608bcf5b86e Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 4 Dec 2025 13:51:37 -0800 Subject: [PATCH 06/32] simplify flow logging --- packages/core/examples/flowLoggingJourney.ts | 76 --- packages/core/lib/v3/flowLogger.ts | 599 ++++++++++++------ .../handlers/handlerUtils/actHandlerUtils.ts | 4 +- .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 4 +- packages/core/lib/v3/sessionFileLogger.ts | 269 -------- packages/core/lib/v3/understudy/cdp.ts | 6 +- packages/core/lib/v3/v3.ts | 26 +- 7 files changed, 411 insertions(+), 573 deletions(-) delete mode 100644 packages/core/examples/flowLoggingJourney.ts delete mode 100644 packages/core/lib/v3/sessionFileLogger.ts diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts deleted file mode 100644 index 2def3fc2f..000000000 --- a/packages/core/examples/flowLoggingJourney.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Stagehand } from "../lib/v3"; -import { getSessionFileLogger } from "../lib/v3/flowLogger"; - -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.", - ); - } - - // Set custom config dir if desired - // process.env.BROWSERBASE_CONFIG_DIR = "/tmp/my-stagehand-logs"; - - 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(); - - // Get the session file logger to see where logs are being written - const fileLogger = getSessionFileLogger(); - if (fileLogger) { - console.log("\nšŸ—‚ļø Session logs are being written to:"); - console.log(" ", fileLogger.getSessionDir()); - console.log(" └── agent_events.log"); - console.log(" └── stagehand_events.log"); - console.log(" └── understudy_events.log"); - console.log(" └── cdp_events.log"); - console.log(" └── session.json\n"); - } - - 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 index 8e6db6768..1a9bb087e 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -1,21 +1,37 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { randomUUID } from "node:crypto"; -import { v3Logger } from "./logger"; -import type { SessionFileLogger } from "./sessionFileLogger"; - -type FlowPrefixOptions = { - includeAction?: boolean; - includeStep?: boolean; - includeTask?: boolean; -}; +import fs from "node:fs"; +import path from "node:path"; +import type { V3Options } from "./types/public"; 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; -let sessionFileLogger: SessionFileLogger | null = null; +interface LogFile { + path: string; + stream: fs.WriteStream | null; +} + +interface FlowLoggerContext { + sessionId: string; + sessionDir: string; + configDir: string; + logFiles: { + agent: LogFile; + stagehand: LogFile; + understudy: LogFile; + cdp: LogFile; + }; + initPromise: Promise; + initialized: boolean; + // Flow context + taskId: string | null; + stepId: string | null; + actionId: string | null; + stepLabel: string | null; + actionLabel: string | null; +} + +const loggerContext = new AsyncLocalStorage(); function generateId(label: string): string { try { @@ -90,226 +106,399 @@ function formatCdpTag(sessionId?: string | null): string { function shortId(id: string | null): string { if (!id) return "-"; - const trimmed = id.slice(-4); - return trimmed; + return id.slice(-4); } -function ensureTaskContext(): void { - if (!currentTaskId) { - currentTaskId = generateId("task"); - } +function sanitizeOptions(options: V3Options): Record { + const sensitiveKeys = [ + "apiKey", + "api_key", + "apikey", + "key", + "secret", + "token", + "password", + "passwd", + "pwd", + "credential", + "credentials", + "auth", + "authorization", + ]; + + const sanitizeValue = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(sanitizeValue); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { + result[key] = "******"; + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeValue(value); + } else { + result[key] = value; + } + } + return result; + }; + + return sanitizeValue({ ...options }) as Record; } -function ensureStepContext(defaultLabel?: string): void { - if (defaultLabel) { - currentStepLabel = defaultLabel.toUpperCase(); - } - if (!currentStepLabel) { - currentStepLabel = "STEP"; - } - if (!currentStepId) { - currentStepId = generateId("step"); +/** + * Get the config directory from environment or use default + */ +export function getConfigDir(): string { + const fromEnv = process.env.BROWSERBASE_CONFIG_DIR; + if (fromEnv) { + return path.resolve(fromEnv); } + return path.resolve(process.cwd(), ".browserbase"); } -function ensureActionContext(defaultLabel?: string): void { - if (defaultLabel) { - currentActionLabel = defaultLabel.toUpperCase(); +/** + * SessionFileLogger - static methods for flow logging with AsyncLocalStorage context + */ +export class SessionFileLogger { + /** + * Initialize a new logging context. Call this at the start of a session. + */ + static init(sessionId: string, v3Options?: V3Options): void { + const configDir = getConfigDir(); + const sessionDir = path.join(configDir, "sessions", sessionId); + + const ctx: FlowLoggerContext = { + sessionId, + sessionDir, + configDir, + logFiles: { + agent: { path: path.join(sessionDir, "agent_events.log"), stream: null }, + stagehand: { path: path.join(sessionDir, "stagehand_events.log"), stream: null }, + understudy: { path: path.join(sessionDir, "understudy_events.log"), stream: null }, + cdp: { path: path.join(sessionDir, "cdp_events.log"), stream: null }, + }, + initPromise: Promise.resolve(), + initialized: false, + taskId: null, + stepId: null, + actionId: null, + stepLabel: null, + actionLabel: null, + }; + + // Store init promise for awaiting in writeToFile + ctx.initPromise = SessionFileLogger.initAsync(ctx, v3Options); + + loggerContext.enterWith(ctx); } - if (!currentActionLabel) { - currentActionLabel = "ACTION"; + + private static async initAsync( + ctx: FlowLoggerContext, + v3Options?: V3Options, + ): Promise { + try { + await fs.promises.mkdir(ctx.sessionDir, { recursive: true }); + + if (v3Options) { + const sanitizedOptions = sanitizeOptions(v3Options); + const sessionJsonPath = path.join(ctx.sessionDir, "session.json"); + await fs.promises.writeFile( + sessionJsonPath, + JSON.stringify(sanitizedOptions, null, 2), + "utf-8", + ); + } + + // Create symlink to latest session + const latestLink = path.join(ctx.configDir, "sessions", "latest"); + try { + try { + await fs.promises.unlink(latestLink); + } catch { + // Ignore if doesn't exist + } + await fs.promises.symlink(ctx.sessionId, latestLink, "dir"); + } catch { + // Symlink creation can fail on Windows or due to permissions + } + + for (const logFile of Object.values(ctx.logFiles)) { + try { + logFile.stream = fs.createWriteStream(logFile.path, { flags: "a" }); + } catch { + // Fail silently + } + } + + ctx.initialized = true; + } catch { + // Fail silently + } } - if (!currentActionId) { - currentActionId = generateId("action"); + + private static async writeToFile( + logFile: LogFile, + message: string, + ): Promise { + const ctx = loggerContext.getStore(); + if (!ctx) return; + + await ctx.initPromise; + + if (!ctx.initialized || !logFile.stream) { + return; + } + + try { + logFile.stream.write(message + "\n", (err) => { + if (err) { + // Fail silently + } + }); + } catch { + // Fail silently + } } -} -function buildPrefix({ - includeAction = true, - includeStep = true, - includeTask = true, -}: FlowPrefixOptions = {}): string { - const parts: string[] = []; - if (includeTask) { - ensureTaskContext(); - parts.push(formatTag("TASK", currentTaskId)); + static async close(): Promise { + const ctx = loggerContext.getStore(); + if (!ctx) return; + + await ctx.initPromise; + + const closePromises: Promise[] = []; + + for (const logFile of Object.values(ctx.logFiles)) { + if (logFile.stream) { + closePromises.push( + new Promise((resolve) => { + logFile.stream!.end(() => { + logFile.stream = null; + resolve(); + }); + }), + ); + } + } + + try { + await Promise.all(closePromises); + } catch { + // Fail silently + } + + SessionFileLogger.clearFlowContext(); } - if (includeStep) { - ensureStepContext(); - const label = currentStepLabel ?? "STEP"; - parts.push(formatTag(label, currentStepId)); + + static get sessionId(): string | null { + return loggerContext.getStore()?.sessionId ?? null; } - if (includeAction) { - ensureActionContext(); - const actionLabel = currentActionLabel ?? "ACTION"; - parts.push(formatTag(actionLabel, currentActionId)); + + static get sessionDir(): string | null { + return loggerContext.getStore()?.sessionDir ?? null; } - return parts.join(" "); -} -/** - * Set the session file logger for writing logs to files - */ -export function setSessionFileLogger(logger: SessionFileLogger | null): void { - sessionFileLogger = logger; -} + // --- Flow context methods --- -/** - * Get the current session file logger - */ -export function getSessionFileLogger(): SessionFileLogger | null { - return sessionFileLogger; -} + private static ensureTaskContext(ctx: FlowLoggerContext): void { + if (!ctx.taskId) { + ctx.taskId = generateId("task"); + } + } -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}`; - - // Write to file if available - if (sessionFileLogger) { - sessionFileLogger.writeAgentLog(message); + private static ensureStepContext( + ctx: FlowLoggerContext, + defaultLabel?: string, + ): void { + if (defaultLabel) { + ctx.stepLabel = defaultLabel.toUpperCase(); + } + if (!ctx.stepLabel) { + ctx.stepLabel = "STEP"; + } + if (!ctx.stepId) { + ctx.stepId = generateId("step"); + } } - // Also log via v3Logger for backwards compatibility - v3Logger({ - category: "flow", - message, - level: 2, - }); - return currentTaskId; -} + private static ensureActionContext( + ctx: FlowLoggerContext, + defaultLabel?: string, + ): void { + if (defaultLabel) { + ctx.actionLabel = defaultLabel.toUpperCase(); + } + if (!ctx.actionLabel) { + ctx.actionLabel = "ACTION"; + } + if (!ctx.actionId) { + ctx.actionId = generateId("action"); + } + } -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}`; - - // Write to file if available - if (sessionFileLogger) { - sessionFileLogger.writeStagehandLog(message); + private static buildPrefix( + ctx: FlowLoggerContext, + options: { + includeAction?: boolean; + includeStep?: boolean; + includeTask?: boolean; + } = {}, + ): string { + const { includeAction = true, includeStep = true, includeTask = true } = options; + const parts: string[] = []; + if (includeTask) { + SessionFileLogger.ensureTaskContext(ctx); + parts.push(formatTag("TASK", ctx.taskId)); + } + if (includeStep) { + SessionFileLogger.ensureStepContext(ctx); + parts.push(formatTag(ctx.stepLabel ?? "STEP", ctx.stepId)); + } + if (includeAction) { + SessionFileLogger.ensureActionContext(ctx); + parts.push(formatTag(ctx.actionLabel ?? "ACTION", ctx.actionId)); + } + return parts.join(" "); } - // Also log via v3Logger for backwards compatibility - v3Logger({ - category: "flow", - message, - level: 2, - }); - return currentStepId; -} + static clearFlowContext(): void { + const ctx = loggerContext.getStore(); + if (ctx) { + ctx.taskId = null; + ctx.stepId = null; + ctx.actionId = null; + ctx.stepLabel = null; + ctx.actionLabel = null; + } + } -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}`); + // --- Logging methods --- + + static logTaskProgress({ + invocation, + args, + }: { + invocation: string; + args?: unknown | unknown[]; + }): string { + const ctx = loggerContext.getStore(); + if (!ctx) return generateId("task"); + + ctx.taskId = generateId("task"); + ctx.stepId = null; + ctx.actionId = null; + ctx.stepLabel = null; + ctx.actionLabel = null; + + const call = `${invocation}(${formatArgs(args)})`; + const message = `${SessionFileLogger.buildPrefix(ctx, { + includeTask: true, + includeStep: false, + includeAction: false, + })} ${call}`; + + SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); + + return ctx.taskId; } - const argString = formatArgs(args); - if (argString) { - details.push(`args=[${argString}]`); + + static logStepProgress({ + invocation, + args, + label, + }: { + invocation: string; + args?: unknown | unknown[]; + label: string; + }): string { + const ctx = loggerContext.getStore(); + if (!ctx) return generateId("step"); + + SessionFileLogger.ensureTaskContext(ctx); + ctx.stepId = generateId("step"); + ctx.stepLabel = label.toUpperCase(); + ctx.actionId = null; + ctx.actionLabel = null; + + const call = `${invocation}(${formatArgs(args)})`; + const message = `${SessionFileLogger.buildPrefix(ctx, { + includeTask: true, + includeStep: true, + includeAction: false, + })} ${call}`; + + SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); + + return ctx.stepId; } - const message = `${buildPrefix({ - includeTask: true, - includeStep: true, - includeAction: true, - })} ${details.join(" ")}`; + static logActionProgress({ + actionType, + target, + args, + }: { + actionType: string; + target?: string; + args?: unknown | unknown[]; + }): string { + const ctx = loggerContext.getStore(); + if (!ctx) return generateId("action"); + + SessionFileLogger.ensureTaskContext(ctx); + SessionFileLogger.ensureStepContext(ctx); + ctx.actionId = generateId("action"); + ctx.actionLabel = actionType.toUpperCase(); + + const details: string[] = [actionType]; + if (target) { + details.push(`target=${target}`); + } + const argString = formatArgs(args); + if (argString) { + details.push(`args=[${argString}]`); + } - // Write to file if available - if (sessionFileLogger) { - sessionFileLogger.writeUnderstudyLog(message); - } + const message = `${SessionFileLogger.buildPrefix(ctx, { + includeTask: true, + includeStep: true, + includeAction: true, + })} ${details.join(" ")}`; - // Also log via v3Logger for backwards compatibility - v3Logger({ - category: "flow", - message, - level: 2, - }); - return currentActionId; -} + SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); -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; - - // Write to file if available - if (sessionFileLogger) { - sessionFileLogger.writeCdpLog(message); + return ctx.actionId; } - // Also log via v3Logger for backwards compatibility - v3Logger({ - category: "flow", - message, - level: 2, - }); -} - -export function clearFlowContext(): void { - currentTaskId = null; - currentStepId = null; - currentActionId = null; - currentStepLabel = null; - currentActionLabel = null; + static logCdpMessage({ + method, + params, + sessionId, + }: { + method: string; + params?: object; + sessionId?: string | null; + }): void { + const ctx = loggerContext.getStore(); + if (!ctx) return; + + const args = params ? formatArgs(params) : ""; + const call = args ? `${method}(${args})` : `${method}()`; + const prefix = SessionFileLogger.buildPrefix(ctx, { + includeTask: true, + includeStep: true, + includeAction: true, + }); + const rawMessage = `${prefix} ${formatCdpTag(sessionId)} ${call}`; + const message = + rawMessage.length > 120 ? `${rawMessage.slice(0, 117)}...` : rawMessage; + + SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); + } } diff --git a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts index 11a2eeb9a..615ff1379 100644 --- a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts +++ b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts @@ -5,7 +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 { SessionFileLogger } from "../../flowLogger"; import { StagehandClickError } from "../../types/public/sdkErrors"; export class UnderstudyCommandException extends Error { @@ -74,7 +74,7 @@ export async function performUnderstudyMethod( domSettleTimeoutMs, }; - logActionProgress({ + SessionFileLogger.logActionProgress({ actionType: method, target: selectorRaw, args: Array.from(args), diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index e0b69d942..daa5b3504 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -13,7 +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"; +import { SessionFileLogger } from "../flowLogger"; export class V3CuaAgentHandler { private v3: V3; @@ -173,7 +173,7 @@ export class V3CuaAgentHandler { : typeof action.description === "string" ? action.description : undefined; - logActionProgress({ + SessionFileLogger.logActionProgress({ actionType: action.type, target: pointerTarget, args: [action], diff --git a/packages/core/lib/v3/sessionFileLogger.ts b/packages/core/lib/v3/sessionFileLogger.ts deleted file mode 100644 index 1cde8b4d1..000000000 --- a/packages/core/lib/v3/sessionFileLogger.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { V3Options } from "./types/public"; - -interface SessionFileLoggerConfig { - sessionId: string; - configDir: string; - v3Options?: V3Options; -} - -interface LogFile { - path: string; - stream: fs.WriteStream | null; -} - -/** - * SessionFileLogger manages writing logs to session-specific files. - * All filesystem operations are async and fail silently to avoid blocking execution. - */ -export class SessionFileLogger { - private sessionId: string; - private sessionDir: string; - private configDir: string; - private logFiles: { - agent: LogFile; - stagehand: LogFile; - understudy: LogFile; - cdp: LogFile; - }; - private initialized = false; - - constructor(config: SessionFileLoggerConfig) { - this.sessionId = config.sessionId; - this.configDir = config.configDir; - this.sessionDir = path.join( - this.configDir, - "sessions", - this.sessionId, - ); - - // Initialize log file paths (but don't create streams yet) - this.logFiles = { - agent: { path: path.join(this.sessionDir, "agent_events.log"), stream: null }, - stagehand: { path: path.join(this.sessionDir, "stagehand_events.log"), stream: null }, - understudy: { path: path.join(this.sessionDir, "understudy_events.log"), stream: null }, - cdp: { path: path.join(this.sessionDir, "cdp_events.log"), stream: null }, - }; - - // Initialize asynchronously (non-blocking) - this.initAsync(config.v3Options).catch(() => { - // Fail silently - }); - } - - private async initAsync(v3Options?: V3Options): Promise { - try { - // Create session directory - await fs.promises.mkdir(this.sessionDir, { recursive: true }); - - // Create session.json with sanitized options - if (v3Options) { - const sanitizedOptions = this.sanitizeOptions(v3Options); - const sessionJsonPath = path.join(this.sessionDir, "session.json"); - await fs.promises.writeFile( - sessionJsonPath, - JSON.stringify(sanitizedOptions, null, 2), - "utf-8", - ); - } - - // Create symlink to latest session - const latestLink = path.join(this.configDir, "sessions", "latest"); - try { - // Remove existing symlink if it exists - try { - await fs.promises.unlink(latestLink); - } catch { - // Ignore if doesn't exist - } - // Create new symlink (relative path for portability) - await fs.promises.symlink(this.sessionId, latestLink, "dir"); - } catch { - // Symlink creation can fail on Windows or due to permissions - // Fail silently - } - - // Create write streams for log files - for (const [, logFile] of Object.entries(this.logFiles)) { - try { - logFile.stream = fs.createWriteStream(logFile.path, { flags: "a" }); - // Don't wait for drain events - let Node.js buffer handle it - } catch { - // Fail silently if stream creation fails - } - } - - this.initialized = true; - } catch { - // Fail silently - logging should never crash the application - } - } - - /** - * Sanitize V3Options by replacing sensitive values with ****** - */ - private sanitizeOptions(options: V3Options): Record { - const sanitized: Record = { ...options }; - - // List of keys that may contain sensitive data - const sensitiveKeys = [ - "apiKey", - "api_key", - "apikey", - "key", - "secret", - "token", - "password", - "passwd", - "pwd", - "credential", - "credentials", - "auth", - "authorization", - ]; - - const sanitizeValue = (obj: unknown): unknown => { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(sanitizeValue); - } - - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const lowerKey = key.toLowerCase(); - if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { - result[key] = "******"; - } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeValue(value); - } else { - result[key] = value; - } - } - return result; - }; - - return sanitizeValue(sanitized) as Record; - } - - /** - * Write a log line to the agent events log file - */ - writeAgentLog(message: string): void { - this.writeToFile(this.logFiles.agent, message); - } - - /** - * Write a log line to the stagehand events log file - */ - writeStagehandLog(message: string): void { - this.writeToFile(this.logFiles.stagehand, message); - } - - /** - * Write a log line to the understudy events log file - */ - writeUnderstudyLog(message: string): void { - this.writeToFile(this.logFiles.understudy, message); - } - - /** - * Write a log line to the CDP events log file - */ - writeCdpLog(message: string): void { - this.writeToFile(this.logFiles.cdp, message); - } - - /** - * Write to a log file asynchronously (non-blocking) - */ - private writeToFile(logFile: LogFile, message: string): void { - if (!this.initialized || !logFile.stream) { - return; // Silently skip if not initialized - } - - try { - // Non-blocking write - don't await or check for drain - // Node.js will buffer and handle backpressure internally - logFile.stream.write(message + "\n", (err) => { - if (err) { - // Fail silently - logging errors should not crash the app - } - }); - } catch { - // Fail silently - } - } - - /** - * Close all log streams (call on shutdown) - */ - async close(): Promise { - const closePromises: Promise[] = []; - - for (const [, logFile] of Object.entries(this.logFiles)) { - if (logFile.stream) { - closePromises.push( - new Promise((resolve) => { - logFile.stream!.end(() => { - logFile.stream = null; - resolve(); - }); - }), - ); - } - } - - try { - await Promise.all(closePromises); - } catch { - // Fail silently - } - } - - /** - * Get the session directory path - */ - getSessionDir(): string { - return this.sessionDir; - } - - /** - * Get the session ID - */ - getSessionId(): string { - return this.sessionId; - } -} - -/** - * Get the config directory from environment or use default - */ -export function getConfigDir(): string { - const fromEnv = process.env.BROWSERBASE_CONFIG_DIR; - if (fromEnv) { - return path.resolve(fromEnv); - } - // Default to .browserbase in current working directory - return path.resolve(process.cwd(), ".browserbase"); -} - -/** - * Create a session file logger instance - */ -export function createSessionFileLogger( - sessionId: string, - v3Options?: V3Options, -): SessionFileLogger { - const configDir = getConfigDir(); - return new SessionFileLogger({ - sessionId, - configDir, - v3Options, - }); -} diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index e3655cc14..a522ede0a 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -1,7 +1,7 @@ // lib/v3/understudy/cdp.ts import WebSocket from "ws"; import type { Protocol } from "devtools-protocol"; -import { logCdpMessage } from "../flowLogger"; +import { SessionFileLogger } from "../flowLogger"; /** * CDP transport & session multiplexer @@ -119,7 +119,7 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); - logCdpMessage({ method, params, sessionId: null }); + SessionFileLogger.logCdpMessage({ method, params, sessionId: null }); this.ws.send(JSON.stringify(payload)); return p; } @@ -234,7 +234,7 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); - logCdpMessage({ method, params, sessionId }); + SessionFileLogger.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 ff0eaa683..33cb6689a 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -183,7 +183,6 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; - private sessionFileLogger: SessionFileLogger | null = null; public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -322,8 +321,7 @@ export class V3 { this.opts = opts; // Initialize session file logger - this.sessionFileLogger = createSessionFileLogger(this.instanceId, opts); - setSessionFileLogger(this.sessionFileLogger); + SessionFileLogger.init(this.instanceId, opts); // Track instance for global process guard handling V3._instances.add(this); @@ -981,7 +979,7 @@ export class V3 { async act(input: string | Action, options?: ActOptions): Promise { return await withInstanceLogContext(this.instanceId, async () => { - logStepProgress({ + SessionFileLogger.logStepProgress({ invocation: "stagehand.act", args: [input, options], label: "ACT", @@ -1142,7 +1140,7 @@ export class V3 { c?: ExtractOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - logStepProgress({ + SessionFileLogger.logStepProgress({ invocation: "stagehand.extract", args: [a, b, c], label: "EXTRACT", @@ -1226,7 +1224,7 @@ export class V3 { b?: ObserveOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - logStepProgress({ + SessionFileLogger.logStepProgress({ invocation: "stagehand.observe", args: [a, b], label: "OBSERVE", @@ -1305,14 +1303,10 @@ export class V3 { try { // Close session file logger - if (this.sessionFileLogger) { - try { - await this.sessionFileLogger.close(); - setSessionFileLogger(null); - this.sessionFileLogger = null; - } catch { - // Fail silently - } + try { + await SessionFileLogger.close(); + } catch { + // Fail silently } // Unhook CDP transport close handler if context exists @@ -1691,7 +1685,7 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { - logTaskProgress({ + SessionFileLogger.logTaskProgress({ invocation: "agent.execute", args: [instructionOrOptions], }); @@ -1797,7 +1791,7 @@ export class V3 { | AgentStreamExecuteOptions, ): Promise => withInstanceLogContext(this.instanceId, async () => { - logTaskProgress({ + SessionFileLogger.logTaskProgress({ invocation: "agent.execute", args: [instructionOrOptions], }); From 6b632cc695e2d0d3a420c20583ab10373f2e0c09 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 4 Dec 2025 14:24:39 -0800 Subject: [PATCH 07/32] improve flow logging timestamp prefixes --- packages/core/examples/flowLoggingJourney.ts | 60 ++++++++++++++++++++ packages/core/lib/v3/flowLogger.ts | 54 +++++++++++------- 2 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 packages/core/examples/flowLoggingJourney.ts diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts new file mode 100644 index 000000000..89d80c4ed --- /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( + "Find any links on the page", + ); + console.log("Observe result:", observations); + + if (observations.length > 0) { + await stagehand.act(observations[0]); + } else { + await stagehand.act("click the link on the page"); + } + + 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 index 1a9bb087e..f409da839 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -100,7 +100,7 @@ function formatTag(label: string, id: string | null): string { } function formatCdpTag(sessionId?: string | null): string { - if (!sessionId) return "[CDP]"; + if (!sessionId) return "[CDP #????]"; return `[CDP #${shortId(sessionId).toUpperCase()}]`; } @@ -109,6 +109,17 @@ function shortId(id: string | null): string { return id.slice(-4); } +function formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`; +} + function sanitizeOptions(options: V3Options): Record { const sensitiveKeys = [ "apiKey", @@ -309,7 +320,7 @@ export class SessionFileLogger { private static ensureTaskContext(ctx: FlowLoggerContext): void { if (!ctx.taskId) { - ctx.taskId = generateId("task"); + ctx.taskId = generateId("sesh"); } } @@ -355,7 +366,7 @@ export class SessionFileLogger { const parts: string[] = []; if (includeTask) { SessionFileLogger.ensureTaskContext(ctx); - parts.push(formatTag("TASK", ctx.taskId)); + parts.push(formatTag("SESH", ctx.taskId)); } if (includeStep) { SessionFileLogger.ensureStepContext(ctx); @@ -365,6 +376,7 @@ export class SessionFileLogger { SessionFileLogger.ensureActionContext(ctx); parts.push(formatTag(ctx.actionLabel ?? "ACTION", ctx.actionId)); } + parts[parts.length - 1] = parts[parts.length - 1].replace("[", "<").replace("]", ">"); return parts.join(" "); } @@ -381,7 +393,7 @@ export class SessionFileLogger { // --- Logging methods --- - static logTaskProgress({ + static logTaskProgress({ // log agent/session-level events like: Start, End, Execute invocation, args, }: { @@ -389,27 +401,28 @@ export class SessionFileLogger { args?: unknown | unknown[]; }): string { const ctx = loggerContext.getStore(); - if (!ctx) return generateId("task"); + if (!ctx) return generateId("sesh"); - ctx.taskId = generateId("task"); + ctx.taskId = generateId("sesh"); ctx.stepId = null; ctx.actionId = null; ctx.stepLabel = null; ctx.actionLabel = null; const call = `${invocation}(${formatArgs(args)})`; - const message = `${SessionFileLogger.buildPrefix(ctx, { + const prefix = SessionFileLogger.buildPrefix(ctx, { includeTask: true, includeStep: false, includeAction: false, - })} ${call}`; + }); + const message = `${formatTimestamp()} ${prefix} ${call}`; SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); return ctx.taskId; } - static logStepProgress({ + static logStepProgress({ // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate invocation, args, label, @@ -428,18 +441,19 @@ export class SessionFileLogger { ctx.actionLabel = null; const call = `${invocation}(${formatArgs(args)})`; - const message = `${SessionFileLogger.buildPrefix(ctx, { + const prefix = SessionFileLogger.buildPrefix(ctx, { includeTask: true, includeStep: true, includeAction: false, - })} ${call}`; + }); + const message = `${formatTimestamp()} ${prefix} ${call}`; SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); return ctx.stepId; } - static logActionProgress({ + static logActionProgress({ // log understudy-level browser action calls like: Click, Type, Scroll actionType, target, args, @@ -465,18 +479,19 @@ export class SessionFileLogger { details.push(`args=[${argString}]`); } - const message = `${SessionFileLogger.buildPrefix(ctx, { + const prefix = SessionFileLogger.buildPrefix(ctx, { includeTask: true, includeStep: true, includeAction: true, - })} ${details.join(" ")}`; + }); + const message = `${formatTimestamp()} ${prefix} ${details.join(" ")}`; SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); return ctx.actionId; } - static logCdpMessage({ + static logCdpMessage({ // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. method, params, sessionId, @@ -488,16 +503,17 @@ export class SessionFileLogger { const ctx = loggerContext.getStore(); if (!ctx) return; - const args = params ? formatArgs(params) : ""; - const call = args ? `${method}(${args})` : `${method}()`; + const argsStr = params ? formatArgs(params) : ""; + const call = argsStr ? `${method}(${argsStr})` : `${method}()`; const prefix = SessionFileLogger.buildPrefix(ctx, { includeTask: true, includeStep: true, includeAction: true, }); - const rawMessage = `${prefix} ${formatCdpTag(sessionId)} ${call}`; + const timestamp = formatTimestamp(); + const rawMessage = `${timestamp} ${prefix} ${formatCdpTag(sessionId)} ${call}`; const message = - rawMessage.length > 120 ? `${rawMessage.slice(0, 117)}...` : rawMessage; + rawMessage.length > 140 ? `${rawMessage.slice(0, 137)}...` : rawMessage; SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); } From aa021577697e381164f00c119c6a43789005f12d Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 4 Dec 2025 18:38:44 -0800 Subject: [PATCH 08/32] more accurate flow logging context var tracking --- packages/core/examples/flowLoggingJourney.ts | 2 +- packages/core/lib/v3/flowLogger.ts | 615 ++++++++++++++---- .../handlers/handlerUtils/actHandlerUtils.ts | 6 +- .../core/lib/v3/handlers/v3AgentHandler.ts | 2 + .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 8 +- packages/core/lib/v3/llm/aisdk.ts | 53 ++ packages/core/lib/v3/understudy/cdp.ts | 27 +- packages/core/lib/v3/understudy/page.ts | 57 ++ packages/core/lib/v3/v3.ts | 448 +++++++------ 9 files changed, 875 insertions(+), 343 deletions(-) diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts index 89d80c4ed..4f1926a10 100644 --- a/packages/core/examples/flowLoggingJourney.ts +++ b/packages/core/examples/flowLoggingJourney.ts @@ -46,7 +46,7 @@ async function run(): Promise { } const extraction = await stagehand.extract( - "Summarize the current page title and URL.", + "Summarize the current page title and contents in a single sentence", ); console.log("Extraction result:", extraction); } finally { diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index f409da839..1291c8601 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -11,7 +11,7 @@ interface LogFile { stream: fs.WriteStream | null; } -interface FlowLoggerContext { +export interface FlowLoggerContext { sessionId: string; sessionDir: string; configDir: string; @@ -20,15 +20,23 @@ interface FlowLoggerContext { stagehand: LogFile; understudy: LogFile; cdp: LogFile; + llm: LogFile; }; initPromise: Promise; initialized: boolean; // Flow context - taskId: string | null; - stepId: string | null; - actionId: string | null; - stepLabel: string | null; - actionLabel: string | null; + agentTaskId: string | null; + stagehandStepId: string | null; + understudyActionId: string | null; + stagehandStepLabel: string | null; + understudyActionLabel: string | null; + stagehandStepStartTime: number | null; + // Task metrics + agentTaskStartTime: number | null; + agentTaskLlmRequests: number; + agentTaskCdpEvents: number; + agentTaskLlmInputTokens: number; + agentTaskLlmOutputTokens: number; } const loggerContext = new AsyncLocalStorage(); @@ -45,12 +53,27 @@ function generateId(label: string): string { } function truncate(value: string): string { + value = value.replace(/\s+/g, " "); // replace newlines, tabs, etc. with space + value = value.replace(/\s+/g, " "); // replace repeated spaces with single space if (value.length <= MAX_ARG_LENGTH) { return value; } return `${value.slice(0, MAX_ARG_LENGTH)}…`; } +/** + * Truncate conversation/prompt strings showing first 30 chars + ... + last 100 chars + * This helps see both the beginning context and the most recent part of growing conversations + */ +function truncateConversation(value: string): string { + value = value.replace(/\s+/g, " "); // normalize whitespace + const maxLen = 130; // 30 + 100 + if (value.length <= maxLen) { + return value; + } + return `${value.slice(0, 30)}…${value.slice(-100)}`; +} + function formatValue(value: unknown): string { if (typeof value === "string") { return `'${value}'`; @@ -95,13 +118,10 @@ function formatArgs(args?: unknown | unknown[]): string { 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 formatTag(label: string, id: string | null, icon: string | null): string { + if (!id) return `⤑`; // omit the part if the id is null, we're not in an active task/step/action + // return `[${label} ${icon ? icon : ""} #${shortId(id)}]`; + return `[${icon || ''} #${shortId(id)}${label ? " " : ""}${label || ""}]`; } function shortId(id: string | null): string { @@ -109,6 +129,8 @@ function shortId(id: string | null): string { return id.slice(-4); } +let nonce = 0; + function formatTimestamp(): string { const now = new Date(); const year = now.getFullYear(); @@ -117,7 +139,9 @@ function formatTimestamp(): string { const hours = String(now.getHours()).padStart(2, "0"); const minutes = String(now.getMinutes()).padStart(2, "0"); const seconds = String(now.getSeconds()).padStart(2, "0"); - return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`; + const milliseconds = String(now.getMilliseconds()).padStart(3, "0"); + const monotonic = String(nonce++ % 100).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${monotonic}`; } function sanitizeOptions(options: V3Options): Record { @@ -194,14 +218,24 @@ export class SessionFileLogger { stagehand: { path: path.join(sessionDir, "stagehand_events.log"), stream: null }, understudy: { path: path.join(sessionDir, "understudy_events.log"), stream: null }, cdp: { path: path.join(sessionDir, "cdp_events.log"), stream: null }, + llm: { path: path.join(sessionDir, "llm_events.log"), stream: null }, }, initPromise: Promise.resolve(), initialized: false, - taskId: null, - stepId: null, - actionId: null, - stepLabel: null, - actionLabel: null, + // sessionId is set once at init and never changes + // taskId is null until agent.execute starts + agentTaskId: null, + stagehandStepId: null, + stagehandStepLabel: null, + understudyActionId: null, + understudyActionLabel: null, + stagehandStepStartTime: null, + // Task metrics - null until a task starts + agentTaskStartTime: null, + agentTaskLlmRequests: 0, + agentTaskCdpEvents: 0, + agentTaskLlmInputTokens: 0, + agentTaskLlmOutputTokens: 0, }; // Store init promise for awaiting in writeToFile @@ -305,7 +339,7 @@ export class SessionFileLogger { // Fail silently } - SessionFileLogger.clearFlowContext(); + SessionFileLogger.logAgentTaskCompleted(); } static get sessionId(): string | null { @@ -316,113 +350,165 @@ export class SessionFileLogger { return loggerContext.getStore()?.sessionDir ?? null; } + /** + * Get the current logger context object. This can be captured and passed + * to callbacks that run outside the AsyncLocalStorage context (like WebSocket handlers). + * Updates to the context (taskId, stepId, etc.) will be visible through this reference. + */ + static getContext(): FlowLoggerContext | null { + return loggerContext.getStore() ?? null; + } + // --- Flow context methods --- - private static ensureTaskContext(ctx: FlowLoggerContext): void { - if (!ctx.taskId) { - ctx.taskId = generateId("sesh"); + private static ensureAgentTaskContext(ctx: FlowLoggerContext): void { + if (!ctx.agentTaskId) { + ctx.agentTaskId = generateId("task"); } } - private static ensureStepContext( + private static ensureStagehandStepContext( ctx: FlowLoggerContext, defaultLabel?: string, ): void { if (defaultLabel) { - ctx.stepLabel = defaultLabel.toUpperCase(); + ctx.stagehandStepLabel = defaultLabel.toUpperCase(); } - if (!ctx.stepLabel) { - ctx.stepLabel = "STEP"; + if (!ctx.stagehandStepLabel) { + ctx.stagehandStepLabel = "STEP"; } - if (!ctx.stepId) { - ctx.stepId = generateId("step"); + if (!ctx.stagehandStepId) { + ctx.stagehandStepId = generateId("step"); } } - private static ensureActionContext( + private static ensureUnderstudyActionContext( ctx: FlowLoggerContext, defaultLabel?: string, ): void { if (defaultLabel) { - ctx.actionLabel = defaultLabel.toUpperCase(); + ctx.understudyActionLabel = defaultLabel.toUpperCase(); } - if (!ctx.actionLabel) { - ctx.actionLabel = "ACTION"; + if (!ctx.understudyActionLabel) { + ctx.understudyActionLabel = "ACTION"; } - if (!ctx.actionId) { - ctx.actionId = generateId("action"); + if (!ctx.understudyActionId) { + ctx.understudyActionId = generateId("action"); } } - private static buildPrefix( + private static buildLogLine( ctx: FlowLoggerContext, options: { - includeAction?: boolean; - includeStep?: boolean; includeTask?: boolean; - } = {}, + includeStep?: boolean; + includeAction?: boolean; + }, + details: string, ): string { const { includeAction = true, includeStep = true, includeTask = true } = options; const parts: string[] = []; if (includeTask) { - SessionFileLogger.ensureTaskContext(ctx); - parts.push(formatTag("SESH", ctx.taskId)); + parts.push(formatTag("", ctx.agentTaskId, 'šŸ…°')); } if (includeStep) { - SessionFileLogger.ensureStepContext(ctx); - parts.push(formatTag(ctx.stepLabel ?? "STEP", ctx.stepId)); + parts.push(formatTag(ctx.stagehandStepLabel, ctx.stagehandStepId, 'šŸ†‚')); } if (includeAction) { - SessionFileLogger.ensureActionContext(ctx); - parts.push(formatTag(ctx.actionLabel ?? "ACTION", ctx.actionId)); + parts.push(formatTag(ctx.understudyActionLabel, ctx.understudyActionId, 'šŸ†„')); } - parts[parts.length - 1] = parts[parts.length - 1].replace("[", "<").replace("]", ">"); - return parts.join(" "); + // parts[parts.length - 1] = parts[parts.length - 1].replace("[", "⟦").replace("]", "⟧"); // try and higlight the last tag so it stands out visually (imperfect) + return `${formatTimestamp()} ${parts.join(" ")} ${details}`; } - static clearFlowContext(): void { + /** + * Start a new task. Call this when agent.execute() begins. + * Sets taskId to a new UUID and resets metrics. + */ + static logAgentTaskStarted(): void { const ctx = loggerContext.getStore(); if (ctx) { - ctx.taskId = null; - ctx.stepId = null; - ctx.actionId = null; - ctx.stepLabel = null; - ctx.actionLabel = null; + ctx.agentTaskId = generateId("task"); + ctx.stagehandStepId = null; + ctx.stagehandStepLabel = null; + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; + ctx.agentTaskStartTime = Date.now(); + ctx.agentTaskLlmRequests = 0; + ctx.agentTaskCdpEvents = 0; + ctx.agentTaskLlmInputTokens = 0; + ctx.agentTaskLlmOutputTokens = 0; + } + } + + /** + * Log task completion with metrics summary. Call this after agent.execute() completes. + * Sets taskId back to null. + */ + static logAgentTaskCompleted(): void { + const ctx = loggerContext.getStore(); + if (ctx && ctx.agentTaskStartTime) { + const durationMs = Date.now() - ctx.agentTaskStartTime; + const durationSec = (durationMs / 1000).toFixed(1); + + const details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${ctx.agentTaskLlmRequests} LLM calls źœ›${ctx.agentTaskLlmInputTokens} ꜜ${ctx.agentTaskLlmOutputTokens} tokens | ${ctx.agentTaskCdpEvents} CDP msgs`; + + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: false, includeAction: false }, + details, + ); + SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); + + // Clear task context - no active task + ctx.agentTaskId = null; + ctx.stagehandStepId = null; + ctx.understudyActionId = null; + ctx.stagehandStepLabel = null; + ctx.understudyActionLabel = null; + ctx.agentTaskStartTime = null; + } + } + + static clearStagehandStepContext(): void { + const ctx = loggerContext.getStore(); + if (ctx) { + ctx.stagehandStepId = null; + ctx.stagehandStepLabel = null; + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; + } + } + + static clearUnderstudyActionContext(): void { + const ctx = loggerContext.getStore(); + if (ctx) { + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; } } // --- Logging methods --- - static logTaskProgress({ // log agent/session-level events like: Start, End, Execute + static logAgentTaskEvent({ // log agent/session-level events like: Start, End, Execute invocation, args, }: { invocation: string; args?: unknown | unknown[]; - }): string { + }): void { const ctx = loggerContext.getStore(); - if (!ctx) return generateId("sesh"); - - ctx.taskId = generateId("sesh"); - ctx.stepId = null; - ctx.actionId = null; - ctx.stepLabel = null; - ctx.actionLabel = null; - - const call = `${invocation}(${formatArgs(args)})`; - const prefix = SessionFileLogger.buildPrefix(ctx, { - includeTask: true, - includeStep: false, - includeAction: false, - }); - const message = `${formatTimestamp()} ${prefix} ${call}`; + if (!ctx) return; + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: false, includeAction: false }, + `ā–· ${invocation}(${formatArgs(args)})`, + ); SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); - - return ctx.taskId; } - static logStepProgress({ // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate + static logStagehandStepEvent({ // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate invocation, args, label, @@ -434,26 +520,49 @@ export class SessionFileLogger { const ctx = loggerContext.getStore(); if (!ctx) return generateId("step"); - SessionFileLogger.ensureTaskContext(ctx); - ctx.stepId = generateId("step"); - ctx.stepLabel = label.toUpperCase(); - ctx.actionId = null; - ctx.actionLabel = null; + SessionFileLogger.ensureAgentTaskContext(ctx); + ctx.stagehandStepId = generateId("step"); + ctx.stagehandStepLabel = label.toUpperCase(); + ctx.stagehandStepStartTime = Date.now(); + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; + + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: false }, + `${invocation}(${formatArgs(args)})`, + ); + SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); - const call = `${invocation}(${formatArgs(args)})`; - const prefix = SessionFileLogger.buildPrefix(ctx, { - includeTask: true, - includeStep: true, - includeAction: false, - }); - const message = `${formatTimestamp()} ${prefix} ${call}`; + return ctx.stagehandStepId; + } + static logStagehandStepCompleted(): void { + const ctx = loggerContext.getStore(); + if (!ctx || !ctx.stagehandStepId) return; + + const durationMs = ctx.stagehandStepStartTime + ? Date.now() - ctx.stagehandStepStartTime + : 0; + const durationSec = (durationMs / 1000).toFixed(2); + const label = ctx.stagehandStepLabel || "STEP"; + + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: false }, + `āœ“ ${label} completed in ${durationSec}s`, + ); SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); - return ctx.stepId; + // Clear step context + ctx.stagehandStepId = null; + ctx.stagehandStepLabel = null; + ctx.stagehandStepStartTime = null; + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; } - static logActionProgress({ // log understudy-level browser action calls like: Click, Type, Scroll + static logUnderstudyActionEvent({ // log understudy-level browser action calls like: Click, Type, Scroll actionType, target, args, @@ -465,56 +574,312 @@ export class SessionFileLogger { const ctx = loggerContext.getStore(); if (!ctx) return generateId("action"); - SessionFileLogger.ensureTaskContext(ctx); - SessionFileLogger.ensureStepContext(ctx); - ctx.actionId = generateId("action"); - ctx.actionLabel = actionType.toUpperCase(); + // THESE ARE NOT NEEDED, it's possible for understudy methods to be called directly without going through stagehand.act/observe/extract or agent.execute + // SessionFileLogger.ensureAgentTaskContext(ctx); + // SessionFileLogger.ensureStagehandStepContext(ctx); - const details: string[] = [actionType]; - if (target) { - details.push(`target=${target}`); - } - const argString = formatArgs(args); - if (argString) { - details.push(`args=[${argString}]`); - } + ctx.understudyActionId = generateId("action"); + ctx.understudyActionLabel = actionType.toUpperCase().replace("UNDERSTUDY.", ""); - const prefix = SessionFileLogger.buildPrefix(ctx, { - includeTask: true, - includeStep: true, - includeAction: true, - }); - const message = `${formatTimestamp()} ${prefix} ${details.join(" ")}`; + const details: string[] = []; + if (target) details.push(`target=${target}`); + const argString = formatArgs(args); + if (argString) details.push(`args=[${argString}]`); + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: true }, + `${actionType}(${details.join(", ")})`, + ); SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); - return ctx.actionId; + return ctx.understudyActionId; } - static logCdpMessage({ // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. - method, - params, - sessionId, - }: { - method: string; - params?: object; - sessionId?: string | null; - }): void { - const ctx = loggerContext.getStore(); + static logCdpMessageEvent( + { // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. + method, + params, + targetId, + }: { + method: string; + params?: object; + targetId?: string | null; + }, + explicitCtx?: FlowLoggerContext | null, + ): void { + const ctx = explicitCtx ?? loggerContext.getStore(); if (!ctx) return; + // Track CDP events for task metrics + ctx.agentTaskCdpEvents++; + const argsStr = params ? formatArgs(params) : ""; const call = argsStr ? `${method}(${argsStr})` : `${method}()`; - const prefix = SessionFileLogger.buildPrefix(ctx, { - includeTask: true, - includeStep: true, - includeAction: true, - }); - const timestamp = formatTimestamp(); - const rawMessage = `${timestamp} ${prefix} ${formatCdpTag(sessionId)} ${call}`; - const message = - rawMessage.length > 140 ? `${rawMessage.slice(0, 137)}...` : rawMessage; + const details = `${formatTag("CDP", targetId || "0000", 'šŸ…²')} ${call}`; + + const rawMessage = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: true }, + details, + ); + const message = rawMessage.length > 140 ? `${rawMessage.slice(0, 137)}…` : rawMessage; SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); } + + static logLlmRequest( + { // log outgoing LLM API requests + requestId, + model, + operation, + prompt, + }: { + requestId: string; + model: string; + operation: string; + prompt?: string; + }, + explicitCtx?: FlowLoggerContext | null, + ): void { + const ctx = explicitCtx ?? loggerContext.getStore(); + if (!ctx) return; + + // Track LLM requests for task metrics + ctx.agentTaskLlmRequests++; + + const promptStr = prompt ? ` ${truncateConversation(prompt)}` : ""; + const details = `${formatTag("LLM", requestId, '🧠')} ${model} šŸ’¬${promptStr}`; + + const rawMessage = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: false }, + details, + ); + // Temporarily increased limit for debugging + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; + + SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); + } + + static logLlmResponse( + { // log incoming LLM API responses + requestId, + model, + operation, + output, + inputTokens, + outputTokens, + }: { + requestId: string; + model: string; + operation: string; + output?: string; + inputTokens?: number; + outputTokens?: number; + }, + explicitCtx?: FlowLoggerContext | null, + ): void { + const ctx = explicitCtx ?? loggerContext.getStore(); + if (!ctx) return; + + // Track tokens for task metrics + ctx.agentTaskLlmInputTokens += inputTokens ?? 0; + ctx.agentTaskLlmOutputTokens += outputTokens ?? 0; + + const tokens = inputTokens !== undefined || outputTokens !== undefined + ? ` źœ›${inputTokens ?? 0} ꜜ${outputTokens ?? 0} |` + : ""; + const outputStr = output ? ` ${truncateConversation(output)}` : ""; + const details = `${formatTag("LLM", requestId, '🧠')} ${model} ↳${tokens}${outputStr}`; + + const rawMessage = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: false }, + details, + ); + // Temporarily increased limit for debugging + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; + + SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); + } + + static generateLlmRequestId(): string { + return generateId("llm"); + } + + /** + * Create middleware for wrapping language models with LLM call logging. + * Use with wrapLanguageModel from the AI SDK. + */ + static createLlmLoggingMiddleware(modelId: string): { + wrapGenerate: (options: { + doGenerate: () => Promise<{ + text?: string; + toolCalls?: unknown[]; + usage?: { inputTokens?: number; outputTokens?: number }; + }>; + params: { prompt?: Array<{ role: string; content?: unknown[] }> }; + }) => Promise<{ + text?: string; + toolCalls?: unknown[]; + usage?: { inputTokens?: number; outputTokens?: number }; + }>; + } { + return { + wrapGenerate: async ({ doGenerate, params }) => { + // Capture context at the start of the call to preserve step/action context + const ctx = SessionFileLogger.getContext(); + + const llmRequestId = SessionFileLogger.generateLlmRequestId(); + + const p = params as { prompt?: unknown[]; tools?: unknown[]; schema?: unknown }; + + // Count tools + const toolCount = Array.isArray(p.tools) ? p.tools.length : 0; + + // Check for images in any message + const hasImage = p.prompt?.some((m: unknown) => { + const msg = m as { content?: unknown[] }; + if (!Array.isArray(msg.content)) return false; + return msg.content.some((c: unknown) => { + const part = c as { type?: string }; + return part.type === "image"; + }); + }) ?? false; + + // Check for schema (structured output) + const hasSchema = !!p.schema; + + // Find the last non-system message to show the newest content (tool result, etc.) + const nonSystemMessages = (p.prompt ?? []).filter((m: unknown) => { + const msg = m as { role?: string }; + return msg.role !== "system"; + }); + const lastMsg = nonSystemMessages[nonSystemMessages.length - 1] as Record | undefined; + const lastRole = (lastMsg?.role as string) ?? "?"; + + // Extract content from last message - handle various formats + let lastContent = ""; + let toolName = ""; + + if (lastMsg) { + // Check for tool result format: content → [{type: "tool-result", toolName, output: {type, value: [...]}}] + if (lastMsg.content && Array.isArray(lastMsg.content)) { + for (const part of lastMsg.content) { + const item = part as Record; + if (item.type === "tool-result") { + toolName = (item.toolName as string) || ""; + + // output is directly on the tool-result item + const output = item.output as Record | undefined; + + if (output) { + if (output.type === "json" && output.value) { + // JSON result like goto, scroll + lastContent = JSON.stringify(output.value).slice(0, 150); + } else if (Array.isArray(output.value)) { + // Array of content parts (text, images) + const parts: string[] = []; + for (const v of output.value) { + const vItem = v as Record; + if (vItem.type === "text" && vItem.text) { + parts.push(vItem.text as string); + } else if (vItem.mediaType && typeof vItem.data === "string") { + // Image data + const sizeKb = ((vItem.data as string).length * 0.75 / 1024).toFixed(1); + parts.push(`[${sizeKb}kb img]`); + } + } + if (parts.length > 0) { + lastContent = parts.join(" "); + } + } + } + break; + } else if (item.type === "text") { + lastContent += (item.text as string) || ""; + } + } + } else if (typeof lastMsg.content === "string") { + lastContent = lastMsg.content; + } + } + + // Fallback: if still no content, stringify what we have for debugging + if (!lastContent && lastMsg) { + try { + const debugStr = JSON.stringify(lastMsg, (key, value) => { + // Truncate long strings (like base64 images) + if (typeof value === "string" && value.length > 100) { + if (value.startsWith("data:image")) { + const sizeKb = (value.length * 0.75 / 1024).toFixed(1); + return `[${sizeKb}kb image]`; + } + return value.slice(0, 50) + "..."; + } + return value; + }); + lastContent = debugStr.slice(0, 300); + } catch { + lastContent = "(unserializable)"; + } + } + + // Build preview: role + tool name + truncated content + metadata + const rolePrefix = toolName ? `tool result: ${toolName}()` : lastRole; + const contentTruncated = lastContent ? truncateConversation(lastContent) : "(no text)"; + const promptPreview = `${rolePrefix}āž” ${contentTruncated} +{${toolCount} tools}`; + + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: modelId, + operation: "generateText", + prompt: promptPreview, + }, ctx); + + const result = await doGenerate(); + + // Extract output - handle various response formats + let outputPreview = ""; + const res = result as { text?: string; content?: unknown; toolCalls?: unknown[] }; + if (res.text) { + outputPreview = res.text; + } else if (res.content) { + // AI SDK may return content as string or array + if (typeof res.content === "string") { + outputPreview = res.content; + } else if (Array.isArray(res.content)) { + outputPreview = res.content + .map((c: unknown) => { + const item = c as { type?: string; text?: string; toolName?: string }; + if (item.type === "text") return item.text; + if (item.type === "tool-call") return `tool call: ${item.toolName}()`; + return `[${item.type || "unknown"}]`; + }) + .join(" "); + } else { + outputPreview = String(res.content); + } + } else if (res.toolCalls?.length) { + outputPreview = `[${res.toolCalls.length} tool calls]`; + } else if (typeof result === "object" && result !== null) { + // Fallback: try to stringify relevant parts of the result + const keys = Object.keys(result).filter(k => k !== "usage" && k !== "rawResponse"); + outputPreview = keys.length > 0 ? `{${keys.join(", ")}}` : "[empty response]"; + } + + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: modelId, + operation: "generateText", + output: outputPreview, + inputTokens: result.usage?.inputTokens, + outputTokens: result.usage?.outputTokens, + }, ctx); + + return result; + }, + }; + } } diff --git a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts index 615ff1379..ae2a32c5f 100644 --- a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts +++ b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts @@ -74,8 +74,8 @@ export async function performUnderstudyMethod( domSettleTimeoutMs, }; - SessionFileLogger.logActionProgress({ - actionType: method, + SessionFileLogger.logUnderstudyActionEvent({ + actionType: `Understudy.${method}`, target: selectorRaw, args: Array.from(args), }); @@ -127,6 +127,8 @@ export async function performUnderstudyMethod( }, }); throw new UnderstudyCommandException(msg); + } finally { + SessionFileLogger.clearUnderstudyActionContext(); } } diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 6648256bf..37162011f 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -13,6 +13,7 @@ import { } from "ai"; import { processMessages } from "../agent/utils/messageProcessing"; import { LLMClient } from "../llm/LLMClient"; +import { SessionFileLogger } from "../flowLogger"; import { AgentExecuteOptions, AgentStreamExecuteOptions, @@ -86,6 +87,7 @@ export class V3AgentHandler { const { processedPrompt } = processMessages(params); return { ...params, prompt: processedPrompt } as typeof params; }, + ...SessionFileLogger.createLlmLoggingMiddleware(baseModel.modelId), }, }); diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index daa5b3504..d2d471304 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -173,11 +173,12 @@ export class V3CuaAgentHandler { : typeof action.description === "string" ? action.description : undefined; - SessionFileLogger.logActionProgress({ - actionType: action.type, + SessionFileLogger.logUnderstudyActionEvent({ + actionType: `v3CUA.${action.type}`, target: pointerTarget, args: [action], }); + try { switch (action.type) { case "click": { const { x, y, button = "left", clickCount } = action; @@ -438,6 +439,9 @@ export class V3CuaAgentHandler { error: `Unknown action ${String(action.type)}`, }; } + } finally { + SessionFileLogger.clearUnderstudyActionContext(); + } } private ensureXPath(value: unknown): string | null { diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index d437766fc..21d46aa49 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -16,6 +16,7 @@ import { ChatCompletion } from "openai/resources"; import { LogLine } from "../types/public/logs"; import { AvailableModel } from "../types/public/model"; import { CreateChatCompletionOptions, LLMClient } from "./LLMClient"; +import { SessionFileLogger } from "../flowLogger"; export class AISdkClient extends LLMClient { public type = "aisdk" as const; @@ -41,6 +42,7 @@ export class AISdkClient extends LLMClient { async createChatCompletion({ options, }: CreateChatCompletionOptions): Promise { + this.logger?.({ category: "aisdk", message: "creating chat completion", @@ -129,6 +131,21 @@ export class AISdkClient extends LLMClient { const isGPT5 = this.model.modelId.includes("gpt-5"); const isGPT51 = this.model.modelId.includes("gpt-5.1"); if (options.response_model) { + // Log LLM request for generateObject (extract) + const llmRequestId = SessionFileLogger.generateLlmRequestId(); + const lastUserMsg = options.messages.filter(m => m.role === "user").pop(); + const promptPreview = lastUserMsg + ? (typeof lastUserMsg.content === "string" + ? lastUserMsg.content.replace('instruction: ', '').replace('Instruction: ', '') + : lastUserMsg.content.map(c => "text" in c ? c.text : "[img]").join(" ")) + : undefined; + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateObject", + prompt: promptPreview ? `${promptPreview} +schema` : "+schema", + }); + try { objectResponse = await generateObject({ model: this.model, @@ -194,6 +211,16 @@ export class AISdkClient extends LLMClient { }, } as T; + // Log LLM response for generateObject + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateObject", + output: JSON.stringify(objectResponse.object).slice(0, 200), + inputTokens: objectResponse.usage.inputTokens, + outputTokens: objectResponse.usage.outputTokens, + }); + this.logger?.({ category: "aisdk", message: "response", @@ -228,6 +255,22 @@ export class AISdkClient extends LLMClient { } } + // Log LLM request for generateText (act/observe) + const llmRequestId = SessionFileLogger.generateLlmRequestId(); + const lastUserMsg = options.messages.filter(m => m.role === "user").pop(); + const promptPreview = lastUserMsg + ? (typeof lastUserMsg.content === "string" + ? lastUserMsg.content.replace("instruction: ", "") + : lastUserMsg.content.map(c => "text" in c ? c.text : "[img]").join(" ")) + : undefined; + const toolCount = Object.keys(tools).length; + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateText", + prompt: promptPreview ? `${promptPreview}${toolCount > 0 ? ` +{${toolCount} tools}` : ""}` : undefined, + }); + const textResponse = await generateText({ model: this.model, messages: formattedMessages, @@ -282,6 +325,16 @@ export class AISdkClient extends LLMClient { }, } as T; + // Log LLM response for generateText + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateText", + output: textResponse.text || (transformedToolCalls.length > 0 ? `[${transformedToolCalls.length} tool calls]` : ""), + inputTokens: textResponse.usage.inputTokens, + outputTokens: textResponse.usage.outputTokens, + }); + this.logger?.({ category: "aisdk", message: "response", diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index a522ede0a..9d7ba952d 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -1,7 +1,6 @@ // lib/v3/understudy/cdp.ts import WebSocket from "ws"; import type { Protocol } from "devtools-protocol"; -import { SessionFileLogger } from "../flowLogger"; /** * CDP transport & session multiplexer @@ -46,9 +45,18 @@ export class CdpConnection implements CDPSessionLike { private inflight = new Map(); private eventHandlers = new Map>(); private sessions = new Map(); + /** Maps sessionId -> targetId (1:1 mapping) */ + private sessionToTarget = new Map(); public readonly id: string | null = null; // root private transportCloseHandlers = new Set<(why: string) => void>(); + /** Optional CDP logger - set this to receive all CDP method calls */ + public cdpLogger?: (info: { + method: string; + params?: object; + targetId?: string | null; + }) => void; + public onTransportClosed(handler: (why: string) => void): void { this.transportCloseHandlers.add(handler); } @@ -119,7 +127,7 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); - SessionFileLogger.logCdpMessage({ method, params, sessionId: null }); + this.cdpLogger?.({ method, params, targetId: null }); this.ws.send(JSON.stringify(payload)); return p; } @@ -157,6 +165,7 @@ export class CdpConnection implements CDPSessionLike { session = new CdpSession(this, sessionId); this.sessions.set(sessionId, session); } + this.sessionToTarget.set(sessionId, targetId); return session; } @@ -191,6 +200,7 @@ export class CdpConnection implements CDPSessionLike { if (!this.sessions.has(p.sessionId)) { this.sessions.set(p.sessionId, new CdpSession(this, p.sessionId)); } + this.sessionToTarget.set(p.sessionId, p.targetInfo.targetId); } else if (msg.method === "Target.detachedFromTarget") { const p = (msg as { params: Protocol.Target.DetachedFromTargetEvent }) .params; @@ -201,6 +211,16 @@ export class CdpConnection implements CDPSessionLike { } } this.sessions.delete(p.sessionId); + this.sessionToTarget.delete(p.sessionId); + } else if (msg.method === "Target.targetDestroyed") { + const p = (msg as { params: { targetId: string } }).params; + // Remove any session mapping for this target + for (const [sessionId, targetId] of this.sessionToTarget.entries()) { + if (targetId === p.targetId) { + this.sessionToTarget.delete(sessionId); + break; + } + } } const { method, params, sessionId } = msg; @@ -234,7 +254,8 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); - SessionFileLogger.logCdpMessage({ method, params, sessionId }); + const targetId = this.sessionToTarget.get(sessionId) ?? null; + this.cdpLogger?.({ method, params, targetId }); this.ws.send(JSON.stringify(payload)); return p; } diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index f558659a3..5ae5174ce 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -1,6 +1,7 @@ import { Protocol } from "devtools-protocol"; import { promises as fs } from "fs"; import { v3Logger } from "../logger"; +import { SessionFileLogger } from "../flowLogger"; import type { CDPSessionLike } from "./cdp"; import { CdpConnection } from "./cdp"; import { Frame } from "./frame"; @@ -625,6 +626,9 @@ export class Page { * Close this top-level page (tab). Best-effort via Target.closeTarget. */ public async close(): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.close", + }); try { await this.conn.send("Target.closeTarget", { targetId: this._targetId }); } catch { @@ -762,6 +766,11 @@ export class Page { url: string, options?: { waitUntil?: LoadState; timeoutMs?: number }, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.goto", + target: url, + args: options, + }); const waitUntil: LoadState = options?.waitUntil ?? "domcontentloaded"; const timeout = options?.timeoutMs ?? 15000; @@ -825,6 +834,10 @@ export class Page { timeoutMs?: number; ignoreCache?: boolean; }): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.reload", + args: options, + }); const waitUntil = options?.waitUntil; const timeout = options?.timeoutMs ?? 15000; @@ -870,6 +883,10 @@ export class Page { waitUntil?: LoadState; timeoutMs?: number; }): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.goBack", + args: options, + }); const { entries, currentIndex } = await this.mainSession.send( "Page.getNavigationHistory", @@ -922,6 +939,10 @@ export class Page { waitUntil?: LoadState; timeoutMs?: number; }): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.goForward", + args: options, + }); const { entries, currentIndex } = await this.mainSession.send( "Page.getNavigationHistory", @@ -1048,6 +1069,10 @@ export class Page { * @param options.type Image format (`"png"` by default). */ async screenshot(options?: ScreenshotOptions): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.screenshot", + args: options, + }); const opts = options ?? {}; const type = opts.type ?? "png"; @@ -1170,6 +1195,10 @@ export class Page { * Mirrors Playwright's API signatures. */ async waitForLoadState(state: LoadState, timeoutMs?: number): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.waitForLoadState", + args: [state, timeoutMs], + }); await this.waitForMainLoadState(state, timeoutMs ?? 15000); } @@ -1184,6 +1213,10 @@ export class Page { pageFunctionOrExpression: string | ((arg: Arg) => R | Promise), arg?: Arg, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.evaluate", + args: [typeof pageFunctionOrExpression === "string" ? pageFunctionOrExpression : "[function]", arg], + }); await this.mainSession.send("Runtime.enable").catch(() => {}); const ctxId = await this.mainWorldExecutionContextId(); @@ -1240,6 +1273,10 @@ export class Page { height: number, options?: { deviceScaleFactor?: number }, ): Promise { + // SessionFileLogger.logUnderstudyActionEvent({ + // actionType: "Page.setViewportSize", + // args: [width, height, options], + // }); const dsf = Math.max(0.01, options?.deviceScaleFactor ?? 1); await this.mainSession .send("Emulation.setDeviceMetricsOverride", { @@ -1303,6 +1340,10 @@ export class Page { returnXpath?: boolean; }, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.click", + args: [x, y, options], + }); const button = options?.button ?? "left"; const clickCount = options?.clickCount ?? 1; @@ -1397,6 +1438,10 @@ export class Page { deltaY: number, options?: { returnXpath?: boolean }, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.scroll", + args: [x, y, deltaX, deltaY, options], + }); let xpathResult: string | undefined; if (options?.returnXpath) { try { @@ -1480,6 +1525,10 @@ export class Page { returnXpath?: boolean; }, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.dragAndDrop", + args: [fromX, fromY, toX, toY, options], + }); const button = options?.button ?? "left"; const steps = Math.max(1, Math.floor(options?.steps ?? 1)); const delay = Math.max(0, options?.delay ?? 0); @@ -1576,6 +1625,10 @@ export class Page { text: string, options?: { delay?: number; withMistakes?: boolean }, ): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.type", + args: [text, options], + }); const delay = Math.max(0, options?.delay ?? 0); const withMistakes = !!options?.withMistakes; @@ -1670,6 +1723,10 @@ export class Page { * Supports key combinations with modifiers like "Cmd+A", "Ctrl+C", "Shift+Tab", etc. */ async keyPress(key: string, options?: { delay?: number }): Promise { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: "Page.keyPress", + args: [key, options], + }); const delay = Math.max(0, options?.delay ?? 0); const sleep = (ms: number) => new Promise((r) => (ms > 0 ? setTimeout(r, ms) : r())); diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 33cb6689a..970e04595 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -76,6 +76,7 @@ import { logTaskProgress, logStepProgress, setSessionFileLogger, + FlowLoggerContext, } from "./flowLogger"; import { createSessionFileLogger, @@ -701,6 +702,9 @@ export class V3 { this.ctx = await V3Context.create(lbo.cdpUrl, { env: "LOCAL", }); + const logCtx = SessionFileLogger.getContext(); + this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { kind: "LOCAL", @@ -790,6 +794,9 @@ export class V3 { env: "LOCAL", localBrowserLaunchOptions: lbo, }); + const logCtx = SessionFileLogger.getContext(); + this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { kind: "LOCAL", @@ -867,6 +874,9 @@ export class V3 { env: "BROWSERBASE", apiClient: this.apiClient, }); + const logCtx = SessionFileLogger.getContext(); + this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { kind: "BROWSERBASE", sessionId, ws, bb }; this.browserbaseSessionId = sessionId; @@ -979,133 +989,137 @@ export class V3 { async act(input: string | Action, options?: ActOptions): Promise { return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStepProgress({ - invocation: "stagehand.act", + SessionFileLogger.logStagehandStepEvent({ + invocation: "Stagehand.act", args: [input, options], label: "ACT", }); - if (!this.actHandler) throw new StagehandNotInitializedError("act()"); + try { + if (!this.actHandler) throw new StagehandNotInitializedError("act()"); - let actResult: ActResult; + let actResult: ActResult; - if (isObserveResult(input)) { - // Resolve page: use provided page if any, otherwise default active page - const v3Page = await this.resolvePage(options?.page); + if (isObserveResult(input)) { + // Resolve page: use provided page if any, otherwise default active page + const v3Page = await this.resolvePage(options?.page); - // Use selector as provided to support XPath, CSS, and other engines - const selector = input.selector; - if (this.apiClient) { - actResult = await this.apiClient.act({ - input, - options, - frameId: v3Page.mainFrameId(), - }); - } else { - const effectiveTimeoutMs = - typeof options?.timeout === "number" && options.timeout > 0 - ? options.timeout - : undefined; - const ensureTimeRemaining = createTimeoutGuard( - effectiveTimeoutMs, - (ms) => new ActTimeoutError(ms), + // Use selector as provided to support XPath, CSS, and other engines + const selector = input.selector; + if (this.apiClient) { + actResult = await this.apiClient.act({ + input, + options, + frameId: v3Page.mainFrameId(), + }); + } else { + const effectiveTimeoutMs = + typeof options?.timeout === "number" && options.timeout > 0 + ? options.timeout + : undefined; + const ensureTimeRemaining = createTimeoutGuard( + effectiveTimeoutMs, + (ms) => new ActTimeoutError(ms), + ); + actResult = await this.actHandler.takeDeterministicAction( + { ...input, selector }, + v3Page, + this.domSettleTimeoutMs, + this.resolveLlmClient(options?.model), + ensureTimeRemaining, + ); + } + + // history: record ObserveResult-based act call + this.addToHistory( + "act", + { + observeResult: input, + }, + actResult, ); - actResult = await this.actHandler.takeDeterministicAction( - { ...input, selector }, - v3Page, - this.domSettleTimeoutMs, - this.resolveLlmClient(options?.model), - ensureTimeRemaining, + return actResult; + } + // instruction path + if (typeof input !== "string" || !input.trim()) { + throw new StagehandInvalidArgumentError( + "act(): instruction string is required unless passing an Action", ); } - // history: record ObserveResult-based act call - this.addToHistory( - "act", - { - observeResult: input, - }, - actResult, - ); - return actResult; - } - // instruction path - if (typeof input !== "string" || !input.trim()) { - throw new StagehandInvalidArgumentError( - "act(): instruction string is required unless passing an Action", - ); - } - - // Resolve page from options or default - const page = await this.resolvePage(options?.page); - - let actCacheContext: Awaited< - ReturnType - > | null = null; - const canUseCache = - typeof input === "string" && - !this.isAgentReplayRecording() && - this.actCache.enabled; - if (canUseCache) { - actCacheContext = await this.actCache.prepareContext( - input, - page, - options?.variables, - ); - if (actCacheContext) { - const cachedResult = await this.actCache.tryReplay( - actCacheContext, + // Resolve page from options or default + const page = await this.resolvePage(options?.page); + + let actCacheContext: Awaited< + ReturnType + > | null = null; + const canUseCache = + typeof input === "string" && + !this.isAgentReplayRecording() && + this.actCache.enabled; + if (canUseCache) { + actCacheContext = await this.actCache.prepareContext( + input, page, - options?.timeout, + options?.variables, ); - if (cachedResult) { - this.addToHistory( - "act", - { - instruction: input, - variables: options?.variables, - timeout: options?.timeout, - cacheHit: true, - }, - cachedResult, + if (actCacheContext) { + const cachedResult = await this.actCache.tryReplay( + actCacheContext, + page, + options?.timeout, ); - return cachedResult; + if (cachedResult) { + this.addToHistory( + "act", + { + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + cacheHit: true, + }, + cachedResult, + ); + return cachedResult; + } } } - } - const handlerParams: ActHandlerParams = { - instruction: input, - page, - variables: options?.variables, - timeout: options?.timeout, - model: options?.model, - }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - actResult = await this.apiClient.act({ input, options, frameId }); - } else { - actResult = await this.actHandler.act(handlerParams); - } - // history: record instruction-based act call (omit page object) - this.addToHistory( - "act", - { + const handlerParams: ActHandlerParams = { instruction: input, + page, variables: options?.variables, timeout: options?.timeout, - }, - actResult, - ); + model: options?.model, + }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + actResult = await this.apiClient.act({ input, options, frameId }); + } else { + actResult = await this.actHandler.act(handlerParams); + } + // history: record instruction-based act call (omit page object) + this.addToHistory( + "act", + { + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + }, + actResult, + ); - if ( - actCacheContext && - actResult.success && - Array.isArray(actResult.actions) && - actResult.actions.length > 0 - ) { - await this.actCache.store(actCacheContext, actResult); + if ( + actCacheContext && + actResult.success && + Array.isArray(actResult.actions) && + actResult.actions.length > 0 + ) { + await this.actCache.store(actCacheContext, actResult); + } + return actResult; + } finally { + SessionFileLogger.logStagehandStepCompleted(); } - return actResult; }); } @@ -1140,73 +1154,79 @@ export class V3 { c?: ExtractOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStepProgress({ - invocation: "stagehand.extract", + SessionFileLogger.logStagehandStepEvent({ + invocation: "Stagehand.extract", args: [a, b, c], label: "EXTRACT", }); - if (!this.extractHandler) { - throw new StagehandNotInitializedError("extract()"); - } + try { + if (!this.extractHandler) { + throw new StagehandNotInitializedError("extract()"); + } - // Normalize args - let instruction: string | undefined; - let schema: StagehandZodSchema | undefined; - let options: ExtractOptions | undefined; - - if (typeof a === "string") { - instruction = a; - const isZodSchema = (val: unknown): val is StagehandZodSchema => - !!val && - typeof val === "object" && - "parse" in val && - "safeParse" in val; - if (isZodSchema(b)) { - schema = b as StagehandZodSchema; - options = c as ExtractOptions | undefined; + // Normalize args + let instruction: string | undefined; + let schema: StagehandZodSchema | undefined; + let options: ExtractOptions | undefined; + + if (typeof a === "string") { + instruction = a; + const isZodSchema = (val: unknown): val is StagehandZodSchema => + !!val && + typeof val === "object" && + "parse" in val && + "safeParse" in val; + if (isZodSchema(b)) { + schema = b as StagehandZodSchema; + options = c as ExtractOptions | undefined; + } else { + options = b as ExtractOptions | undefined; + } } else { - options = b as ExtractOptions | undefined; + // a is options or undefined + options = (a as ExtractOptions) || undefined; } - } else { - // a is options or undefined - options = (a as ExtractOptions) || undefined; - } - if (!instruction && schema) { - throw new StagehandInvalidArgumentError( - "extract(): schema provided without instruction", - ); - } + if (!instruction && schema) { + throw new StagehandInvalidArgumentError( + "extract(): schema provided without instruction", + ); + } - // If instruction without schema → defaultExtractSchema - const effectiveSchema = - instruction && !schema ? defaultExtractSchema : schema; + // If instruction without schema → defaultExtractSchema + const effectiveSchema = + instruction && !schema ? defaultExtractSchema : schema; - // Resolve page from options or use active page - const page = await this.resolvePage(options?.page); + // Resolve page from options or use active page + const page = await this.resolvePage(options?.page); - const handlerParams: ExtractHandlerParams = { - instruction, - schema: effectiveSchema as StagehandZodSchema | undefined, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page, - }; - let result: z.infer | { pageText: string }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - result = await this.apiClient.extract({ - instruction: handlerParams.instruction, - schema: handlerParams.schema, - options, - frameId, - }); - } else { - result = - await this.extractHandler.extract(handlerParams); + const handlerParams: ExtractHandlerParams = { + instruction, + schema: effectiveSchema as StagehandZodSchema | undefined, + model: options?.model, + timeout: options?.timeout, + selector: options?.selector, + page, + }; + let result: z.infer | { pageText: string }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + result = await this.apiClient.extract({ + instruction: handlerParams.instruction, + schema: handlerParams.schema, + options, + frameId, + }); + } else { + result = + await this.extractHandler.extract( + handlerParams, + ); + } + return result; + } finally { + SessionFileLogger.logStagehandStepCompleted(); } - return result; }); } @@ -1224,58 +1244,62 @@ export class V3 { b?: ObserveOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStepProgress({ - invocation: "stagehand.observe", + SessionFileLogger.logStagehandStepEvent({ + invocation: "Stagehand.observe", args: [a, b], label: "OBSERVE", }); - if (!this.observeHandler) { - throw new StagehandNotInitializedError("observe()"); - } - - // Normalize args - let instruction: string | undefined; - let options: ObserveOptions | undefined; - if (typeof a === "string") { - instruction = a; - options = b; - } else { - options = a as ObserveOptions | undefined; - } - - // Resolve to our internal Page type - const page = await this.resolvePage(options?.page); + try { + if (!this.observeHandler) { + throw new StagehandNotInitializedError("observe()"); + } - const handlerParams: ObserveHandlerParams = { - instruction, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page: page!, - }; + // Normalize args + let instruction: string | undefined; + let options: ObserveOptions | undefined; + if (typeof a === "string") { + instruction = a; + options = b; + } else { + options = a as ObserveOptions | undefined; + } - let results: Action[]; - if (this.apiClient) { - const frameId = page.mainFrameId(); - results = await this.apiClient.observe({ - instruction, - options, - frameId, - }); - } else { - results = await this.observeHandler.observe(handlerParams); - } + // Resolve to our internal Page type + const page = await this.resolvePage(options?.page); - // history: record observe call (omit page object) - this.addToHistory( - "observe", - { + const handlerParams: ObserveHandlerParams = { instruction, + model: options?.model, timeout: options?.timeout, - }, - results, - ); - return results; + selector: options?.selector, + page: page!, + }; + + let results: Action[]; + if (this.apiClient) { + const frameId = page.mainFrameId(); + results = await this.apiClient.observe({ + instruction, + options, + frameId, + }); + } else { + results = await this.observeHandler.observe(handlerParams); + } + + // history: record observe call (omit page object) + this.addToHistory( + "observe", + { + instruction, + timeout: options?.timeout, + }, + results, + ); + return results; + } finally { + SessionFileLogger.logStagehandStepCompleted(); + } }); } @@ -1685,8 +1709,9 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logTaskProgress({ - invocation: "agent.execute", + SessionFileLogger.logAgentTaskStarted(); + SessionFileLogger.logAgentTaskEvent({ + invocation: "Agent.execute", args: [instructionOrOptions], }); if (options?.integrations && !this.experimental) { @@ -1774,6 +1799,7 @@ export class V3 { if (recording) { this.discardAgentReplayRecording(); } + SessionFileLogger.logAgentTaskCompleted(); } }), }; @@ -1791,8 +1817,9 @@ export class V3 { | AgentStreamExecuteOptions, ): Promise => withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logTaskProgress({ - invocation: "agent.execute", + SessionFileLogger.logAgentTaskStarted(); + SessionFileLogger.logAgentTaskEvent({ + invocation: "Agent.execute", args: [instructionOrOptions], }); if ( @@ -1891,6 +1918,7 @@ export class V3 { if (recording) { this.discardAgentReplayRecording(); } + SessionFileLogger.logAgentTaskCompleted(); } }), }; From d69fe156170e719b739f988a84f81df69b7c9e55 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 14:54:58 -0800 Subject: [PATCH 09/32] fix id scoping for spans where no task is running, use uuidv7 for event ids, cleanup code --- packages/core/examples/flowLoggingJourney.ts | 30 +- packages/core/lib/v3/flowLogger.ts | 477 +++++++++++------- .../handlers/handlerUtils/actHandlerUtils.ts | 2 +- .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 42 +- packages/core/lib/v3/llm/aisdk.ts | 44 +- packages/core/lib/v3/understudy/cdp.ts | 14 +- packages/core/lib/v3/v3.ts | 19 +- packages/core/package.json | 2 + pnpm-lock.yaml | 12 + 9 files changed, 410 insertions(+), 232 deletions(-) diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts index 4f1926a10..3b2963f08 100644 --- a/packages/core/examples/flowLoggingJourney.ts +++ b/packages/core/examples/flowLoggingJourney.ts @@ -1,17 +1,19 @@ import { Stagehand } from "../lib/v3"; async function run(): Promise { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { + const openaiKey = process.env.OPENAI_API_KEY; + const anthropicKey = process.env.ANTHROPIC_API_KEY; + + if (!openaiKey || !anthropicKey) { throw new Error( - "Set OPENAI_API_KEY to a valid OpenAI key before running this demo.", + "Set both OPENAI_API_KEY and ANTHROPIC_API_KEY before running this demo.", ); } const stagehand = new Stagehand({ env: "LOCAL", verbose: 2, - model: { modelName: "openai/gpt-4.1-mini", apiKey }, + model: { modelName: "openai/gpt-4.1-mini", apiKey: openaiKey }, localBrowserLaunchOptions: { headless: true, args: ["--window-size=1280,720"], @@ -25,6 +27,7 @@ async function run(): Promise { const [page] = stagehand.context.pages(); await page.goto("https://example.com/", { waitUntil: "load" }); + // Test standard agent path const agent = stagehand.agent({ systemPrompt: "You are a QA assistant. Keep answers short and deterministic. Finish quickly.", @@ -34,9 +37,22 @@ async function run(): Promise { ); console.log("Agent result:", agentResult); - const observations = await stagehand.observe( - "Find any links on the page", - ); + // Test CUA (Computer Use Agent) path + await page.goto("https://example.com/", { waitUntil: "load" }); + const cuaAgent = stagehand.agent({ + cua: true, + model: { + modelName: "anthropic/claude-sonnet-4-5-20250929", + apiKey: anthropicKey, + }, + }); + const cuaResult = await cuaAgent.execute({ + instruction: "Click on the 'More information...' link on the page.", + maxSteps: 3, + }); + console.log("CUA Agent result:", cuaResult); + + const observations = await stagehand.observe("Find any links on the page"); console.log("Observe result:", observations); if (observations.length > 0) { diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 1291c8601..812694645 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -1,11 +1,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import { v7 as uuidv7 } from "uuid"; import path from "node:path"; import type { V3Options } from "./types/public"; const MAX_ARG_LENGTH = 500; +// TODO: Eventually refactor this to use pino logging system + eventbus listeners +// library code should emit an event to the bus instead of calling log funcs directly, +// logger watchdog should listen to the bus and forward the events to pino / OTEL +// events should track their parent events automatically (maybe with AsyncLocalStorage or manual child_events / parent_id fields), +// so span context can be reconstructed by following the parent chain, e.g. CDPEvent.parent_id -> StagehandStepEvent.parent_id -> AgentTaskEvent.parent_id -> etc. +// we should wait for the Stagehand.eventBus to be ready before working on this refactor + interface LogFile { path: string; stream: fs.WriteStream | null; @@ -24,13 +31,14 @@ export interface FlowLoggerContext { }; initPromise: Promise; initialized: boolean; - // Flow context + // Flow context state for each tracing span (session -> task -> step -> action -> cdp,llm) agentTaskId: string | null; stagehandStepId: string | null; understudyActionId: string | null; stagehandStepLabel: string | null; understudyActionLabel: string | null; stagehandStepStartTime: number | null; + understudyActionStartTime: number | null; // Task metrics agentTaskStartTime: number | null; agentTaskLlmRequests: number; @@ -41,33 +49,36 @@ export interface FlowLoggerContext { const loggerContext = new AsyncLocalStorage(); -function generateId(label: string): string { - try { - return randomUUID(); - } catch { - const fallback = - (globalThis.crypto as Crypto | undefined)?.randomUUID?.() ?? - `${Date.now()}-${label}-${Math.floor(Math.random() * 1e6)}`; - return fallback; - } -} - function truncate(value: string): string { - value = value.replace(/\s+/g, " "); // replace newlines, tabs, etc. with space - value = value.replace(/\s+/g, " "); // replace repeated spaces with single space + value = value.replace(/\s+/g, " "); // replace newlines, tabs, etc. with space + value = value.replace(/\s+/g, " "); // replace repeated spaces with single space if (value.length <= MAX_ARG_LENGTH) { return value; } return `${value.slice(0, MAX_ARG_LENGTH)}…`; } +/** + * Truncate CDP IDs (32-char uppercase hex strings) that appear after id/Id patterns. + * Transforms: frameId:363F03EB7E3795ACB434672C35095EF8 → frameId:363F…5EF8 + */ +function truncateCdpIds(value: string): string { + // Match patterns like: id:, Id:, frameId:, loaderId:, etc. followed by optional quote and 32-char hex ID + // The ID must be exactly 32 uppercase hex characters [0-9A-F] + return value.replace( + /([iI]d:?"?)([0-9A-F]{32})(?="?[,})\s]|$)/g, + (_match, prefix: string, id: string) => + `${prefix}${id.slice(0, 4)}…${id.slice(-4)}`, + ); +} + /** * Truncate conversation/prompt strings showing first 30 chars + ... + last 100 chars * This helps see both the beginning context and the most recent part of growing conversations */ function truncateConversation(value: string): string { - value = value.replace(/\s+/g, " "); // normalize whitespace - const maxLen = 130; // 30 + 100 + value = value.replace(/\s+/g, " "); // normalize whitespace + const maxLen = 130; // 30 + 100 if (value.length <= maxLen) { return value; } @@ -118,10 +129,14 @@ function formatArgs(args?: unknown | unknown[]): string { return rendered.join(", "); } -function formatTag(label: string, id: string | null, icon: string | null): string { - if (!id) return `⤑`; // omit the part if the id is null, we're not in an active task/step/action +function formatTag( + label: string, + id: string | null, + icon: string | null, +): string { + if (!id) return `⤑`; // omit the part if the id is null, we're not in an active task/step/action // return `[${label} ${icon ? icon : ""} #${shortId(id)}]`; - return `[${icon || ''} #${shortId(id)}${label ? " " : ""}${label || ""}]`; + return `[${icon || ""} #${shortId(id)}${label ? " " : ""}${label || ""}]`; } function shortId(id: string | null): string { @@ -195,6 +210,7 @@ export function getConfigDir(): string { if (fromEnv) { return path.resolve(fromEnv); } + // in the future maybe we use a centralized config directory ~/.config/browserbase return path.resolve(process.cwd(), ".browserbase"); } @@ -214,9 +230,18 @@ export class SessionFileLogger { sessionDir, configDir, logFiles: { - agent: { path: path.join(sessionDir, "agent_events.log"), stream: null }, - stagehand: { path: path.join(sessionDir, "stagehand_events.log"), stream: null }, - understudy: { path: path.join(sessionDir, "understudy_events.log"), stream: null }, + agent: { + path: path.join(sessionDir, "agent_events.log"), + stream: null, + }, + stagehand: { + path: path.join(sessionDir, "stagehand_events.log"), + stream: null, + }, + understudy: { + path: path.join(sessionDir, "understudy_events.log"), + stream: null, + }, cdp: { path: path.join(sessionDir, "cdp_events.log"), stream: null }, llm: { path: path.join(sessionDir, "llm_events.log"), stream: null }, }, @@ -229,6 +254,7 @@ export class SessionFileLogger { stagehandStepLabel: null, understudyActionId: null, understudyActionLabel: null, + understudyActionStartTime: null, stagehandStepStartTime: null, // Task metrics - null until a task starts agentTaskStartTime: null, @@ -359,44 +385,6 @@ export class SessionFileLogger { return loggerContext.getStore() ?? null; } - // --- Flow context methods --- - - private static ensureAgentTaskContext(ctx: FlowLoggerContext): void { - if (!ctx.agentTaskId) { - ctx.agentTaskId = generateId("task"); - } - } - - private static ensureStagehandStepContext( - ctx: FlowLoggerContext, - defaultLabel?: string, - ): void { - if (defaultLabel) { - ctx.stagehandStepLabel = defaultLabel.toUpperCase(); - } - if (!ctx.stagehandStepLabel) { - ctx.stagehandStepLabel = "STEP"; - } - if (!ctx.stagehandStepId) { - ctx.stagehandStepId = generateId("step"); - } - } - - private static ensureUnderstudyActionContext( - ctx: FlowLoggerContext, - defaultLabel?: string, - ): void { - if (defaultLabel) { - ctx.understudyActionLabel = defaultLabel.toUpperCase(); - } - if (!ctx.understudyActionLabel) { - ctx.understudyActionLabel = "ACTION"; - } - if (!ctx.understudyActionId) { - ctx.understudyActionId = generateId("action"); - } - } - private static buildLogLine( ctx: FlowLoggerContext, options: { @@ -406,39 +394,69 @@ export class SessionFileLogger { }, details: string, ): string { - const { includeAction = true, includeStep = true, includeTask = true } = options; + const { + includeAction = true, + includeStep = true, + includeTask = true, + } = options; const parts: string[] = []; if (includeTask) { - parts.push(formatTag("", ctx.agentTaskId, 'šŸ…°')); + parts.push(formatTag("", ctx.agentTaskId, "šŸ…°")); } if (includeStep) { - parts.push(formatTag(ctx.stagehandStepLabel, ctx.stagehandStepId, 'šŸ†‚')); + parts.push(formatTag(ctx.stagehandStepLabel, ctx.stagehandStepId, "šŸ†‚")); } if (includeAction) { - parts.push(formatTag(ctx.understudyActionLabel, ctx.understudyActionId, 'šŸ†„')); + parts.push( + formatTag(ctx.understudyActionLabel, ctx.understudyActionId, "šŸ†„"), + ); } // parts[parts.length - 1] = parts[parts.length - 1].replace("[", "⟦").replace("]", "⟧"); // try and higlight the last tag so it stands out visually (imperfect) - return `${formatTimestamp()} ${parts.join(" ")} ${details}`; + const full_line = `${formatTimestamp()} ${parts.join(" ")} ${details}`; + + // Remove unescaped " and ' characters, but leave those preceded by a backslash (\) + const without_quotes = full_line + .replace(/([^\\])["']/g, "$1") // remove " or ' if not preceded by \ + .replace(/^["']|["']$/g, "") // also remove leading/trailing " or ' at string ends (not preceded by \) + .trim(); + + return without_quotes; } /** - * Start a new task. Call this when agent.execute() begins. - * Sets taskId to a new UUID and resets metrics. + * Start a new task and log it. Call this when agent.execute() begins. + * Sets taskId to a new UUID, resets metrics, and logs the start event. */ - static logAgentTaskStarted(): void { + static logAgentTaskStarted({ + invocation, + args, + }: { + invocation: string; + args?: unknown | unknown[]; + }): void { const ctx = loggerContext.getStore(); - if (ctx) { - ctx.agentTaskId = generateId("task"); - ctx.stagehandStepId = null; - ctx.stagehandStepLabel = null; - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - ctx.agentTaskStartTime = Date.now(); - ctx.agentTaskLlmRequests = 0; - ctx.agentTaskCdpEvents = 0; - ctx.agentTaskLlmInputTokens = 0; - ctx.agentTaskLlmOutputTokens = 0; - } + if (!ctx) return; + + // Set up task context + ctx.agentTaskId = uuidv7(); + ctx.stagehandStepId = null; + ctx.stagehandStepLabel = null; + ctx.stagehandStepStartTime = null; + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; + ctx.agentTaskStartTime = Date.now(); + ctx.agentTaskLlmRequests = 0; + ctx.agentTaskCdpEvents = 0; + ctx.agentTaskLlmInputTokens = 0; + ctx.agentTaskLlmOutputTokens = 0; + + // Log the start event + const message = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: false, includeAction: false }, + `ā–· ${invocation}(${formatArgs(args)})`, + ); + SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); } /** @@ -466,49 +484,37 @@ export class SessionFileLogger { ctx.understudyActionId = null; ctx.stagehandStepLabel = null; ctx.understudyActionLabel = null; + ctx.understudyActionStartTime = null; ctx.agentTaskStartTime = null; } } - static clearStagehandStepContext(): void { - const ctx = loggerContext.getStore(); - if (ctx) { - ctx.stagehandStepId = null; - ctx.stagehandStepLabel = null; - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - } - } - - static clearUnderstudyActionContext(): void { - const ctx = loggerContext.getStore(); - if (ctx) { - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - } - } - - // --- Logging methods --- - - static logAgentTaskEvent({ // log agent/session-level events like: Start, End, Execute - invocation, - args, - }: { - invocation: string; - args?: unknown | unknown[]; - }): void { + static logUnderstudyActionCompleted(): void { const ctx = loggerContext.getStore(); if (!ctx) return; + const durationMs = ctx.understudyActionStartTime + ? Date.now() - ctx.understudyActionStartTime + : 0; + const durationSec = (durationMs / 1000).toFixed(2); + + const details = `āœ“ ${ctx.understudyActionLabel} completed in ${durationSec}s`; const message = SessionFileLogger.buildLogLine( ctx, - { includeTask: true, includeStep: false, includeAction: false }, - `ā–· ${invocation}(${formatArgs(args)})`, + { includeTask: true, includeStep: true, includeAction: true }, + details, ); - SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); + SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); + + // Clear action context + ctx.understudyActionId = null; + ctx.understudyActionLabel = null; } - static logStagehandStepEvent({ // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate + // --- Logging methods --- + + static logStagehandStepEvent({ + // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate invocation, args, label, @@ -518,19 +524,22 @@ export class SessionFileLogger { label: string; }): string { const ctx = loggerContext.getStore(); - if (!ctx) return generateId("step"); + if (!ctx) return uuidv7(); + + // leave parent task id null/untouched for now, stagehand steps called directly dont always have a parent task, maybe worth randomizing the task id when a step starts to make it easier to correlate steps to tasks? + // ctx.agentTaskId = uuidv7(); - SessionFileLogger.ensureAgentTaskContext(ctx); - ctx.stagehandStepId = generateId("step"); + ctx.stagehandStepId = uuidv7(); ctx.stagehandStepLabel = label.toUpperCase(); ctx.stagehandStepStartTime = Date.now(); ctx.understudyActionId = null; ctx.understudyActionLabel = null; + ctx.understudyActionStartTime = null; const message = SessionFileLogger.buildLogLine( ctx, { includeTask: true, includeStep: true, includeAction: false }, - `${invocation}(${formatArgs(args)})`, + `ā–· ${invocation}(${formatArgs(args)})`, ); SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); @@ -560,9 +569,11 @@ export class SessionFileLogger { ctx.stagehandStepStartTime = null; ctx.understudyActionId = null; ctx.understudyActionLabel = null; + ctx.understudyActionStartTime = null; } - static logUnderstudyActionEvent({ // log understudy-level browser action calls like: Click, Type, Scroll + static logUnderstudyActionEvent({ + // log understudy-level browser action calls like: Click, Type, Scroll actionType, target, args, @@ -572,14 +583,19 @@ export class SessionFileLogger { args?: unknown | unknown[]; }): string { const ctx = loggerContext.getStore(); - if (!ctx) return generateId("action"); + if (!ctx) return uuidv7(); // THESE ARE NOT NEEDED, it's possible for understudy methods to be called directly without going through stagehand.act/observe/extract or agent.execute - // SessionFileLogger.ensureAgentTaskContext(ctx); - // SessionFileLogger.ensureStagehandStepContext(ctx); + // ctx.agentTaskId = ctx.agentTaskId || uuidv7(); + // ctx.stagehandStepId = ctx.stagehandStepId || uuidv7(); + + ctx.understudyActionId = uuidv7(); + ctx.understudyActionLabel = actionType + .toUpperCase() + .replace("UNDERSTUDY.", "") + .replace("PAGE.", ""); - ctx.understudyActionId = generateId("action"); - ctx.understudyActionLabel = actionType.toUpperCase().replace("UNDERSTUDY.", ""); + ctx.understudyActionStartTime = Date.now(); const details: string[] = []; if (target) details.push(`target=${target}`); @@ -589,15 +605,16 @@ export class SessionFileLogger { const message = SessionFileLogger.buildLogLine( ctx, { includeTask: true, includeStep: true, includeAction: true }, - `${actionType}(${details.join(", ")})`, + `ā–· ${actionType}(${details.join(", ")})`, ); SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); return ctx.understudyActionId; } - static logCdpMessageEvent( - { // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. + static logCdpCallEvent( + { + // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. method, params, targetId, @@ -614,22 +631,84 @@ export class SessionFileLogger { // Track CDP events for task metrics ctx.agentTaskCdpEvents++; + // Filter out CDP enable calls - they're too noisy and not useful for debugging + if (method.endsWith(".enable") || method === "enable") { + return; + } + const argsStr = params ? formatArgs(params) : ""; const call = argsStr ? `${method}(${argsStr})` : `${method}()`; - const details = `${formatTag("CDP", targetId || "0000", 'šŸ…²')} ${call}`; + const details = `${formatTag("CDP", targetId || "0000", "šŸ…²")} āµ ${call}`; const rawMessage = SessionFileLogger.buildLogLine( ctx, { includeTask: true, includeStep: true, includeAction: true }, details, ); - const message = rawMessage.length > 140 ? `${rawMessage.slice(0, 137)}…` : rawMessage; + const truncatedIds = truncateCdpIds(rawMessage); + const message = + truncatedIds.length > 140 + ? `${truncatedIds.slice(0, 137)}…` + : truncatedIds; + + SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); + } + + static logCdpMessageEvent( + { + // log CDP events received asynchronously from the browser + method, + params, + targetId, + }: { + method: string; + params?: unknown; + targetId?: string | null; + }, + explicitCtx?: FlowLoggerContext | null, + ): void { + const ctx = explicitCtx ?? loggerContext.getStore(); + if (!ctx) return; + + // Filter out noisy events that aren't useful for debugging + const noisyEvents = [ + "Target.targetInfoChanged", + "Runtime.executionContextCreated", + "Runtime.executionContextDestroyed", + "Runtime.executionContextsCleared", + "Page.lifecycleEvent", + "Network.dataReceived", + "Network.loadingFinished", + "Network.requestWillBeSentExtraInfo", + "Network.responseReceivedExtraInfo", + "Network.requestWillBeSent", + "Network.responseReceived", + ]; + if (noisyEvents.includes(method)) { + return; + } + + const argsStr = params ? formatArgs(params) : ""; + const event = argsStr ? `${method}(${argsStr})` : `${method}`; + const details = `${formatTag("CDP", targetId ? targetId.slice(-4) : "????", "šŸ…²")} ā“ ${event}`; + + const rawMessage = SessionFileLogger.buildLogLine( + ctx, + { includeTask: true, includeStep: true, includeAction: true }, + details, + ); + const truncatedIds = truncateCdpIds(rawMessage); + const message = + truncatedIds.length > 140 + ? `${truncatedIds.slice(0, 137)}…` + : truncatedIds; SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); } static logLlmRequest( - { // log outgoing LLM API requests + { + // log outgoing LLM API requests requestId, model, operation, @@ -649,7 +728,7 @@ export class SessionFileLogger { ctx.agentTaskLlmRequests++; const promptStr = prompt ? ` ${truncateConversation(prompt)}` : ""; - const details = `${formatTag("LLM", requestId, '🧠')} ${model} šŸ’¬${promptStr}`; + const details = `${formatTag("LLM", requestId, "🧠")} ${model} ā“${promptStr}`; const rawMessage = SessionFileLogger.buildLogLine( ctx, @@ -657,13 +736,15 @@ export class SessionFileLogger { details, ); // Temporarily increased limit for debugging - const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; + const message = + rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); } static logLlmResponse( - { // log incoming LLM API responses + { + // log incoming LLM API responses requestId, model, operation, @@ -687,11 +768,12 @@ export class SessionFileLogger { ctx.agentTaskLlmInputTokens += inputTokens ?? 0; ctx.agentTaskLlmOutputTokens += outputTokens ?? 0; - const tokens = inputTokens !== undefined || outputTokens !== undefined - ? ` źœ›${inputTokens ?? 0} ꜜ${outputTokens ?? 0} |` - : ""; + const tokens = + inputTokens !== undefined || outputTokens !== undefined + ? ` źœ›${inputTokens ?? 0} ꜜ${outputTokens ?? 0} |` + : ""; const outputStr = output ? ` ${truncateConversation(output)}` : ""; - const details = `${formatTag("LLM", requestId, '🧠')} ${model} ↳${tokens}${outputStr}`; + const details = `${formatTag("LLM", requestId, "🧠")} ${model} ↳${tokens}${outputStr}`; const rawMessage = SessionFileLogger.buildLogLine( ctx, @@ -699,18 +781,18 @@ export class SessionFileLogger { details, ); // Temporarily increased limit for debugging - const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; + const message = + rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); } - static generateLlmRequestId(): string { - return generateId("llm"); - } - /** * Create middleware for wrapping language models with LLM call logging. * Use with wrapLanguageModel from the AI SDK. + * This is vibecoded and a bit messy, but it's a quick way to get LLM + * logging working and in a useful format for devs watching the terminal in realtime. + * TODO: Refactor this to use a proper span-based tracing system like OpenTelemetry and clean up/reduce all the parsing/reformatting logic. */ static createLlmLoggingMiddleware(modelId: string): { wrapGenerate: (options: { @@ -731,32 +813,39 @@ export class SessionFileLogger { // Capture context at the start of the call to preserve step/action context const ctx = SessionFileLogger.getContext(); - const llmRequestId = SessionFileLogger.generateLlmRequestId(); + const llmRequestId = uuidv7(); - const p = params as { prompt?: unknown[]; tools?: unknown[]; schema?: unknown }; + const p = params as { + prompt?: unknown[]; + tools?: unknown[]; + schema?: unknown; + }; // Count tools const toolCount = Array.isArray(p.tools) ? p.tools.length : 0; // Check for images in any message - const hasImage = p.prompt?.some((m: unknown) => { - const msg = m as { content?: unknown[] }; - if (!Array.isArray(msg.content)) return false; - return msg.content.some((c: unknown) => { - const part = c as { type?: string }; - return part.type === "image"; - }); - }) ?? false; - - // Check for schema (structured output) - const hasSchema = !!p.schema; + // const hasImage = + // p.prompt?.some((m: unknown) => { + // const msg = m as { content?: unknown[] }; + // if (!Array.isArray(msg.content)) return false; + // return msg.content.some((c: unknown) => { + // const part = c as { type?: string }; + // return part.type === "image"; + // }); + // }) ?? false; + + // // Check for schema (structured output) + // const hasSchema = !!p.schema; // Find the last non-system message to show the newest content (tool result, etc.) const nonSystemMessages = (p.prompt ?? []).filter((m: unknown) => { const msg = m as { role?: string }; return msg.role !== "system"; }); - const lastMsg = nonSystemMessages[nonSystemMessages.length - 1] as Record | undefined; + const lastMsg = nonSystemMessages[nonSystemMessages.length - 1] as + | Record + | undefined; const lastRole = (lastMsg?.role as string) ?? "?"; // Extract content from last message - handle various formats @@ -772,7 +861,9 @@ export class SessionFileLogger { toolName = (item.toolName as string) || ""; // output is directly on the tool-result item - const output = item.output as Record | undefined; + const output = item.output as + | Record + | undefined; if (output) { if (output.type === "json" && output.value) { @@ -785,9 +876,15 @@ export class SessionFileLogger { const vItem = v as Record; if (vItem.type === "text" && vItem.text) { parts.push(vItem.text as string); - } else if (vItem.mediaType && typeof vItem.data === "string") { + } else if ( + vItem.mediaType && + typeof vItem.data === "string" + ) { // Image data - const sizeKb = ((vItem.data as string).length * 0.75 / 1024).toFixed(1); + const sizeKb = ( + ((vItem.data as string).length * 0.75) / + 1024 + ).toFixed(1); parts.push(`[${sizeKb}kb img]`); } } @@ -813,7 +910,7 @@ export class SessionFileLogger { // Truncate long strings (like base64 images) if (typeof value === "string" && value.length > 100) { if (value.startsWith("data:image")) { - const sizeKb = (value.length * 0.75 / 1024).toFixed(1); + const sizeKb = ((value.length * 0.75) / 1024).toFixed(1); return `[${sizeKb}kb image]`; } return value.slice(0, 50) + "..."; @@ -828,21 +925,30 @@ export class SessionFileLogger { // Build preview: role + tool name + truncated content + metadata const rolePrefix = toolName ? `tool result: ${toolName}()` : lastRole; - const contentTruncated = lastContent ? truncateConversation(lastContent) : "(no text)"; - const promptPreview = `${rolePrefix}āž” ${contentTruncated} +{${toolCount} tools}`; - - SessionFileLogger.logLlmRequest({ - requestId: llmRequestId, - model: modelId, - operation: "generateText", - prompt: promptPreview, - }, ctx); + const contentTruncated = lastContent + ? truncateConversation(lastContent) + : "(no text)"; + const promptPreview = `${rolePrefix}: ${contentTruncated} +{${toolCount} tools}`; + + SessionFileLogger.logLlmRequest( + { + requestId: llmRequestId, + model: modelId, + operation: "generateText", + prompt: promptPreview, + }, + ctx, + ); const result = await doGenerate(); // Extract output - handle various response formats let outputPreview = ""; - const res = result as { text?: string; content?: unknown; toolCalls?: unknown[] }; + const res = result as { + text?: string; + content?: unknown; + toolCalls?: unknown[]; + }; if (res.text) { outputPreview = res.text; } else if (res.content) { @@ -852,9 +958,14 @@ export class SessionFileLogger { } else if (Array.isArray(res.content)) { outputPreview = res.content .map((c: unknown) => { - const item = c as { type?: string; text?: string; toolName?: string }; + const item = c as { + type?: string; + text?: string; + toolName?: string; + }; if (item.type === "text") return item.text; - if (item.type === "tool-call") return `tool call: ${item.toolName}()`; + if (item.type === "tool-call") + return `tool call: ${item.toolName}()`; return `[${item.type || "unknown"}]`; }) .join(" "); @@ -865,18 +976,24 @@ export class SessionFileLogger { outputPreview = `[${res.toolCalls.length} tool calls]`; } else if (typeof result === "object" && result !== null) { // Fallback: try to stringify relevant parts of the result - const keys = Object.keys(result).filter(k => k !== "usage" && k !== "rawResponse"); - outputPreview = keys.length > 0 ? `{${keys.join(", ")}}` : "[empty response]"; + const keys = Object.keys(result).filter( + (k) => k !== "usage" && k !== "rawResponse", + ); + outputPreview = + keys.length > 0 ? `{${keys.join(", ")}}` : "[empty response]"; } - SessionFileLogger.logLlmResponse({ - requestId: llmRequestId, - model: modelId, - operation: "generateText", - output: outputPreview, - inputTokens: result.usage?.inputTokens, - outputTokens: result.usage?.outputTokens, - }, ctx); + SessionFileLogger.logLlmResponse( + { + requestId: llmRequestId, + model: modelId, + operation: "generateText", + output: outputPreview, + inputTokens: result.usage?.inputTokens, + outputTokens: result.usage?.outputTokens, + }, + ctx, + ); return result; }, diff --git a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts index ae2a32c5f..1b4334ba8 100644 --- a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts +++ b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts @@ -128,7 +128,7 @@ export async function performUnderstudyMethod( }); throw new UnderstudyCommandException(msg); } finally { - SessionFileLogger.clearUnderstudyActionContext(); + SessionFileLogger.logUnderstudyActionCompleted(); } } diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index d2d471304..428027a7f 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -74,7 +74,16 @@ export class V3CuaAgentHandler { } } await new Promise((r) => setTimeout(r, 300)); - await this.executeAction(action); + SessionFileLogger.logUnderstudyActionEvent({ + actionType: `v3CUA.${action.type}`, + target: this.computePointerTarget(action), + args: [action], + }); + try { + await this.executeAction(action); + } finally { + SessionFileLogger.logUnderstudyActionCompleted(); + } action.timestamp = Date.now(); @@ -163,22 +172,6 @@ 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})` - : typeof action.selector === "string" - ? action.selector - : typeof action.input === "string" - ? action.input - : typeof action.description === "string" - ? action.description - : undefined; - SessionFileLogger.logUnderstudyActionEvent({ - actionType: `v3CUA.${action.type}`, - target: pointerTarget, - args: [action], - }); - try { switch (action.type) { case "click": { const { x, y, button = "left", clickCount } = action; @@ -439,9 +432,18 @@ export class V3CuaAgentHandler { error: `Unknown action ${String(action.type)}`, }; } - } finally { - SessionFileLogger.clearUnderstudyActionContext(); - } + } + + private computePointerTarget(action: AgentAction): string | undefined { + return typeof action.x === "number" && typeof action.y === "number" + ? `(${action.x}, ${action.y})` + : typeof action.selector === "string" + ? action.selector + : typeof action.input === "string" + ? action.input + : typeof action.description === "string" + ? action.description + : undefined; } private ensureXPath(value: unknown): string | null { diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index 21d46aa49..344c30c1f 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -13,6 +13,7 @@ import { } from "ai"; import type { LanguageModelV2 } from "@ai-sdk/provider"; import { ChatCompletion } from "openai/resources"; +import { v7 as uuidv7 } from "uuid"; import { LogLine } from "../types/public/logs"; import { AvailableModel } from "../types/public/model"; import { CreateChatCompletionOptions, LLMClient } from "./LLMClient"; @@ -42,7 +43,6 @@ export class AISdkClient extends LLMClient { async createChatCompletion({ options, }: CreateChatCompletionOptions): Promise { - this.logger?.({ category: "aisdk", message: "creating chat completion", @@ -132,18 +132,24 @@ export class AISdkClient extends LLMClient { const isGPT51 = this.model.modelId.includes("gpt-5.1"); if (options.response_model) { // Log LLM request for generateObject (extract) - const llmRequestId = SessionFileLogger.generateLlmRequestId(); - const lastUserMsg = options.messages.filter(m => m.role === "user").pop(); + const llmRequestId = uuidv7(); + const lastUserMsg = options.messages + .filter((m) => m.role === "user") + .pop(); const promptPreview = lastUserMsg - ? (typeof lastUserMsg.content === "string" - ? lastUserMsg.content.replace('instruction: ', '').replace('Instruction: ', '') - : lastUserMsg.content.map(c => "text" in c ? c.text : "[img]").join(" ")) + ? typeof lastUserMsg.content === "string" + ? lastUserMsg.content + .replace("instruction: ", "") + .replace("Instruction: ", "") + : lastUserMsg.content + .map((c) => ("text" in c ? c.text : "[img]")) + .join(" ") : undefined; SessionFileLogger.logLlmRequest({ requestId: llmRequestId, model: this.model.modelId, operation: "generateObject", - prompt: promptPreview ? `${promptPreview} +schema` : "+schema", + prompt: `${promptPreview} +{schema}`, }); try { @@ -216,7 +222,7 @@ export class AISdkClient extends LLMClient { requestId: llmRequestId, model: this.model.modelId, operation: "generateObject", - output: JSON.stringify(objectResponse.object).slice(0, 200), + output: JSON.stringify(objectResponse.object), inputTokens: objectResponse.usage.inputTokens, outputTokens: objectResponse.usage.outputTokens, }); @@ -256,19 +262,23 @@ export class AISdkClient extends LLMClient { } // Log LLM request for generateText (act/observe) - const llmRequestId = SessionFileLogger.generateLlmRequestId(); - const lastUserMsg = options.messages.filter(m => m.role === "user").pop(); + const llmRequestId = uuidv7(); + const lastUserMsg = options.messages.filter((m) => m.role === "user").pop(); const promptPreview = lastUserMsg - ? (typeof lastUserMsg.content === "string" - ? lastUserMsg.content.replace("instruction: ", "") - : lastUserMsg.content.map(c => "text" in c ? c.text : "[img]").join(" ")) + ? typeof lastUserMsg.content === "string" + ? lastUserMsg.content.replace("instruction: ", "") + : lastUserMsg.content + .map((c) => ("text" in c ? c.text : "[img]")) + .join(" ") : undefined; const toolCount = Object.keys(tools).length; SessionFileLogger.logLlmRequest({ requestId: llmRequestId, model: this.model.modelId, operation: "generateText", - prompt: promptPreview ? `${promptPreview}${toolCount > 0 ? ` +{${toolCount} tools}` : ""}` : undefined, + prompt: promptPreview + ? `${promptPreview}${toolCount > 0 ? ` +{${toolCount} tools}` : ""}` + : undefined, }); const textResponse = await generateText({ @@ -330,7 +340,11 @@ export class AISdkClient extends LLMClient { requestId: llmRequestId, model: this.model.modelId, operation: "generateText", - output: textResponse.text || (transformedToolCalls.length > 0 ? `[${transformedToolCalls.length} tool calls]` : ""), + output: + textResponse.text || + (transformedToolCalls.length > 0 + ? `[${transformedToolCalls.length} tool calls]` + : ""), inputTokens: textResponse.usage.inputTokens, outputTokens: textResponse.usage.outputTokens, }); diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index 9d7ba952d..9ab81f665 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -50,13 +50,20 @@ export class CdpConnection implements CDPSessionLike { public readonly id: string | null = null; // root private transportCloseHandlers = new Set<(why: string) => void>(); - /** Optional CDP logger - set this to receive all CDP method calls */ + /** Optional CDP logger - set this to receive all outgoing CDP method calls */ public cdpLogger?: (info: { method: string; params?: object; targetId?: string | null; }) => void; + /** Optional CDP event logger - set this to receive all incoming CDP events */ + public cdpEventLogger?: (info: { + method: string; + params?: unknown; + targetId?: string | null; + }) => void; + public onTransportClosed(handler: (why: string) => void): void { this.transportCloseHandlers.add(handler); } @@ -224,6 +231,11 @@ export class CdpConnection implements CDPSessionLike { } const { method, params, sessionId } = msg; + + // Log incoming CDP events + const targetId = this.sessionToTarget.get(sessionId) || sessionId; + this.cdpEventLogger?.({ method, params, targetId }); + if (sessionId) { const session = this.sessions.get(sessionId); session?.dispatch(method, params); diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 970e04595..5d1423930 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -3,6 +3,7 @@ import fs from "fs"; import os from "os"; import path from "path"; import process from "process"; +import { v7 as uuidv7 } from "uuid"; import { z } from "zod"; import type { InferStagehandSchema, StagehandZodSchema } from "./zodCompat"; import { loadApiKeyFromEnv } from "../utils"; @@ -217,9 +218,7 @@ export class V3 { V3._installProcessGuards(); this.externalLogger = opts.logger; this.verbose = opts.verbose ?? 1; - this.instanceId = - (globalThis.crypto as Crypto | undefined)?.randomUUID?.() ?? - `${Date.now()}-${Math.floor(Math.random() * 1e9)}`; + this.instanceId = uuidv7(); // Create per-instance StagehandLogger (handles usePino, verbose, externalLogger) // This gives each V3 instance independent logger configuration @@ -704,6 +703,8 @@ export class V3 { }); const logCtx = SessionFileLogger.getContext(); this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpCallEvent(info, logCtx); + this.ctx.conn.cdpEventLogger = (info) => SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { @@ -796,6 +797,8 @@ export class V3 { }); const logCtx = SessionFileLogger.getContext(); this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpCallEvent(info, logCtx); + this.ctx.conn.cdpEventLogger = (info) => SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { @@ -876,6 +879,8 @@ export class V3 { }); const logCtx = SessionFileLogger.getContext(); this.ctx.conn.cdpLogger = (info) => + SessionFileLogger.logCdpCallEvent(info, logCtx); + this.ctx.conn.cdpEventLogger = (info) => SessionFileLogger.logCdpMessageEvent(info, logCtx); this.ctx.conn.onTransportClosed(this._onCdpClosed); this.state = { kind: "BROWSERBASE", sessionId, ws, bb }; @@ -1451,7 +1456,7 @@ export class V3 { } if (this.isPuppeteerPage(page)) { - const cdp = await page.target().createCDPSession(); + const cdp = await page.createCDPSession(); const { frameTree } = await cdp.send("Page.getFrameTree"); this.logger({ category: "v3", @@ -1709,8 +1714,7 @@ export class V3 { return { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logAgentTaskStarted(); - SessionFileLogger.logAgentTaskEvent({ + SessionFileLogger.logAgentTaskStarted({ invocation: "Agent.execute", args: [instructionOrOptions], }); @@ -1817,8 +1821,7 @@ export class V3 { | AgentStreamExecuteOptions, ): Promise => withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logAgentTaskStarted(); - SessionFileLogger.logAgentTaskEvent({ + SessionFileLogger.logAgentTaskStarted({ invocation: "Agent.execute", args: [instructionOrOptions], }); diff --git a/packages/core/package.json b/packages/core/package.json index 621e16f7c..5bcc338c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@ai-sdk/provider": "^2.0.0", + "uuid": "^11.1.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.4.0", "@google/genai": "^1.22.0", @@ -82,6 +83,7 @@ }, "devDependencies": { "@playwright/test": "^1.42.1", + "@types/uuid": "^10.0.0", "eslint": "^9.16.0", "prettier": "^3.2.5", "tsup": "^8.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f563ab992..dbd5a8576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: playwright: specifier: ^1.52.0 version: 1.54.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 ws: specifier: ^8.18.0 version: 8.18.3(bufferutil@4.0.9) @@ -229,6 +232,9 @@ importers: '@playwright/test': specifier: ^1.42.1 version: 1.54.2 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 eslint: specifier: ^9.16.0 version: 9.25.1(jiti@1.21.7) @@ -6209,6 +6215,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -13615,6 +13625,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.0: {} + uuid@9.0.1: {} validate.io-array@1.0.6: {} From 121f463d6b3ea6fe6fd99a0950bb26a0ea01f4e4 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 15:20:40 -0800 Subject: [PATCH 10/32] move logging and formatting into flowLogger.ts and add logging for CUA llm calls --- .../core/lib/v3/agent/AnthropicCUAClient.ts | 25 ++++ packages/core/lib/v3/agent/GoogleCUAClient.ts | 25 ++++ packages/core/lib/v3/agent/OpenAICUAClient.ts | 25 ++++ packages/core/lib/v3/flowLogger.ts | 133 +++++++++++++++++- packages/core/lib/v3/llm/aisdk.ts | 34 ++--- packages/core/lib/v3/v3.ts | 3 + 6 files changed, 214 insertions(+), 31 deletions(-) diff --git a/packages/core/lib/v3/agent/AnthropicCUAClient.ts b/packages/core/lib/v3/agent/AnthropicCUAClient.ts index e1fbe5a1a..f47cb9265 100644 --- a/packages/core/lib/v3/agent/AnthropicCUAClient.ts +++ b/packages/core/lib/v3/agent/AnthropicCUAClient.ts @@ -19,6 +19,12 @@ import { mapKeyToPlaywright } from "./utils/cuaKeyMapping"; import { compressConversationImages } from "./utils/imageCompression"; import { toJsonSchema } from "../zodCompat"; import type { StagehandZodSchema } from "../zodCompat"; +import { + SessionFileLogger, + formatCuaPromptPreview, + formatCuaResponsePreview, +} from "../flowLogger"; +import { v7 as uuidv7 } from "uuid"; export type ResponseInputItem = AnthropicMessage | AnthropicToolResult; @@ -481,6 +487,15 @@ export class AnthropicCUAClient extends AgentClient { requestParams.thinking = thinking; } + // Log LLM request + const llmRequestId = uuidv7(); + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.getAction", + prompt: formatCuaPromptPreview(messages), + }); + const startTime = Date.now(); // Create the message using the Anthropic Messages API // @ts-expect-error - The Anthropic SDK types are stricter than what we need @@ -493,6 +508,16 @@ export class AnthropicCUAClient extends AgentClient { inference_time_ms: elapsedMs, }; + // Log LLM response + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.getAction", + output: formatCuaResponsePreview(response.content), + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }); + // Store the message ID for future use this.lastMessageId = response.id; diff --git a/packages/core/lib/v3/agent/GoogleCUAClient.ts b/packages/core/lib/v3/agent/GoogleCUAClient.ts index 62df670dc..ee651da5e 100644 --- a/packages/core/lib/v3/agent/GoogleCUAClient.ts +++ b/packages/core/lib/v3/agent/GoogleCUAClient.ts @@ -30,6 +30,12 @@ import { convertToolSetToFunctionDeclarations, } from "./utils/googleCustomToolHandler"; import { ToolSet } from "ai"; +import { + SessionFileLogger, + formatCuaPromptPreview, + formatCuaResponsePreview, +} from "../flowLogger"; +import { v7 as uuidv7 } from "uuid"; /** * Client for Google's Computer Use Assistant API @@ -300,6 +306,15 @@ export class GoogleCUAClient extends AgentClient { let lastError: Error | null = null; let response: GenerateContentResponse | null = null; + // Log LLM request + const llmRequestId = uuidv7(); + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.generateContent", + prompt: formatCuaPromptPreview(compressedHistory), + }); + for (let attempt = 0; attempt < maxRetries; attempt++) { try { // Add exponential backoff delay for retries @@ -357,6 +372,16 @@ export class GoogleCUAClient extends AgentClient { const elapsedMs = endTime - startTime; const { usageMetadata } = response; + // Log LLM response + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.generateContent", + output: formatCuaResponsePreview(response), + inputTokens: usageMetadata?.promptTokenCount, + outputTokens: usageMetadata?.candidatesTokenCount, + }); + // Process the response const result = await this.processResponse(response, logger); diff --git a/packages/core/lib/v3/agent/OpenAICUAClient.ts b/packages/core/lib/v3/agent/OpenAICUAClient.ts index 94ac18aa7..8c0b76ceb 100644 --- a/packages/core/lib/v3/agent/OpenAICUAClient.ts +++ b/packages/core/lib/v3/agent/OpenAICUAClient.ts @@ -14,6 +14,12 @@ import { ClientOptions } from "../types/public/model"; import { AgentClient } from "./AgentClient"; import { AgentScreenshotProviderError } from "../types/public/sdkErrors"; import { ToolSet } from "ai"; +import { + SessionFileLogger, + formatCuaPromptPreview, + formatCuaResponsePreview, +} from "../flowLogger"; +import { v7 as uuidv7 } from "uuid"; /** * Client for OpenAI's Computer Use Assistant API @@ -409,6 +415,15 @@ export class OpenAICUAClient extends AgentClient { requestParams.previous_response_id = previousResponseId; } + // Log LLM request + const llmRequestId = uuidv7(); + SessionFileLogger.logLlmRequest({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.getAction", + prompt: formatCuaPromptPreview(inputItems), + }); + const startTime = Date.now(); // Create the response using the OpenAI Responses API // @ts-expect-error - Force type to match what the OpenAI SDK expects @@ -423,6 +438,16 @@ export class OpenAICUAClient extends AgentClient { inference_time_ms: elapsedMs, }; + // Log LLM response + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.modelName, + operation: "CUA.getAction", + output: formatCuaResponsePreview(response.output), + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }); + // Store the response ID for future use this.lastResponseId = response.id; diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 812694645..952ba7a28 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -214,6 +214,126 @@ export function getConfigDir(): string { return path.resolve(process.cwd(), ".browserbase"); } +/** + * Format a prompt preview from LLM messages for logging. + * Extracts the last user message and formats it for display. + * Accepts generic message arrays to avoid tight coupling with specific LLM client types. + */ +export function formatLlmPromptPreview( + messages: Array<{ role: string; content: unknown }>, + options?: { toolCount?: number; hasSchema?: boolean }, +): string | undefined { + const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + if (!lastUserMsg) return undefined; + + let preview: string; + if (typeof lastUserMsg.content === "string") { + preview = lastUserMsg.content + .replace("instruction: ", "") + .replace("Instruction: ", ""); + } else if (Array.isArray(lastUserMsg.content)) { + preview = lastUserMsg.content + .map((c: unknown) => { + const item = c as { text?: string }; + return item.text ? item.text : "[img]"; + }) + .join(" "); + } else { + return undefined; + } + + // Add suffix for tools/schema + const suffixes: string[] = []; + if (options?.hasSchema) { + suffixes.push("schema"); + } + if (options?.toolCount && options.toolCount > 0) { + suffixes.push(`${options.toolCount} tools`); + } + + if (suffixes.length > 0) { + return `${preview} +{${suffixes.join(", ")}}`; + } + return preview; +} + +/** + * Extract a text preview from CUA-style messages (Anthropic, OpenAI, Google formats). + * Returns the last user message content truncated to maxLen characters. + */ +export function formatCuaPromptPreview( + messages: Array<{ role?: string; content?: unknown; parts?: unknown[] }>, + maxLen = 100, +): string | undefined { + const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + if (!lastUserMsg) return undefined; + + let text: string | undefined; + + // Handle string content directly + if (typeof lastUserMsg.content === "string") { + text = lastUserMsg.content; + } + // Handle Google-style parts array + else if (Array.isArray(lastUserMsg.parts)) { + const firstPart = lastUserMsg.parts[0] as { text?: string } | undefined; + text = firstPart?.text; + } + // Handle array content (Anthropic/OpenAI multipart) + else if (Array.isArray(lastUserMsg.content)) { + text = "[multipart message]"; + } + + if (!text) return undefined; + return text.length > maxLen ? text.slice(0, maxLen) : text; +} + +/** + * Format CUA response output for logging. + * Handles multiple formats flexibly: + * - Anthropic/OpenAI: Array of { type, text?, name? } + * - Google: { candidates: [{ content: { parts: [...] } }] } + * - Or direct array of parts + */ +export function formatCuaResponsePreview( + output: unknown, + maxLen = 100, +): string { + // Handle Google-style response with candidates + const googleParts = ( + output as { + candidates?: Array<{ + content?: { parts?: unknown[] }; + }>; + } + )?.candidates?.[0]?.content?.parts; + + const items: unknown[] = googleParts ?? (Array.isArray(output) ? output : []); + + const preview = items + .map((item) => { + const i = item as { + type?: string; + text?: string; + name?: string; + functionCall?: { name?: string }; + }; + // Text content (various formats) + if (i.text) return i.text.slice(0, 50); + if (i.type === "text" && typeof i.text === "string") + return i.text.slice(0, 50); + // Tool/function calls (various formats) + if (i.functionCall?.name) return `fn:${i.functionCall.name}`; + if (i.type === "tool_use" && i.name) return `tool_use:${i.name}`; + // Fallback to type if available + if (i.type) return `[${i.type}]`; + return "[item]"; + }) + .join(" "); + + return preview.length > maxLen ? preview.slice(0, maxLen) : preview; +} + /** * SessionFileLogger - static methods for flow logging with AsyncLocalStorage context */ @@ -463,13 +583,16 @@ export class SessionFileLogger { * Log task completion with metrics summary. Call this after agent.execute() completes. * Sets taskId back to null. */ - static logAgentTaskCompleted(): void { + static logAgentTaskCompleted(options?: { cacheHit?: boolean }): void { const ctx = loggerContext.getStore(); if (ctx && ctx.agentTaskStartTime) { const durationMs = Date.now() - ctx.agentTaskStartTime; const durationSec = (durationMs / 1000).toFixed(1); - const details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${ctx.agentTaskLlmRequests} LLM calls źœ›${ctx.agentTaskLlmInputTokens} ꜜ${ctx.agentTaskLlmOutputTokens} tokens | ${ctx.agentTaskCdpEvents} CDP msgs`; + const llmStats = options?.cacheHit + ? `${ctx.agentTaskLlmRequests} LLM calls [CACHE HIT, NO LLM NEEDED]` + : `${ctx.agentTaskLlmRequests} LLM calls źœ›${ctx.agentTaskLlmInputTokens} ꜜ${ctx.agentTaskLlmOutputTokens} tokens`; + const details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${llmStats} | ${ctx.agentTaskCdpEvents} CDP msgs`; const message = SessionFileLogger.buildLogLine( ctx, @@ -711,12 +834,11 @@ export class SessionFileLogger { // log outgoing LLM API requests requestId, model, - operation, prompt, }: { requestId: string; model: string; - operation: string; + operation: string; // reserved for future use prompt?: string; }, explicitCtx?: FlowLoggerContext | null, @@ -747,14 +869,13 @@ export class SessionFileLogger { // log incoming LLM API responses requestId, model, - operation, output, inputTokens, outputTokens, }: { requestId: string; model: string; - operation: string; + operation: string; // reserved for future use output?: string; inputTokens?: number; outputTokens?: number; diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index 344c30c1f..f1be3a392 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -17,7 +17,7 @@ import { v7 as uuidv7 } from "uuid"; import { LogLine } from "../types/public/logs"; import { AvailableModel } from "../types/public/model"; import { CreateChatCompletionOptions, LLMClient } from "./LLMClient"; -import { SessionFileLogger } from "../flowLogger"; +import { SessionFileLogger, formatLlmPromptPreview } from "../flowLogger"; export class AISdkClient extends LLMClient { public type = "aisdk" as const; @@ -133,23 +133,14 @@ export class AISdkClient extends LLMClient { if (options.response_model) { // Log LLM request for generateObject (extract) const llmRequestId = uuidv7(); - const lastUserMsg = options.messages - .filter((m) => m.role === "user") - .pop(); - const promptPreview = lastUserMsg - ? typeof lastUserMsg.content === "string" - ? lastUserMsg.content - .replace("instruction: ", "") - .replace("Instruction: ", "") - : lastUserMsg.content - .map((c) => ("text" in c ? c.text : "[img]")) - .join(" ") - : undefined; + const promptPreview = formatLlmPromptPreview(options.messages, { + hasSchema: true, + }); SessionFileLogger.logLlmRequest({ requestId: llmRequestId, model: this.model.modelId, operation: "generateObject", - prompt: `${promptPreview} +{schema}`, + prompt: promptPreview, }); try { @@ -263,22 +254,15 @@ export class AISdkClient extends LLMClient { // Log LLM request for generateText (act/observe) const llmRequestId = uuidv7(); - const lastUserMsg = options.messages.filter((m) => m.role === "user").pop(); - const promptPreview = lastUserMsg - ? typeof lastUserMsg.content === "string" - ? lastUserMsg.content.replace("instruction: ", "") - : lastUserMsg.content - .map((c) => ("text" in c ? c.text : "[img]")) - .join(" ") - : undefined; const toolCount = Object.keys(tools).length; + const promptPreview = formatLlmPromptPreview(options.messages, { + toolCount, + }); SessionFileLogger.logLlmRequest({ requestId: llmRequestId, model: this.model.modelId, operation: "generateText", - prompt: promptPreview - ? `${promptPreview}${toolCount > 0 ? ` +{${toolCount} tools}` : ""}` - : undefined, + prompt: promptPreview, }); const textResponse = await generateText({ diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 5d1423930..72b72aa59 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -1764,6 +1764,7 @@ export class V3 { if (cacheContext) { const replayed = await this.agentCache.tryReplay(cacheContext); if (replayed) { + SessionFileLogger.logAgentTaskCompleted({ cacheHit: true }); return replayed; } } @@ -1849,6 +1850,7 @@ export class V3 { const replayed = await this.agentCache.tryReplayAsStream(cacheContext); if (replayed) { + SessionFileLogger.logAgentTaskCompleted({ cacheHit: true }); return replayed; } } @@ -1881,6 +1883,7 @@ export class V3 { if (cacheContext) { const replayed = await this.agentCache.tryReplay(cacheContext); if (replayed) { + SessionFileLogger.logAgentTaskCompleted({ cacheHit: true }); return replayed; } } From cc0364258112b595ff773f0c33045cc6c42b859c Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 16:49:35 -0800 Subject: [PATCH 11/32] refactor to use pino for logging --- packages/core/lib/v3/flowLogger.ts | 1077 ++++++++++++++++------------ 1 file changed, 618 insertions(+), 459 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 952ba7a28..edecc2af0 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -1,61 +1,131 @@ import { AsyncLocalStorage } from "node:async_hooks"; import fs from "node:fs"; +import { Writable } from "node:stream"; import { v7 as uuidv7 } from "uuid"; import path from "node:path"; +import pino from "pino"; +import type { LanguageModelMiddleware } from "ai"; import type { V3Options } from "./types/public"; -const MAX_ARG_LENGTH = 500; +// ============================================================================= +// Constants +// ============================================================================= -// TODO: Eventually refactor this to use pino logging system + eventbus listeners -// library code should emit an event to the bus instead of calling log funcs directly, -// logger watchdog should listen to the bus and forward the events to pino / OTEL -// events should track their parent events automatically (maybe with AsyncLocalStorage or manual child_events / parent_id fields), -// so span context can be reconstructed by following the parent chain, e.g. CDPEvent.parent_id -> StagehandStepEvent.parent_id -> AgentTaskEvent.parent_id -> etc. -// we should wait for the Stagehand.eventBus to be ready before working on this refactor +const MAX_ARG_LENGTH = 500; +const MAX_LINE_LENGTH = 140; +const MAX_LLM_LINE_LENGTH = 500; + +// CDP events to filter from pretty output (still logged to JSONL) +const NOISY_CDP_EVENTS = [ + "Target.targetInfoChanged", + "Runtime.executionContextCreated", + "Runtime.executionContextDestroyed", + "Runtime.executionContextsCleared", + "Page.lifecycleEvent", + "Network.dataReceived", + "Network.loadingFinished", + "Network.requestWillBeSentExtraInfo", + "Network.responseReceivedExtraInfo", + "Network.requestWillBeSent", + "Network.responseReceived", +]; + +// ============================================================================= +// Types +// ============================================================================= + +type EventCategory = + | "AgentTask" + | "StagehandStep" + | "UnderstudyAction" + | "CDP" + | "LLM"; + +interface FlowEvent { + // Core identifiers (set via mixin from child logger bindings) + eventId: string; + sessionId: string; + taskId?: string | null; + stepId?: string | null; + stepLabel?: string | null; + actionId?: string | null; + actionLabel?: string | null; + + // Event classification + category: EventCategory; + event: "started" | "completed" | "call" | "message" | "request" | "response"; + method?: string; + msg?: string; + + // Event-specific payload (not truncated) + params?: unknown; + targetId?: string | null; + + // LLM event fields (for individual LLM request/response events only) + requestId?: string; // Correlation ID linking LLM request to response + model?: string; + prompt?: unknown; + output?: unknown; + inputTokens?: number; // Tokens for THIS specific LLM call + outputTokens?: number; // Tokens for THIS specific LLM call + + // Aggregate metrics (for completion events only - task/step/action) + metrics?: { + durationMs?: number; + llmRequests?: number; // Total LLM calls in this span + inputTokens?: number; // Total input tokens across all LLM calls + outputTokens?: number; // Total output tokens across all LLM calls + cdpEvents?: number; // Total CDP events in this span + }; +} -interface LogFile { - path: string; - stream: fs.WriteStream | null; +interface FlowLoggerMetrics { + taskStartTime?: number; + stepStartTime?: number; + actionStartTime?: number; + llmRequests: number; + llmInputTokens: number; + llmOutputTokens: number; + cdpEvents: number; } export interface FlowLoggerContext { + logger: pino.Logger; + metrics: FlowLoggerMetrics; sessionId: string; sessionDir: string; configDir: string; - logFiles: { - agent: LogFile; - stagehand: LogFile; - understudy: LogFile; - cdp: LogFile; - llm: LogFile; - }; initPromise: Promise; initialized: boolean; - // Flow context state for each tracing span (session -> task -> step -> action -> cdp,llm) - agentTaskId: string | null; - stagehandStepId: string | null; - understudyActionId: string | null; - stagehandStepLabel: string | null; - understudyActionLabel: string | null; - stagehandStepStartTime: number | null; - understudyActionStartTime: number | null; - // Task metrics - agentTaskStartTime: number | null; - agentTaskLlmRequests: number; - agentTaskCdpEvents: number; - agentTaskLlmInputTokens: number; - agentTaskLlmOutputTokens: number; + // Current span context (mutable, injected via mixin) + taskId: string | null; + stepId: string | null; + stepLabel: string | null; + actionId: string | null; + actionLabel: string | null; + // File handles for pretty streams + fileStreams: { + agent: fs.WriteStream | null; + stagehand: fs.WriteStream | null; + understudy: fs.WriteStream | null; + cdp: fs.WriteStream | null; + llm: fs.WriteStream | null; + jsonl: fs.WriteStream | null; + }; } const loggerContext = new AsyncLocalStorage(); -function truncate(value: string): string { - value = value.replace(/\s+/g, " "); // replace newlines, tabs, etc. with space - value = value.replace(/\s+/g, " "); // replace repeated spaces with single space - if (value.length <= MAX_ARG_LENGTH) { +// ============================================================================= +// Formatting Utilities (used by pretty streams) +// ============================================================================= + +function truncate(value: string, maxLen = MAX_ARG_LENGTH): string { + value = value.replace(/\s+/g, " "); + if (value.length <= maxLen) { return value; } - return `${value.slice(0, MAX_ARG_LENGTH)}…`; + return `${value.slice(0, maxLen)}…`; } /** @@ -63,8 +133,6 @@ function truncate(value: string): string { * Transforms: frameId:363F03EB7E3795ACB434672C35095EF8 → frameId:363F…5EF8 */ function truncateCdpIds(value: string): string { - // Match patterns like: id:, Id:, frameId:, loaderId:, etc. followed by optional quote and 32-char hex ID - // The ID must be exactly 32 uppercase hex characters [0-9A-F] return value.replace( /([iI]d:?"?)([0-9A-F]{32})(?="?[,})\s]|$)/g, (_match, prefix: string, id: string) => @@ -74,11 +142,10 @@ function truncateCdpIds(value: string): string { /** * Truncate conversation/prompt strings showing first 30 chars + ... + last 100 chars - * This helps see both the beginning context and the most recent part of growing conversations */ function truncateConversation(value: string): string { - value = value.replace(/\s+/g, " "); // normalize whitespace - const maxLen = 130; // 30 + 100 + value = value.replace(/\s+/g, " "); + const maxLen = 130; if (value.length <= maxLen) { return value; } @@ -129,21 +196,20 @@ function formatArgs(args?: unknown | unknown[]): string { return rendered.join(", "); } -function formatTag( - label: string, - id: string | null, - icon: string | null, -): string { - if (!id) return `⤑`; // omit the part if the id is null, we're not in an active task/step/action - // return `[${label} ${icon ? icon : ""} #${shortId(id)}]`; - return `[${icon || ""} #${shortId(id)}${label ? " " : ""}${label || ""}]`; -} - -function shortId(id: string | null): string { +function shortId(id: string | null | undefined): string { if (!id) return "-"; return id.slice(-4); } +function formatTag( + label: string | null | undefined, + id: string | null | undefined, + icon: string, +): string { + if (!id) return `⤑`; + return `[${icon} #${shortId(id)}${label ? " " : ""}${label || ""}]`; +} + let nonce = 0; function formatTimestamp(): string { @@ -202,6 +268,220 @@ function sanitizeOptions(options: V3Options): Record { return sanitizeValue({ ...options }) as Record; } +/** + * Remove unescaped quotes from a string for cleaner log output + */ +function removeQuotes(str: string): string { + return str + .replace(/([^\\])["']/g, "$1") + .replace(/^["']|["']$/g, "") + .trim(); +} + +// ============================================================================= +// Pretty Formatting (converts FlowEvent to human-readable log line) +// ============================================================================= + +function prettifyEvent(event: FlowEvent): string | null { + const parts: string[] = []; + + // Build context tags based on category + if (event.category === "AgentTask") { + parts.push(formatTag("", event.taskId, "šŸ…°")); + } else if (event.category === "StagehandStep") { + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + } else if (event.category === "UnderstudyAction") { + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + } else if (event.category === "CDP") { + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + parts.push(formatTag("CDP", event.targetId, "šŸ…²")); + } else if (event.category === "LLM") { + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag("LLM", event.requestId, "🧠")); + } + + // Build details based on event type + let details = ""; + + if (event.category === "AgentTask") { + if (event.event === "started") { + const argsStr = event.params ? formatArgs(event.params) : ""; + details = `ā–· ${event.method}(${argsStr})`; + } else if (event.event === "completed") { + const m = event.metrics; + const durationSec = m?.durationMs + ? (m.durationMs / 1000).toFixed(1) + : "?"; + const llmStats = m + ? `${m.llmRequests} LLM calls źœ›${m.inputTokens} ꜜ${m.outputTokens} tokens` + : ""; + const cdpStats = m ? `${m.cdpEvents} CDP msgs` : ""; + details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${llmStats} | ${cdpStats}`; + } + } else if (event.category === "StagehandStep") { + if (event.event === "started") { + const argsStr = event.params ? formatArgs(event.params) : ""; + details = `ā–· ${event.method}(${argsStr})`; + } else if (event.event === "completed") { + const durationSec = event.metrics?.durationMs + ? (event.metrics.durationMs / 1000).toFixed(2) + : "?"; + details = `āœ“ ${event.stepLabel || "STEP"} completed in ${durationSec}s`; + } + } else if (event.category === "UnderstudyAction") { + if (event.event === "started") { + const argsStr = event.params ? formatArgs(event.params) : ""; + details = `ā–· ${event.method}(${argsStr})`; + } else if (event.event === "completed") { + const durationSec = event.metrics?.durationMs + ? (event.metrics.durationMs / 1000).toFixed(2) + : "?"; + details = `āœ“ ${event.actionLabel || "ACTION"} completed in ${durationSec}s`; + } + } else if (event.category === "CDP") { + const argsStr = event.params ? formatArgs(event.params) : ""; + const call = argsStr ? `${event.method}(${argsStr})` : `${event.method}()`; + if (event.event === "call") { + details = `āµ ${call}`; + } else if (event.event === "message") { + details = `ā“ ${call}`; + } + } else if (event.category === "LLM") { + if (event.event === "request") { + const promptStr = event.prompt + ? ` ${truncateConversation(String(event.prompt))}` + : ""; + details = `${event.model} ā“${promptStr}`; + } else if (event.event === "response") { + const tokens = + event.inputTokens !== undefined || event.outputTokens !== undefined + ? ` źœ›${event.inputTokens ?? 0} ꜜ${event.outputTokens ?? 0} |` + : ""; + const outputStr = event.output + ? ` ${truncateConversation(String(event.output))}` + : ""; + details = `${event.model} ↳${tokens}${outputStr}`; + } + } + + if (!details) return null; + + const fullLine = `${formatTimestamp()} ${parts.join(" ")} ${details}`; + const withoutQuotes = removeQuotes(fullLine); + + // Apply category-specific truncation + if (event.category === "CDP") { + const truncatedIds = truncateCdpIds(withoutQuotes); + return truncatedIds.length > MAX_LINE_LENGTH + ? `${truncatedIds.slice(0, MAX_LINE_LENGTH - 1)}…` + : truncatedIds; + } else if (event.category === "LLM") { + return withoutQuotes.length > MAX_LLM_LINE_LENGTH + ? `${withoutQuotes.slice(0, MAX_LLM_LINE_LENGTH - 1)}…` + : withoutQuotes; + } + + return withoutQuotes; +} + +/** + * Check if a CDP event should be filtered from pretty output + */ +function shouldFilterCdpEvent(event: FlowEvent): boolean { + if (event.category !== "CDP") return false; + + // Filter .enable calls + if (event.method?.endsWith(".enable") || event.method === "enable") { + return true; + } + + // Filter noisy message events + if (event.event === "message" && NOISY_CDP_EVENTS.includes(event.method!)) { + return true; + } + + return false; +} + +// ============================================================================= +// Stream Creation (inline in this file) +// ============================================================================= + +/** + * Create a JSONL stream that writes full events verbatim + */ +function createJsonlStream(ctx: FlowLoggerContext): Writable { + return new Writable({ + objectMode: true, + write(chunk: string, _encoding, callback) { + const stream = ctx.fileStreams.jsonl; + if (!ctx.initialized || !stream || stream.destroyed || !stream.writable) { + callback(); + return; + } + // Pino already adds a newline, so just write the chunk as-is + stream.write(chunk, callback); + }, + }); +} + +/** + * Create a pretty stream for a specific category + */ +function createPrettyStream( + ctx: FlowLoggerContext, + category: EventCategory, + streamKey: keyof FlowLoggerContext["fileStreams"], +): Writable { + return new Writable({ + objectMode: true, + write(chunk: string, _encoding, callback) { + const stream = ctx.fileStreams[streamKey]; + if (!ctx.initialized || !stream || stream.destroyed || !stream.writable) { + callback(); + return; + } + + try { + const event = JSON.parse(chunk) as FlowEvent; + + // Category routing + if (event.category !== category) { + callback(); + return; + } + + // Filter noisy CDP events from pretty output + if (shouldFilterCdpEvent(event)) { + callback(); + return; + } + + // Pretty format the event + const line = prettifyEvent(event); + if (!line) { + callback(); + return; + } + + stream.write(line + "\n", callback); + } catch { + callback(); + } + }, + }); +} + +// ============================================================================= +// Public Helpers (used by external callers) +// ============================================================================= + /** * Get the config directory from environment or use default */ @@ -210,14 +490,11 @@ export function getConfigDir(): string { if (fromEnv) { return path.resolve(fromEnv); } - // in the future maybe we use a centralized config directory ~/.config/browserbase return path.resolve(process.cwd(), ".browserbase"); } /** * Format a prompt preview from LLM messages for logging. - * Extracts the last user message and formats it for display. - * Accepts generic message arrays to avoid tight coupling with specific LLM client types. */ export function formatLlmPromptPreview( messages: Array<{ role: string; content: unknown }>, @@ -242,7 +519,6 @@ export function formatLlmPromptPreview( return undefined; } - // Add suffix for tools/schema const suffixes: string[] = []; if (options?.hasSchema) { suffixes.push("schema"); @@ -258,29 +534,33 @@ export function formatLlmPromptPreview( } /** - * Extract a text preview from CUA-style messages (Anthropic, OpenAI, Google formats). - * Returns the last user message content truncated to maxLen characters. + * Extract a text preview from CUA-style messages. + * Accepts various message formats (Anthropic, OpenAI, Google). */ export function formatCuaPromptPreview( - messages: Array<{ role?: string; content?: unknown; parts?: unknown[] }>, + messages: unknown[], maxLen = 100, ): string | undefined { - const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + // Find last user message - handle various formats + const lastUserMsg = messages + .filter((m) => { + const msg = m as { role?: string }; + return msg.role === "user"; + }) + .pop() as + | { role?: string; content?: unknown; parts?: unknown[] } + | undefined; + if (!lastUserMsg) return undefined; let text: string | undefined; - // Handle string content directly if (typeof lastUserMsg.content === "string") { text = lastUserMsg.content; - } - // Handle Google-style parts array - else if (Array.isArray(lastUserMsg.parts)) { + } else if (Array.isArray(lastUserMsg.parts)) { const firstPart = lastUserMsg.parts[0] as { text?: string } | undefined; text = firstPart?.text; - } - // Handle array content (Anthropic/OpenAI multipart) - else if (Array.isArray(lastUserMsg.content)) { + } else if (Array.isArray(lastUserMsg.content)) { text = "[multipart message]"; } @@ -290,16 +570,11 @@ export function formatCuaPromptPreview( /** * Format CUA response output for logging. - * Handles multiple formats flexibly: - * - Anthropic/OpenAI: Array of { type, text?, name? } - * - Google: { candidates: [{ content: { parts: [...] } }] } - * - Or direct array of parts */ export function formatCuaResponsePreview( output: unknown, maxLen = 100, ): string { - // Handle Google-style response with candidates const googleParts = ( output as { candidates?: Array<{ @@ -318,14 +593,11 @@ export function formatCuaResponsePreview( name?: string; functionCall?: { name?: string }; }; - // Text content (various formats) if (i.text) return i.text.slice(0, 50); if (i.type === "text" && typeof i.text === "string") return i.text.slice(0, 50); - // Tool/function calls (various formats) if (i.functionCall?.name) return `fn:${i.functionCall.name}`; if (i.type === "tool_use" && i.name) return `tool_use:${i.name}`; - // Fallback to type if available if (i.type) return `[${i.type}]`; return "[item]"; }) @@ -334,9 +606,10 @@ export function formatCuaResponsePreview( return preview.length > maxLen ? preview.slice(0, maxLen) : preview; } -/** - * SessionFileLogger - static methods for flow logging with AsyncLocalStorage context - */ +// ============================================================================= +// SessionFileLogger - Main API +// ============================================================================= + export class SessionFileLogger { /** * Initialize a new logging context. Call this at the start of a session. @@ -345,46 +618,37 @@ export class SessionFileLogger { const configDir = getConfigDir(); const sessionDir = path.join(configDir, "sessions", sessionId); + // Create context with placeholder logger (will be replaced after streams init) const ctx: FlowLoggerContext = { + logger: pino({ level: "silent" }), // Placeholder, replaced below + metrics: { + llmRequests: 0, + llmInputTokens: 0, + llmOutputTokens: 0, + cdpEvents: 0, + }, sessionId, sessionDir, configDir, - logFiles: { - agent: { - path: path.join(sessionDir, "agent_events.log"), - stream: null, - }, - stagehand: { - path: path.join(sessionDir, "stagehand_events.log"), - stream: null, - }, - understudy: { - path: path.join(sessionDir, "understudy_events.log"), - stream: null, - }, - cdp: { path: path.join(sessionDir, "cdp_events.log"), stream: null }, - llm: { path: path.join(sessionDir, "llm_events.log"), stream: null }, - }, initPromise: Promise.resolve(), initialized: false, - // sessionId is set once at init and never changes - // taskId is null until agent.execute starts - agentTaskId: null, - stagehandStepId: null, - stagehandStepLabel: null, - understudyActionId: null, - understudyActionLabel: null, - understudyActionStartTime: null, - stagehandStepStartTime: null, - // Task metrics - null until a task starts - agentTaskStartTime: null, - agentTaskLlmRequests: 0, - agentTaskCdpEvents: 0, - agentTaskLlmInputTokens: 0, - agentTaskLlmOutputTokens: 0, + // Span context - mutable, injected into every log via mixin + taskId: null, + stepId: null, + stepLabel: null, + actionId: null, + actionLabel: null, + fileStreams: { + agent: null, + stagehand: null, + understudy: null, + cdp: null, + llm: null, + jsonl: null, + }, }; - // Store init promise for awaiting in writeToFile + // Store init promise for awaiting in log methods ctx.initPromise = SessionFileLogger.initAsync(ctx, v3Options); loggerContext.enterWith(ctx); @@ -420,39 +684,66 @@ export class SessionFileLogger { // Symlink creation can fail on Windows or due to permissions } - for (const logFile of Object.values(ctx.logFiles)) { - try { - logFile.stream = fs.createWriteStream(logFile.path, { flags: "a" }); - } catch { - // Fail silently - } - } + // Create file streams + ctx.fileStreams.agent = fs.createWriteStream( + path.join(ctx.sessionDir, "agent_events.log"), + { flags: "a" }, + ); + ctx.fileStreams.stagehand = fs.createWriteStream( + path.join(ctx.sessionDir, "stagehand_events.log"), + { flags: "a" }, + ); + ctx.fileStreams.understudy = fs.createWriteStream( + path.join(ctx.sessionDir, "understudy_events.log"), + { flags: "a" }, + ); + ctx.fileStreams.cdp = fs.createWriteStream( + path.join(ctx.sessionDir, "cdp_events.log"), + { flags: "a" }, + ); + ctx.fileStreams.llm = fs.createWriteStream( + path.join(ctx.sessionDir, "llm_events.log"), + { flags: "a" }, + ); + ctx.fileStreams.jsonl = fs.createWriteStream( + path.join(ctx.sessionDir, "session_events.jsonl"), + { flags: "a" }, + ); ctx.initialized = true; - } catch { - // Fail silently - } - } - private static async writeToFile( - logFile: LogFile, - message: string, - ): Promise { - const ctx = loggerContext.getStore(); - if (!ctx) return; - - await ctx.initPromise; - - if (!ctx.initialized || !logFile.stream) { - return; - } - - try { - logFile.stream.write(message + "\n", (err) => { - if (err) { - // Fail silently - } - }); + // Create pino logger with multistream + const streams: pino.StreamEntry[] = [ + // JSONL stream - full events + { stream: createJsonlStream(ctx) }, + // Pretty streams per category + { stream: createPrettyStream(ctx, "AgentTask", "agent") }, + { stream: createPrettyStream(ctx, "StagehandStep", "stagehand") }, + { stream: createPrettyStream(ctx, "UnderstudyAction", "understudy") }, + { stream: createPrettyStream(ctx, "CDP", "cdp") }, + { stream: createPrettyStream(ctx, "LLM", "llm") }, + ]; + + // Create logger with mixin that injects span context from AsyncLocalStorage + ctx.logger = pino( + { + level: "info", + // Mixin adds eventId and current span context to every log + mixin() { + const store = loggerContext.getStore(); + return { + eventId: uuidv7(), + sessionId: store?.sessionId, + taskId: store?.taskId, + stepId: store?.stepId, + stepLabel: store?.stepLabel, + actionId: store?.actionId, + actionLabel: store?.actionLabel, + }; + }, + }, + pino.multistream(streams), + ); } catch { // Fail silently } @@ -464,16 +755,16 @@ export class SessionFileLogger { await ctx.initPromise; + // Log task completion if there's an active task + SessionFileLogger.logAgentTaskCompleted(); + const closePromises: Promise[] = []; - for (const logFile of Object.values(ctx.logFiles)) { - if (logFile.stream) { + for (const stream of Object.values(ctx.fileStreams)) { + if (stream) { closePromises.push( new Promise((resolve) => { - logFile.stream!.end(() => { - logFile.stream = null; - resolve(); - }); + stream.end(() => resolve()); }), ); } @@ -484,8 +775,6 @@ export class SessionFileLogger { } catch { // Fail silently } - - SessionFileLogger.logAgentTaskCompleted(); } static get sessionId(): string | null { @@ -497,55 +786,18 @@ export class SessionFileLogger { } /** - * Get the current logger context object. This can be captured and passed - * to callbacks that run outside the AsyncLocalStorage context (like WebSocket handlers). - * Updates to the context (taskId, stepId, etc.) will be visible through this reference. + * Get the current logger context object. */ static getContext(): FlowLoggerContext | null { return loggerContext.getStore() ?? null; } - private static buildLogLine( - ctx: FlowLoggerContext, - options: { - includeTask?: boolean; - includeStep?: boolean; - includeAction?: boolean; - }, - details: string, - ): string { - const { - includeAction = true, - includeStep = true, - includeTask = true, - } = options; - const parts: string[] = []; - if (includeTask) { - parts.push(formatTag("", ctx.agentTaskId, "šŸ…°")); - } - if (includeStep) { - parts.push(formatTag(ctx.stagehandStepLabel, ctx.stagehandStepId, "šŸ†‚")); - } - if (includeAction) { - parts.push( - formatTag(ctx.understudyActionLabel, ctx.understudyActionId, "šŸ†„"), - ); - } - // parts[parts.length - 1] = parts[parts.length - 1].replace("[", "⟦").replace("]", "⟧"); // try and higlight the last tag so it stands out visually (imperfect) - const full_line = `${formatTimestamp()} ${parts.join(" ")} ${details}`; - - // Remove unescaped " and ' characters, but leave those preceded by a backslash (\) - const without_quotes = full_line - .replace(/([^\\])["']/g, "$1") // remove " or ' if not preceded by \ - .replace(/^["']|["']$/g, "") // also remove leading/trailing " or ' at string ends (not preceded by \) - .trim(); - - return without_quotes; - } + // =========================================================================== + // Agent Task Events + // =========================================================================== /** - * Start a new task and log it. Call this when agent.execute() begins. - * Sets taskId to a new UUID, resets metrics, and logs the start event. + * Start a new task and log it. */ static logAgentTaskStarted({ invocation, @@ -558,86 +810,71 @@ export class SessionFileLogger { if (!ctx) return; // Set up task context - ctx.agentTaskId = uuidv7(); - ctx.stagehandStepId = null; - ctx.stagehandStepLabel = null; - ctx.stagehandStepStartTime = null; - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - ctx.agentTaskStartTime = Date.now(); - ctx.agentTaskLlmRequests = 0; - ctx.agentTaskCdpEvents = 0; - ctx.agentTaskLlmInputTokens = 0; - ctx.agentTaskLlmOutputTokens = 0; - - // Log the start event - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: false, includeAction: false }, - `ā–· ${invocation}(${formatArgs(args)})`, - ); - SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); + ctx.taskId = uuidv7(); + ctx.stepId = null; + ctx.stepLabel = null; + ctx.actionId = null; + ctx.actionLabel = null; + + // Reset metrics for new task + ctx.metrics = { + taskStartTime: Date.now(), + llmRequests: 0, + llmInputTokens: 0, + llmOutputTokens: 0, + cdpEvents: 0, + }; + + ctx.logger.info({ + category: "AgentTask", + event: "started", + method: invocation, + params: args, + } as FlowEvent); } /** - * Log task completion with metrics summary. Call this after agent.execute() completes. - * Sets taskId back to null. + * Log task completion with metrics summary. */ static logAgentTaskCompleted(options?: { cacheHit?: boolean }): void { const ctx = loggerContext.getStore(); - if (ctx && ctx.agentTaskStartTime) { - const durationMs = Date.now() - ctx.agentTaskStartTime; - const durationSec = (durationMs / 1000).toFixed(1); - - const llmStats = options?.cacheHit - ? `${ctx.agentTaskLlmRequests} LLM calls [CACHE HIT, NO LLM NEEDED]` - : `${ctx.agentTaskLlmRequests} LLM calls źœ›${ctx.agentTaskLlmInputTokens} ꜜ${ctx.agentTaskLlmOutputTokens} tokens`; - const details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${llmStats} | ${ctx.agentTaskCdpEvents} CDP msgs`; - - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: false, includeAction: false }, - details, - ); - SessionFileLogger.writeToFile(ctx.logFiles.agent, message).then(); - - // Clear task context - no active task - ctx.agentTaskId = null; - ctx.stagehandStepId = null; - ctx.understudyActionId = null; - ctx.stagehandStepLabel = null; - ctx.understudyActionLabel = null; - ctx.understudyActionStartTime = null; - ctx.agentTaskStartTime = null; - } - } - - static logUnderstudyActionCompleted(): void { - const ctx = loggerContext.getStore(); - if (!ctx) return; + if (!ctx || !ctx.metrics.taskStartTime) return; + + const durationMs = Date.now() - ctx.metrics.taskStartTime; + + const event: Partial = { + category: "AgentTask", + event: "completed", + method: "Agent.execute", + metrics: { + durationMs, + llmRequests: ctx.metrics.llmRequests, + inputTokens: ctx.metrics.llmInputTokens, + outputTokens: ctx.metrics.llmOutputTokens, + cdpEvents: ctx.metrics.cdpEvents, + }, + }; - const durationMs = ctx.understudyActionStartTime - ? Date.now() - ctx.understudyActionStartTime - : 0; - const durationSec = (durationMs / 1000).toFixed(2); + if (options?.cacheHit) { + event.msg = "CACHE HIT, NO LLM NEEDED"; + } - const details = `āœ“ ${ctx.understudyActionLabel} completed in ${durationSec}s`; - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: true }, - details, - ); - SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); + ctx.logger.info(event); - // Clear action context - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; + // Clear task context + ctx.taskId = null; + ctx.stepId = null; + ctx.stepLabel = null; + ctx.actionId = null; + ctx.actionLabel = null; + ctx.metrics.taskStartTime = undefined; } - // --- Logging methods --- + // =========================================================================== + // Stagehand Step Events + // =========================================================================== static logStagehandStepEvent({ - // log stagehand-level high-level API calls like: Act, Observe, Extract, Navigate invocation, args, label, @@ -649,54 +886,50 @@ export class SessionFileLogger { const ctx = loggerContext.getStore(); if (!ctx) return uuidv7(); - // leave parent task id null/untouched for now, stagehand steps called directly dont always have a parent task, maybe worth randomizing the task id when a step starts to make it easier to correlate steps to tasks? - // ctx.agentTaskId = uuidv7(); - - ctx.stagehandStepId = uuidv7(); - ctx.stagehandStepLabel = label.toUpperCase(); - ctx.stagehandStepStartTime = Date.now(); - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - ctx.understudyActionStartTime = null; - - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: false }, - `ā–· ${invocation}(${formatArgs(args)})`, - ); - SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); - - return ctx.stagehandStepId; + // Set up step context + ctx.stepId = uuidv7(); + ctx.stepLabel = label.toUpperCase(); + ctx.actionId = null; + ctx.actionLabel = null; + ctx.metrics.stepStartTime = Date.now(); + + ctx.logger.info({ + category: "StagehandStep", + event: "started", + method: invocation, + params: args, + } as FlowEvent); + + return ctx.stepId; } static logStagehandStepCompleted(): void { const ctx = loggerContext.getStore(); - if (!ctx || !ctx.stagehandStepId) return; + if (!ctx || !ctx.stepId) return; - const durationMs = ctx.stagehandStepStartTime - ? Date.now() - ctx.stagehandStepStartTime + const durationMs = ctx.metrics.stepStartTime + ? Date.now() - ctx.metrics.stepStartTime : 0; - const durationSec = (durationMs / 1000).toFixed(2); - const label = ctx.stagehandStepLabel || "STEP"; - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: false }, - `āœ“ ${label} completed in ${durationSec}s`, - ); - SessionFileLogger.writeToFile(ctx.logFiles.stagehand, message).then(); + ctx.logger.info({ + category: "StagehandStep", + event: "completed", + metrics: { durationMs }, + } as FlowEvent); // Clear step context - ctx.stagehandStepId = null; - ctx.stagehandStepLabel = null; - ctx.stagehandStepStartTime = null; - ctx.understudyActionId = null; - ctx.understudyActionLabel = null; - ctx.understudyActionStartTime = null; + ctx.stepId = null; + ctx.stepLabel = null; + ctx.actionId = null; + ctx.actionLabel = null; + ctx.metrics.stepStartTime = undefined; } + // =========================================================================== + // Understudy Action Events + // =========================================================================== + static logUnderstudyActionEvent({ - // log understudy-level browser action calls like: Click, Type, Scroll actionType, target, args, @@ -708,36 +941,54 @@ export class SessionFileLogger { const ctx = loggerContext.getStore(); if (!ctx) return uuidv7(); - // THESE ARE NOT NEEDED, it's possible for understudy methods to be called directly without going through stagehand.act/observe/extract or agent.execute - // ctx.agentTaskId = ctx.agentTaskId || uuidv7(); - // ctx.stagehandStepId = ctx.stagehandStepId || uuidv7(); - - ctx.understudyActionId = uuidv7(); - ctx.understudyActionLabel = actionType + // Set up action context + ctx.actionId = uuidv7(); + ctx.actionLabel = actionType .toUpperCase() .replace("UNDERSTUDY.", "") .replace("PAGE.", ""); + ctx.metrics.actionStartTime = Date.now(); - ctx.understudyActionStartTime = Date.now(); + const params: Record = {}; + if (target) params.target = target; + if (args) params.args = args; - const details: string[] = []; - if (target) details.push(`target=${target}`); - const argString = formatArgs(args); - if (argString) details.push(`args=[${argString}]`); + ctx.logger.info({ + category: "UnderstudyAction", + event: "started", + method: actionType, + params: Object.keys(params).length > 0 ? params : undefined, + } as FlowEvent); - const message = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: true }, - `ā–· ${actionType}(${details.join(", ")})`, - ); - SessionFileLogger.writeToFile(ctx.logFiles.understudy, message).then(); + return ctx.actionId; + } - return ctx.understudyActionId; + static logUnderstudyActionCompleted(): void { + const ctx = loggerContext.getStore(); + if (!ctx || !ctx.actionId) return; + + const durationMs = ctx.metrics.actionStartTime + ? Date.now() - ctx.metrics.actionStartTime + : 0; + + ctx.logger.info({ + category: "UnderstudyAction", + event: "completed", + metrics: { durationMs }, + } as FlowEvent); + + // Clear action context + ctx.actionId = null; + ctx.actionLabel = null; + ctx.metrics.actionStartTime = undefined; } + // =========================================================================== + // CDP Events + // =========================================================================== + static logCdpCallEvent( { - // log low-level CDP browser calls and events like: Page.getDocument, Runtime.evaluate, etc. method, params, targetId, @@ -752,34 +1003,20 @@ export class SessionFileLogger { if (!ctx) return; // Track CDP events for task metrics - ctx.agentTaskCdpEvents++; + ctx.metrics.cdpEvents++; - // Filter out CDP enable calls - they're too noisy and not useful for debugging - if (method.endsWith(".enable") || method === "enable") { - return; - } - - const argsStr = params ? formatArgs(params) : ""; - const call = argsStr ? `${method}(${argsStr})` : `${method}()`; - const details = `${formatTag("CDP", targetId || "0000", "šŸ…²")} āµ ${call}`; - - const rawMessage = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: true }, - details, - ); - const truncatedIds = truncateCdpIds(rawMessage); - const message = - truncatedIds.length > 140 - ? `${truncatedIds.slice(0, 137)}…` - : truncatedIds; - - SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); + // Log full event - filtering happens in pretty stream + ctx.logger.info({ + category: "CDP", + event: "call", + method, + params, + targetId, + } as FlowEvent); } static logCdpMessageEvent( { - // log CDP events received asynchronously from the browser method, params, targetId, @@ -793,52 +1030,29 @@ export class SessionFileLogger { const ctx = explicitCtx ?? loggerContext.getStore(); if (!ctx) return; - // Filter out noisy events that aren't useful for debugging - const noisyEvents = [ - "Target.targetInfoChanged", - "Runtime.executionContextCreated", - "Runtime.executionContextDestroyed", - "Runtime.executionContextsCleared", - "Page.lifecycleEvent", - "Network.dataReceived", - "Network.loadingFinished", - "Network.requestWillBeSentExtraInfo", - "Network.responseReceivedExtraInfo", - "Network.requestWillBeSent", - "Network.responseReceived", - ]; - if (noisyEvents.includes(method)) { - return; - } - - const argsStr = params ? formatArgs(params) : ""; - const event = argsStr ? `${method}(${argsStr})` : `${method}`; - const details = `${formatTag("CDP", targetId ? targetId.slice(-4) : "????", "šŸ…²")} ā“ ${event}`; - - const rawMessage = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: true }, - details, - ); - const truncatedIds = truncateCdpIds(rawMessage); - const message = - truncatedIds.length > 140 - ? `${truncatedIds.slice(0, 137)}…` - : truncatedIds; - - SessionFileLogger.writeToFile(ctx.logFiles.cdp, message).then(); + // Log full event - filtering happens in pretty stream + ctx.logger.info({ + category: "CDP", + event: "message", + method, + params, + targetId, + } as FlowEvent); } + // =========================================================================== + // LLM Events + // =========================================================================== + static logLlmRequest( { - // log outgoing LLM API requests requestId, model, prompt, }: { requestId: string; model: string; - operation: string; // reserved for future use + operation: string; prompt?: string; }, explicitCtx?: FlowLoggerContext | null, @@ -847,26 +1061,20 @@ export class SessionFileLogger { if (!ctx) return; // Track LLM requests for task metrics - ctx.agentTaskLlmRequests++; + ctx.metrics.llmRequests++; - const promptStr = prompt ? ` ${truncateConversation(prompt)}` : ""; - const details = `${formatTag("LLM", requestId, "🧠")} ${model} ā“${promptStr}`; - - const rawMessage = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: false }, - details, - ); - // Temporarily increased limit for debugging - const message = - rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; - - SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); + ctx.logger.info({ + category: "LLM", + event: "request", + requestId, + method: "LLM.request", + model, + prompt, + }); } static logLlmResponse( { - // log incoming LLM API responses requestId, model, output, @@ -875,7 +1083,7 @@ export class SessionFileLogger { }: { requestId: string; model: string; - operation: string; // reserved for future use + operation: string; output?: string; inputTokens?: number; outputTokens?: number; @@ -886,80 +1094,44 @@ export class SessionFileLogger { if (!ctx) return; // Track tokens for task metrics - ctx.agentTaskLlmInputTokens += inputTokens ?? 0; - ctx.agentTaskLlmOutputTokens += outputTokens ?? 0; + ctx.metrics.llmInputTokens += inputTokens ?? 0; + ctx.metrics.llmOutputTokens += outputTokens ?? 0; - const tokens = - inputTokens !== undefined || outputTokens !== undefined - ? ` źœ›${inputTokens ?? 0} ꜜ${outputTokens ?? 0} |` - : ""; - const outputStr = output ? ` ${truncateConversation(output)}` : ""; - const details = `${formatTag("LLM", requestId, "🧠")} ${model} ↳${tokens}${outputStr}`; - - const rawMessage = SessionFileLogger.buildLogLine( - ctx, - { includeTask: true, includeStep: true, includeAction: false }, - details, - ); - // Temporarily increased limit for debugging - const message = - rawMessage.length > 500 ? `${rawMessage.slice(0, 499)}…` : rawMessage; - - SessionFileLogger.writeToFile(ctx.logFiles.llm, message).then(); + ctx.logger.info({ + category: "LLM", + event: "response", + requestId, + method: "LLM.response", + model, + output, + inputTokens, + outputTokens, + }); } + // =========================================================================== + // LLM Logging Middleware + // =========================================================================== + /** * Create middleware for wrapping language models with LLM call logging. - * Use with wrapLanguageModel from the AI SDK. - * This is vibecoded and a bit messy, but it's a quick way to get LLM - * logging working and in a useful format for devs watching the terminal in realtime. - * TODO: Refactor this to use a proper span-based tracing system like OpenTelemetry and clean up/reduce all the parsing/reformatting logic. + * Returns a partial middleware object compatible with AI SDK's wrapLanguageModel. */ - static createLlmLoggingMiddleware(modelId: string): { - wrapGenerate: (options: { - doGenerate: () => Promise<{ - text?: string; - toolCalls?: unknown[]; - usage?: { inputTokens?: number; outputTokens?: number }; - }>; - params: { prompt?: Array<{ role: string; content?: unknown[] }> }; - }) => Promise<{ - text?: string; - toolCalls?: unknown[]; - usage?: { inputTokens?: number; outputTokens?: number }; - }>; - } { + static createLlmLoggingMiddleware( + modelId: string, + ): Pick { return { wrapGenerate: async ({ doGenerate, params }) => { - // Capture context at the start of the call to preserve step/action context + // Capture context at the start of the call const ctx = SessionFileLogger.getContext(); const llmRequestId = uuidv7(); - const p = params as { - prompt?: unknown[]; - tools?: unknown[]; - schema?: unknown; - }; + const p = params; - // Count tools const toolCount = Array.isArray(p.tools) ? p.tools.length : 0; - // Check for images in any message - // const hasImage = - // p.prompt?.some((m: unknown) => { - // const msg = m as { content?: unknown[] }; - // if (!Array.isArray(msg.content)) return false; - // return msg.content.some((c: unknown) => { - // const part = c as { type?: string }; - // return part.type === "image"; - // }); - // }) ?? false; - - // // Check for schema (structured output) - // const hasSchema = !!p.schema; - - // Find the last non-system message to show the newest content (tool result, etc.) + // Find the last non-system message const nonSystemMessages = (p.prompt ?? []).filter((m: unknown) => { const msg = m as { role?: string }; return msg.role !== "system"; @@ -969,29 +1141,23 @@ export class SessionFileLogger { | undefined; const lastRole = (lastMsg?.role as string) ?? "?"; - // Extract content from last message - handle various formats let lastContent = ""; let toolName = ""; if (lastMsg) { - // Check for tool result format: content → [{type: "tool-result", toolName, output: {type, value: [...]}}] if (lastMsg.content && Array.isArray(lastMsg.content)) { for (const part of lastMsg.content) { const item = part as Record; if (item.type === "tool-result") { toolName = (item.toolName as string) || ""; - - // output is directly on the tool-result item const output = item.output as | Record | undefined; if (output) { if (output.type === "json" && output.value) { - // JSON result like goto, scroll lastContent = JSON.stringify(output.value).slice(0, 150); } else if (Array.isArray(output.value)) { - // Array of content parts (text, images) const parts: string[] = []; for (const v of output.value) { const vItem = v as Record; @@ -1001,7 +1167,6 @@ export class SessionFileLogger { vItem.mediaType && typeof vItem.data === "string" ) { - // Image data const sizeKb = ( ((vItem.data as string).length * 0.75) / 1024 @@ -1024,11 +1189,9 @@ export class SessionFileLogger { } } - // Fallback: if still no content, stringify what we have for debugging if (!lastContent && lastMsg) { try { const debugStr = JSON.stringify(lastMsg, (key, value) => { - // Truncate long strings (like base64 images) if (typeof value === "string" && value.length > 100) { if (value.startsWith("data:image")) { const sizeKb = ((value.length * 0.75) / 1024).toFixed(1); @@ -1044,7 +1207,6 @@ export class SessionFileLogger { } } - // Build preview: role + tool name + truncated content + metadata const rolePrefix = toolName ? `tool result: ${toolName}()` : lastRole; const contentTruncated = lastContent ? truncateConversation(lastContent) @@ -1063,7 +1225,6 @@ export class SessionFileLogger { const result = await doGenerate(); - // Extract output - handle various response formats let outputPreview = ""; const res = result as { text?: string; @@ -1073,7 +1234,6 @@ export class SessionFileLogger { if (res.text) { outputPreview = res.text; } else if (res.content) { - // AI SDK may return content as string or array if (typeof res.content === "string") { outputPreview = res.content; } else if (Array.isArray(res.content)) { @@ -1096,7 +1256,6 @@ export class SessionFileLogger { } else if (res.toolCalls?.length) { outputPreview = `[${res.toolCalls.length} tool calls]`; } else if (typeof result === "object" && result !== null) { - // Fallback: try to stringify relevant parts of the result const keys = Object.keys(result).filter( (k) => k !== "usage" && k !== "rawResponse", ); From b2af3fef10972ef60bf1348aa9951a083c51aa67 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 16:59:54 -0800 Subject: [PATCH 12/32] better prompr preview for CUA llm calls --- packages/core/lib/v3/flowLogger.ts | 161 +++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 30 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index edecc2af0..8a5840c6c 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -495,6 +495,7 @@ export function getConfigDir(): string { /** * Format a prompt preview from LLM messages for logging. + * Returns format like: "some text... +{5.8kb image} +{schema} +{12 tools}" */ export function formatLlmPromptPreview( messages: Array<{ role: string; content: unknown }>, @@ -503,69 +504,169 @@ export function formatLlmPromptPreview( const lastUserMsg = messages.filter((m) => m.role === "user").pop(); if (!lastUserMsg) return undefined; - let preview: string; + let textPreview: string | undefined; + const extras: string[] = []; + if (typeof lastUserMsg.content === "string") { - preview = lastUserMsg.content + textPreview = lastUserMsg.content .replace("instruction: ", "") .replace("Instruction: ", ""); } else if (Array.isArray(lastUserMsg.content)) { - preview = lastUserMsg.content - .map((c: unknown) => { - const item = c as { text?: string }; - return item.text ? item.text : "[img]"; - }) - .join(" "); + for (const c of lastUserMsg.content as unknown[]) { + const item = c as { + type?: string; + text?: string; + image_url?: { url?: string }; + source?: { data?: string }; + }; + + if (item.type === "text" && item.text && !textPreview) { + textPreview = item.text + .replace("instruction: ", "") + .replace("Instruction: ", ""); + } else if (item.type === "image" || item.type === "image_url") { + if (item.image_url?.url?.startsWith("data:")) { + const sizeKb = ((item.image_url.url.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } else { + extras.push("image"); + } + } else if (item.source?.data) { + const sizeKb = ((item.source.data.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } else if (item.text) { + // Text item but we already have textPreview + } + } } else { return undefined; } - const suffixes: string[] = []; + // Add options-based extras if (options?.hasSchema) { - suffixes.push("schema"); + extras.push("schema"); } if (options?.toolCount && options.toolCount > 0) { - suffixes.push(`${options.toolCount} tools`); + extras.push(`${options.toolCount} tools`); } - if (suffixes.length > 0) { - return `${preview} +{${suffixes.join(", ")}}`; + // Build result + let result = textPreview || ""; + if (extras.length > 0) { + const extrasStr = extras.map((e) => `+{${e}}`).join(" "); + result = result ? `${result} ${extrasStr}` : extrasStr; } - return preview; + + return result || undefined; } /** * Extract a text preview from CUA-style messages. * Accepts various message formats (Anthropic, OpenAI, Google). + * Returns format like: "some text... +{5.8kb image} +{schema}" */ export function formatCuaPromptPreview( messages: unknown[], maxLen = 100, ): string | undefined { - // Find last user message - handle various formats - const lastUserMsg = messages + let textPreview: string | undefined; + const extras: string[] = []; + + // Helper to extract content from a content array + const extractFromContentArray = (content: unknown[]) => { + for (const part of content) { + const p = part as { + type?: string; + text?: string; + content?: unknown[]; + source?: { data?: string; media_type?: string }; + image_url?: { url?: string }; + }; + + if (p.type === "text" && p.text && !textPreview) { + textPreview = p.text; + } else if (p.type === "image" || p.type === "image_url") { + if (p.image_url?.url) { + if (p.image_url.url.startsWith("data:")) { + const sizeKb = ((p.image_url.url.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } else { + extras.push("image"); + } + } else if (p.source?.data) { + const sizeKb = ((p.source.data.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } else { + extras.push("image"); + } + } else if (p.source?.data) { + // Anthropic base64 image format + const sizeKb = ((p.source.data.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } else if (p.type === "tool_result" && Array.isArray(p.content)) { + // Anthropic tool_result with nested content + extractFromContentArray(p.content); + } + } + }; + + // Find last user message or tool_result - handle various formats + const lastMsg = messages .filter((m) => { - const msg = m as { role?: string }; - return msg.role === "user"; + const msg = m as { role?: string; type?: string }; + return msg.role === "user" || msg.type === "tool_result"; }) .pop() as - | { role?: string; content?: unknown; parts?: unknown[] } + | { + role?: string; + type?: string; + content?: unknown; + parts?: unknown[]; + text?: string; + } | undefined; - if (!lastUserMsg) return undefined; + if (!lastMsg) return undefined; - let text: string | undefined; + if (typeof lastMsg.content === "string") { + textPreview = lastMsg.content; + } else if (typeof lastMsg.text === "string") { + textPreview = lastMsg.text; + } else if (Array.isArray(lastMsg.parts)) { + // Google format: parts array + for (const part of lastMsg.parts) { + const p = part as { + text?: string; + inlineData?: { mimeType?: string; data?: string }; + }; + if (p.text && !textPreview) { + textPreview = p.text; + } else if (p.inlineData?.data) { + const sizeKb = ((p.inlineData.data.length * 0.75) / 1024).toFixed(1); + extras.push(`${sizeKb}kb image`); + } + } + } else if (Array.isArray(lastMsg.content)) { + extractFromContentArray(lastMsg.content as unknown[]); + } - if (typeof lastUserMsg.content === "string") { - text = lastUserMsg.content; - } else if (Array.isArray(lastUserMsg.parts)) { - const firstPart = lastUserMsg.parts[0] as { text?: string } | undefined; - text = firstPart?.text; - } else if (Array.isArray(lastUserMsg.content)) { - text = "[multipart message]"; + // If we only found images, show that + if (!textPreview && extras.length === 0) return undefined; + + // Truncate text preview + let result = textPreview + ? textPreview.length > maxLen + ? textPreview.slice(0, maxLen) + "..." + : textPreview + : ""; + + // Add extras + if (extras.length > 0) { + const extrasStr = extras.map((e) => `+{${e}}`).join(" "); + result = result ? `${result} ${extrasStr}` : extrasStr; } - if (!text) return undefined; - return text.length > maxLen ? text.slice(0, maxLen) : text; + return result || undefined; } /** From ccd0a967eff3904001c26cf86d28856a3920460c Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 17:32:45 -0800 Subject: [PATCH 13/32] code cleanups --- packages/core/lib/v3/flowLogger.ts | 800 ++++++++++------------------- 1 file changed, 276 insertions(+), 524 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 8a5840c6c..5ade9d278 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -15,8 +15,7 @@ const MAX_ARG_LENGTH = 500; const MAX_LINE_LENGTH = 140; const MAX_LLM_LINE_LENGTH = 500; -// CDP events to filter from pretty output (still logged to JSONL) -const NOISY_CDP_EVENTS = [ +const NOISY_CDP_EVENTS = new Set([ "Target.targetInfoChanged", "Runtime.executionContextCreated", "Runtime.executionContextDestroyed", @@ -28,7 +27,7 @@ const NOISY_CDP_EVENTS = [ "Network.responseReceivedExtraInfo", "Network.requestWillBeSent", "Network.responseReceived", -]; +]); // ============================================================================= // Types @@ -120,157 +119,88 @@ const loggerContext = new AsyncLocalStorage(); // Formatting Utilities (used by pretty streams) // ============================================================================= +/** Calculate base64 data size in KB */ +const dataToKb = (data: string): string => + ((data.length * 0.75) / 1024).toFixed(1); + +/** Collapse whitespace and truncate */ function truncate(value: string, maxLen = MAX_ARG_LENGTH): string { - value = value.replace(/\s+/g, " "); - if (value.length <= maxLen) { - return value; - } - return `${value.slice(0, maxLen)}…`; + const collapsed = value.replace(/\s+/g, " "); + return collapsed.length <= maxLen + ? collapsed + : `${collapsed.slice(0, maxLen)}…`; } -/** - * Truncate CDP IDs (32-char uppercase hex strings) that appear after id/Id patterns. - * Transforms: frameId:363F03EB7E3795ACB434672C35095EF8 → frameId:363F…5EF8 - */ +/** Truncate CDP IDs: frameId:363F03EB...EF8 → frameId:363F…5EF8 */ function truncateCdpIds(value: string): string { return value.replace( /([iI]d:?"?)([0-9A-F]{32})(?="?[,})\s]|$)/g, - (_match, prefix: string, id: string) => - `${prefix}${id.slice(0, 4)}…${id.slice(-4)}`, + (_, pre: string, id: string) => `${pre}${id.slice(0, 4)}…${id.slice(-4)}`, ); } -/** - * Truncate conversation/prompt strings showing first 30 chars + ... + last 100 chars - */ +/** Truncate showing first 30 + last 100 chars */ function truncateConversation(value: string): string { - value = value.replace(/\s+/g, " "); - const maxLen = 130; - if (value.length <= maxLen) { - return value; - } - return `${value.slice(0, 30)}…${value.slice(-100)}`; + const collapsed = value.replace(/\s+/g, " "); + return collapsed.length <= 130 + ? collapsed + : `${collapsed.slice(0, 30)}…${collapsed.slice(-100)}`; } 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"; + if (typeof value === "string") return `'${value}'`; + if (value == null || typeof value !== "object") return String(value); + try { + return truncate(JSON.stringify(value)); + } catch { + return "[unserializable]"; } - return truncate(String(value)); } function formatArgs(args?: unknown | unknown[]): string { - if (args === undefined) { - return ""; - } - const normalized = (Array.isArray(args) ? args : [args]).filter( - (entry) => entry !== undefined, - ); - const rendered = normalized - .map((entry) => formatValue(entry)) - .filter((entry) => entry.length > 0); - return rendered.join(", "); + if (args === undefined) return ""; + return (Array.isArray(args) ? args : [args]) + .filter((e) => e !== undefined) + .map(formatValue) + .filter((e) => e.length > 0) + .join(", "); } -function shortId(id: string | null | undefined): string { - if (!id) return "-"; - return id.slice(-4); -} +const shortId = (id: string | null | undefined): string => + id ? id.slice(-4) : "-"; function formatTag( label: string | null | undefined, id: string | null | undefined, icon: string, ): string { - if (!id) return `⤑`; - return `[${icon} #${shortId(id)}${label ? " " : ""}${label || ""}]`; + return id ? `[${icon} #${shortId(id)}${label ? " " + label : ""}]` : "⤑"; } let nonce = 0; - function formatTimestamp(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - const milliseconds = String(now.getMilliseconds()).padStart(3, "0"); - const monotonic = String(nonce++ % 100).padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${monotonic}`; + const d = new Date(); + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${pad(nonce++ % 100)}`; } -function sanitizeOptions(options: V3Options): Record { - const sensitiveKeys = [ - "apiKey", - "api_key", - "apikey", - "key", - "secret", - "token", - "password", - "passwd", - "pwd", - "credential", - "credentials", - "auth", - "authorization", - ]; - - const sanitizeValue = (obj: unknown): unknown => { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(sanitizeValue); - } +const SENSITIVE_KEYS = + /apikey|api_key|key|secret|token|password|passwd|pwd|credential|auth/i; +function sanitizeOptions(options: V3Options): Record { + const sanitize = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) return obj; + if (Array.isArray(obj)) return obj.map(sanitize); const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - const lowerKey = key.toLowerCase(); - if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { - result[key] = "******"; - } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeValue(value); - } else { - result[key] = value; - } + result[key] = SENSITIVE_KEYS.test(key) ? "******" : sanitize(value); } return result; }; - - return sanitizeValue({ ...options }) as Record; + return sanitize({ ...options }) as Record; } -/** - * Remove unescaped quotes from a string for cleaner log output - */ +/** Remove unescaped quotes for cleaner log output */ function removeQuotes(str: string): string { return str .replace(/([^\\])["']/g, "$1") @@ -285,48 +215,47 @@ function removeQuotes(str: string): string { function prettifyEvent(event: FlowEvent): string | null { const parts: string[] = []; - // Build context tags based on category + // Build context tags based on category (only add tags when IDs are present) if (event.category === "AgentTask") { - parts.push(formatTag("", event.taskId, "šŸ…°")); + if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); } else if (event.category === "StagehandStep") { - parts.push(formatTag("", event.taskId, "šŸ…°")); - parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); + if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); } else if (event.category === "UnderstudyAction") { - parts.push(formatTag("", event.taskId, "šŸ…°")); - parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); - parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); + if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + if (event.actionId) + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); } else if (event.category === "CDP") { - parts.push(formatTag("", event.taskId, "šŸ…°")); - parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); - parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); + if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + if (event.actionId) + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); parts.push(formatTag("CDP", event.targetId, "šŸ…²")); } else if (event.category === "LLM") { - parts.push(formatTag("", event.taskId, "šŸ…°")); - parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); + if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); parts.push(formatTag("LLM", event.requestId, "🧠")); } // Build details based on event type let details = ""; + const argsStr = event.params ? formatArgs(event.params) : ""; if (event.category === "AgentTask") { if (event.event === "started") { - const argsStr = event.params ? formatArgs(event.params) : ""; details = `ā–· ${event.method}(${argsStr})`; } else if (event.event === "completed") { const m = event.metrics; const durationSec = m?.durationMs ? (m.durationMs / 1000).toFixed(1) : "?"; - const llmStats = m - ? `${m.llmRequests} LLM calls źœ›${m.inputTokens} ꜜ${m.outputTokens} tokens` - : ""; - const cdpStats = m ? `${m.cdpEvents} CDP msgs` : ""; + const llmStats = `${m?.llmRequests ?? 0} LLM calls źœ›${m?.inputTokens ?? 0} ꜜ${m?.outputTokens ?? 0} tokens`; + const cdpStats = `${m?.cdpEvents ?? 0} CDP msgs`; details = `āœ“ Agent.execute() DONE in ${durationSec}s | ${llmStats} | ${cdpStats}`; } } else if (event.category === "StagehandStep") { if (event.event === "started") { - const argsStr = event.params ? formatArgs(event.params) : ""; details = `ā–· ${event.method}(${argsStr})`; } else if (event.event === "completed") { const durationSec = event.metrics?.durationMs @@ -336,7 +265,6 @@ function prettifyEvent(event: FlowEvent): string | null { } } else if (event.category === "UnderstudyAction") { if (event.event === "started") { - const argsStr = event.params ? formatArgs(event.params) : ""; details = `ā–· ${event.method}(${argsStr})`; } else if (event.event === "completed") { const durationSec = event.metrics?.durationMs @@ -345,95 +273,76 @@ function prettifyEvent(event: FlowEvent): string | null { details = `āœ“ ${event.actionLabel || "ACTION"} completed in ${durationSec}s`; } } else if (event.category === "CDP") { - const argsStr = event.params ? formatArgs(event.params) : ""; - const call = argsStr ? `${event.method}(${argsStr})` : `${event.method}()`; - if (event.event === "call") { - details = `āµ ${call}`; - } else if (event.event === "message") { - details = `ā“ ${call}`; - } + const icon = event.event === "call" ? "āµ" : "ā“"; + details = `${icon} ${event.method}(${argsStr})`; } else if (event.category === "LLM") { if (event.event === "request") { const promptStr = event.prompt - ? ` ${truncateConversation(String(event.prompt))}` + ? " " + truncateConversation(String(event.prompt)) : ""; details = `${event.model} ā“${promptStr}`; } else if (event.event === "response") { - const tokens = - event.inputTokens !== undefined || event.outputTokens !== undefined - ? ` źœ›${event.inputTokens ?? 0} ꜜ${event.outputTokens ?? 0} |` - : ""; + const hasTokens = + event.inputTokens !== undefined || event.outputTokens !== undefined; + const tokenStr = hasTokens + ? ` źœ›${event.inputTokens ?? 0} ꜜ${event.outputTokens ?? 0} |` + : ""; const outputStr = event.output - ? ` ${truncateConversation(String(event.output))}` + ? " " + truncateConversation(String(event.output)) : ""; - details = `${event.model} ↳${tokens}${outputStr}`; + details = `${event.model} ↳${tokenStr}${outputStr}`; } } if (!details) return null; + // Assemble and post-process the line const fullLine = `${formatTimestamp()} ${parts.join(" ")} ${details}`; const withoutQuotes = removeQuotes(fullLine); - // Apply category-specific truncation + // Apply category-specific processing and truncation if (event.category === "CDP") { const truncatedIds = truncateCdpIds(withoutQuotes); - return truncatedIds.length > MAX_LINE_LENGTH - ? `${truncatedIds.slice(0, MAX_LINE_LENGTH - 1)}…` - : truncatedIds; + if (truncatedIds.length > MAX_LINE_LENGTH) { + return truncatedIds.slice(0, MAX_LINE_LENGTH - 1) + "…"; + } + return truncatedIds; } else if (event.category === "LLM") { - return withoutQuotes.length > MAX_LLM_LINE_LENGTH - ? `${withoutQuotes.slice(0, MAX_LLM_LINE_LENGTH - 1)}…` - : withoutQuotes; + if (withoutQuotes.length > MAX_LLM_LINE_LENGTH) { + return withoutQuotes.slice(0, MAX_LLM_LINE_LENGTH - 1) + "…"; + } + return withoutQuotes; } return withoutQuotes; } -/** - * Check if a CDP event should be filtered from pretty output - */ +/** Check if a CDP event should be filtered from pretty output */ function shouldFilterCdpEvent(event: FlowEvent): boolean { if (event.category !== "CDP") return false; - - // Filter .enable calls - if (event.method?.endsWith(".enable") || event.method === "enable") { + if (event.method?.endsWith(".enable") || event.method === "enable") return true; - } - - // Filter noisy message events - if (event.event === "message" && NOISY_CDP_EVENTS.includes(event.method!)) { - return true; - } - - return false; + return event.event === "message" && NOISY_CDP_EVENTS.has(event.method!); } // ============================================================================= -// Stream Creation (inline in this file) +// Stream Creation // ============================================================================= -/** - * Create a JSONL stream that writes full events verbatim - */ +const isWritable = (s: fs.WriteStream | null): s is fs.WriteStream => + !!(s && !s.destroyed && s.writable); + function createJsonlStream(ctx: FlowLoggerContext): Writable { return new Writable({ objectMode: true, - write(chunk: string, _encoding, callback) { - const stream = ctx.fileStreams.jsonl; - if (!ctx.initialized || !stream || stream.destroyed || !stream.writable) { - callback(); - return; - } - // Pino already adds a newline, so just write the chunk as-is - stream.write(chunk, callback); + write(chunk: string, _, cb) { + if (ctx.initialized && isWritable(ctx.fileStreams.jsonl)) { + ctx.fileStreams.jsonl.write(chunk, cb); + } else cb(); }, }); } -/** - * Create a pretty stream for a specific category - */ function createPrettyStream( ctx: FlowLoggerContext, category: EventCategory, @@ -441,38 +350,18 @@ function createPrettyStream( ): Writable { return new Writable({ objectMode: true, - write(chunk: string, _encoding, callback) { + write(chunk: string, _, cb) { const stream = ctx.fileStreams[streamKey]; - if (!ctx.initialized || !stream || stream.destroyed || !stream.writable) { - callback(); - return; - } - + if (!ctx.initialized || !isWritable(stream)) return cb(); try { const event = JSON.parse(chunk) as FlowEvent; - - // Category routing - if (event.category !== category) { - callback(); - return; - } - - // Filter noisy CDP events from pretty output - if (shouldFilterCdpEvent(event)) { - callback(); - return; - } - - // Pretty format the event + if (event.category !== category || shouldFilterCdpEvent(event)) + return cb(); const line = prettifyEvent(event); - if (!line) { - callback(); - return; - } - - stream.write(line + "\n", callback); + if (line) stream.write(line + "\n", cb); + else cb(); } catch { - callback(); + cb(); } }, }); @@ -493,6 +382,67 @@ export function getConfigDir(): string { return path.resolve(process.cwd(), ".browserbase"); } +// ============================================================================= +// Prompt Preview Helpers +// ============================================================================= + +type ContentPart = { + type?: string; + text?: string; + content?: unknown[]; + source?: { data?: string }; + image_url?: { url?: string }; + inlineData?: { data?: string }; +}; + +/** Extract text and image info from a content array (handles nested tool_result) */ +function extractFromContent( + content: unknown[], + result: { text?: string; extras: string[] }, +): void { + for (const part of content) { + const p = part as ContentPart; + // Text + if (!result.text && p.text) { + result.text = p.type === "text" || !p.type ? p.text : undefined; + } + // Images - various formats + if (p.type === "image" || p.type === "image_url") { + const url = p.image_url?.url; + if (url?.startsWith("data:")) + result.extras.push(`${dataToKb(url)}kb image`); + else if (p.source?.data) + result.extras.push(`${dataToKb(p.source.data)}kb image`); + else result.extras.push("image"); + } else if (p.source?.data) { + result.extras.push(`${dataToKb(p.source.data)}kb image`); + } else if (p.inlineData?.data) { + result.extras.push(`${dataToKb(p.inlineData.data)}kb image`); + } + // Recurse into tool_result content + if (p.type === "tool_result" && Array.isArray(p.content)) { + extractFromContent(p.content, result); + } + } +} + +/** Build final preview string with extras */ +function buildPreview( + text: string | undefined, + extras: string[], + maxLen?: number, +): string | undefined { + if (!text && extras.length === 0) return undefined; + let result = text || ""; + if (maxLen && result.length > maxLen) + result = result.slice(0, maxLen) + "..."; + if (extras.length > 0) { + const extrasStr = extras.map((e) => `+{${e}}`).join(" "); + result = result ? `${result} ${extrasStr}` : extrasStr; + } + return result || undefined; +} + /** * Format a prompt preview from LLM messages for logging. * Returns format like: "some text... +{5.8kb image} +{schema} +{12 tools}" @@ -504,187 +454,77 @@ export function formatLlmPromptPreview( const lastUserMsg = messages.filter((m) => m.role === "user").pop(); if (!lastUserMsg) return undefined; - let textPreview: string | undefined; - const extras: string[] = []; + const result = { + text: undefined as string | undefined, + extras: [] as string[], + }; if (typeof lastUserMsg.content === "string") { - textPreview = lastUserMsg.content - .replace("instruction: ", "") - .replace("Instruction: ", ""); + result.text = lastUserMsg.content; } else if (Array.isArray(lastUserMsg.content)) { - for (const c of lastUserMsg.content as unknown[]) { - const item = c as { - type?: string; - text?: string; - image_url?: { url?: string }; - source?: { data?: string }; - }; - - if (item.type === "text" && item.text && !textPreview) { - textPreview = item.text - .replace("instruction: ", "") - .replace("Instruction: ", ""); - } else if (item.type === "image" || item.type === "image_url") { - if (item.image_url?.url?.startsWith("data:")) { - const sizeKb = ((item.image_url.url.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } else { - extras.push("image"); - } - } else if (item.source?.data) { - const sizeKb = ((item.source.data.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } else if (item.text) { - // Text item but we already have textPreview - } - } + extractFromContent(lastUserMsg.content, result); } else { return undefined; } - // Add options-based extras - if (options?.hasSchema) { - extras.push("schema"); - } - if (options?.toolCount && options.toolCount > 0) { - extras.push(`${options.toolCount} tools`); + // Clean instruction prefix + if (result.text) { + result.text = result.text.replace(/^[Ii]nstruction: /, ""); } - // Build result - let result = textPreview || ""; - if (extras.length > 0) { - const extrasStr = extras.map((e) => `+{${e}}`).join(" "); - result = result ? `${result} ${extrasStr}` : extrasStr; - } + if (options?.hasSchema) result.extras.push("schema"); + if (options?.toolCount) result.extras.push(`${options.toolCount} tools`); - return result || undefined; + return buildPreview(result.text, result.extras); } /** * Extract a text preview from CUA-style messages. * Accepts various message formats (Anthropic, OpenAI, Google). - * Returns format like: "some text... +{5.8kb image} +{schema}" */ export function formatCuaPromptPreview( messages: unknown[], maxLen = 100, ): string | undefined { - let textPreview: string | undefined; - const extras: string[] = []; - - // Helper to extract content from a content array - const extractFromContentArray = (content: unknown[]) => { - for (const part of content) { - const p = part as { - type?: string; - text?: string; - content?: unknown[]; - source?: { data?: string; media_type?: string }; - image_url?: { url?: string }; - }; - - if (p.type === "text" && p.text && !textPreview) { - textPreview = p.text; - } else if (p.type === "image" || p.type === "image_url") { - if (p.image_url?.url) { - if (p.image_url.url.startsWith("data:")) { - const sizeKb = ((p.image_url.url.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } else { - extras.push("image"); - } - } else if (p.source?.data) { - const sizeKb = ((p.source.data.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } else { - extras.push("image"); - } - } else if (p.source?.data) { - // Anthropic base64 image format - const sizeKb = ((p.source.data.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } else if (p.type === "tool_result" && Array.isArray(p.content)) { - // Anthropic tool_result with nested content - extractFromContentArray(p.content); - } - } - }; - - // Find last user message or tool_result - handle various formats const lastMsg = messages .filter((m) => { const msg = m as { role?: string; type?: string }; return msg.role === "user" || msg.type === "tool_result"; }) .pop() as - | { - role?: string; - type?: string; - content?: unknown; - parts?: unknown[]; - text?: string; - } + | { content?: unknown; parts?: unknown[]; text?: string } | undefined; if (!lastMsg) return undefined; + const result = { + text: undefined as string | undefined, + extras: [] as string[], + }; + if (typeof lastMsg.content === "string") { - textPreview = lastMsg.content; + result.text = lastMsg.content; } else if (typeof lastMsg.text === "string") { - textPreview = lastMsg.text; + result.text = lastMsg.text; } else if (Array.isArray(lastMsg.parts)) { - // Google format: parts array - for (const part of lastMsg.parts) { - const p = part as { - text?: string; - inlineData?: { mimeType?: string; data?: string }; - }; - if (p.text && !textPreview) { - textPreview = p.text; - } else if (p.inlineData?.data) { - const sizeKb = ((p.inlineData.data.length * 0.75) / 1024).toFixed(1); - extras.push(`${sizeKb}kb image`); - } - } + extractFromContent(lastMsg.parts, result); } else if (Array.isArray(lastMsg.content)) { - extractFromContentArray(lastMsg.content as unknown[]); + extractFromContent(lastMsg.content, result); } - // If we only found images, show that - if (!textPreview && extras.length === 0) return undefined; - - // Truncate text preview - let result = textPreview - ? textPreview.length > maxLen - ? textPreview.slice(0, maxLen) + "..." - : textPreview - : ""; - - // Add extras - if (extras.length > 0) { - const extrasStr = extras.map((e) => `+{${e}}`).join(" "); - result = result ? `${result} ${extrasStr}` : extrasStr; - } - - return result || undefined; + return buildPreview(result.text, result.extras, maxLen); } -/** - * Format CUA response output for logging. - */ +/** Format CUA response output for logging */ export function formatCuaResponsePreview( output: unknown, maxLen = 100, ): string { - const googleParts = ( - output as { - candidates?: Array<{ - content?: { parts?: unknown[] }; - }>; - } - )?.candidates?.[0]?.content?.parts; - - const items: unknown[] = googleParts ?? (Array.isArray(output) ? output : []); + // Handle Google format or array + const items: unknown[] = + (output as { candidates?: [{ content?: { parts?: unknown[] } }] }) + ?.candidates?.[0]?.content?.parts ?? + (Array.isArray(output) ? output : []); const preview = items .map((item) => { @@ -695,16 +535,13 @@ export function formatCuaResponsePreview( functionCall?: { name?: string }; }; if (i.text) return i.text.slice(0, 50); - if (i.type === "text" && typeof i.text === "string") - return i.text.slice(0, 50); if (i.functionCall?.name) return `fn:${i.functionCall.name}`; if (i.type === "tool_use" && i.name) return `tool_use:${i.name}`; - if (i.type) return `[${i.type}]`; - return "[item]"; + return i.type ? `[${i.type}]` : "[item]"; }) .join(" "); - return preview.length > maxLen ? preview.slice(0, maxLen) : preview; + return preview.slice(0, maxLen); } // ============================================================================= @@ -786,38 +623,38 @@ export class SessionFileLogger { } // Create file streams + // Create file streams + const dir = ctx.sessionDir; ctx.fileStreams.agent = fs.createWriteStream( - path.join(ctx.sessionDir, "agent_events.log"), + path.join(dir, "agent_events.log"), { flags: "a" }, ); ctx.fileStreams.stagehand = fs.createWriteStream( - path.join(ctx.sessionDir, "stagehand_events.log"), + path.join(dir, "stagehand_events.log"), { flags: "a" }, ); ctx.fileStreams.understudy = fs.createWriteStream( - path.join(ctx.sessionDir, "understudy_events.log"), + path.join(dir, "understudy_events.log"), { flags: "a" }, ); ctx.fileStreams.cdp = fs.createWriteStream( - path.join(ctx.sessionDir, "cdp_events.log"), + path.join(dir, "cdp_events.log"), { flags: "a" }, ); ctx.fileStreams.llm = fs.createWriteStream( - path.join(ctx.sessionDir, "llm_events.log"), + path.join(dir, "llm_events.log"), { flags: "a" }, ); ctx.fileStreams.jsonl = fs.createWriteStream( - path.join(ctx.sessionDir, "session_events.jsonl"), + path.join(dir, "session_events.jsonl"), { flags: "a" }, ); ctx.initialized = true; - // Create pino logger with multistream + // Create pino multistream: JSONL + pretty streams per category const streams: pino.StreamEntry[] = [ - // JSONL stream - full events { stream: createJsonlStream(ctx) }, - // Pretty streams per category { stream: createPrettyStream(ctx, "AgentTask", "agent") }, { stream: createPrettyStream(ctx, "StagehandStep", "stagehand") }, { stream: createPrettyStream(ctx, "UnderstudyAction", "understudy") }, @@ -853,29 +690,13 @@ export class SessionFileLogger { static async close(): Promise { const ctx = loggerContext.getStore(); if (!ctx) return; - await ctx.initPromise; - - // Log task completion if there's an active task SessionFileLogger.logAgentTaskCompleted(); - - const closePromises: Promise[] = []; - - for (const stream of Object.values(ctx.fileStreams)) { - if (stream) { - closePromises.push( - new Promise((resolve) => { - stream.end(() => resolve()); - }), - ); - } - } - - try { - await Promise.all(closePromises); - } catch { - // Fail silently - } + await Promise.all( + Object.values(ctx.fileStreams) + .filter(Boolean) + .map((s) => new Promise((r) => s!.end(r))), + ).catch(() => {}); } static get sessionId(): string | null { @@ -1088,57 +909,39 @@ export class SessionFileLogger { // CDP Events // =========================================================================== - static logCdpCallEvent( + private static logCdpEvent( + eventType: "call" | "message", { method, params, targetId, - }: { - method: string; - params?: object; - targetId?: string | null; - }, + }: { method: string; params?: unknown; targetId?: string | null }, explicitCtx?: FlowLoggerContext | null, ): void { const ctx = explicitCtx ?? loggerContext.getStore(); if (!ctx) return; - - // Track CDP events for task metrics - ctx.metrics.cdpEvents++; - - // Log full event - filtering happens in pretty stream + if (eventType === "call") ctx.metrics.cdpEvents++; ctx.logger.info({ category: "CDP", - event: "call", + event: eventType, method, params, targetId, } as FlowEvent); } - static logCdpMessageEvent( - { - method, - params, - targetId, - }: { - method: string; - params?: unknown; - targetId?: string | null; - }, - explicitCtx?: FlowLoggerContext | null, + static logCdpCallEvent( + data: { method: string; params?: object; targetId?: string | null }, + ctx?: FlowLoggerContext | null, ): void { - const ctx = explicitCtx ?? loggerContext.getStore(); - if (!ctx) return; + SessionFileLogger.logCdpEvent("call", data, ctx); + } - // Log full event - filtering happens in pretty stream - ctx.logger.info({ - category: "CDP", - event: "message", - method, - params, - targetId, - } as FlowEvent); + static logCdpMessageEvent( + data: { method: string; params?: unknown; targetId?: string | null }, + ctx?: FlowLoggerContext | null, + ): void { + SessionFileLogger.logCdpEvent("message", data, ctx); } // =========================================================================== @@ -1216,103 +1019,58 @@ export class SessionFileLogger { /** * Create middleware for wrapping language models with LLM call logging. - * Returns a partial middleware object compatible with AI SDK's wrapLanguageModel. */ static createLlmLoggingMiddleware( modelId: string, ): Pick { return { wrapGenerate: async ({ doGenerate, params }) => { - // Capture context at the start of the call const ctx = SessionFileLogger.getContext(); - const llmRequestId = uuidv7(); + const toolCount = Array.isArray(params.tools) ? params.tools.length : 0; - const p = params; - - const toolCount = Array.isArray(p.tools) ? p.tools.length : 0; - - // Find the last non-system message - const nonSystemMessages = (p.prompt ?? []).filter((m: unknown) => { - const msg = m as { role?: string }; - return msg.role !== "system"; - }); - const lastMsg = nonSystemMessages[nonSystemMessages.length - 1] as - | Record - | undefined; - const lastRole = (lastMsg?.role as string) ?? "?"; - - let lastContent = ""; - let toolName = ""; + // Extract prompt preview from last non-system message + const messages = (params.prompt ?? []) as Array<{ + role?: string; + content?: unknown; + }>; + const lastMsg = messages.filter((m) => m.role !== "system").pop(); + const extracted = { + text: undefined as string | undefined, + extras: [] as string[], + }; + let rolePrefix = lastMsg?.role ?? "?"; if (lastMsg) { - if (lastMsg.content && Array.isArray(lastMsg.content)) { - for (const part of lastMsg.content) { - const item = part as Record; - if (item.type === "tool-result") { - toolName = (item.toolName as string) || ""; - const output = item.output as - | Record - | undefined; - - if (output) { - if (output.type === "json" && output.value) { - lastContent = JSON.stringify(output.value).slice(0, 150); - } else if (Array.isArray(output.value)) { - const parts: string[] = []; - for (const v of output.value) { - const vItem = v as Record; - if (vItem.type === "text" && vItem.text) { - parts.push(vItem.text as string); - } else if ( - vItem.mediaType && - typeof vItem.data === "string" - ) { - const sizeKb = ( - ((vItem.data as string).length * 0.75) / - 1024 - ).toFixed(1); - parts.push(`[${sizeKb}kb img]`); - } - } - if (parts.length > 0) { - lastContent = parts.join(" "); - } - } - } - break; - } else if (item.type === "text") { - lastContent += (item.text as string) || ""; + if (typeof lastMsg.content === "string") { + extracted.text = lastMsg.content; + } else if (Array.isArray(lastMsg.content)) { + // Check for tool-result first + const toolResult = ( + lastMsg.content as Array<{ + type?: string; + toolName?: string; + output?: { type?: string; value?: unknown }; + }> + ).find((p) => p.type === "tool-result"); + if (toolResult) { + rolePrefix = `tool result: ${toolResult.toolName}()`; + const out = toolResult.output; + if (out?.type === "json" && out.value) { + extracted.text = JSON.stringify(out.value).slice(0, 150); + } else if (Array.isArray(out?.value)) { + extractFromContent(out.value as unknown[], extracted); } + } else { + extractFromContent(lastMsg.content as unknown[], extracted); } - } else if (typeof lastMsg.content === "string") { - lastContent = lastMsg.content; } } - if (!lastContent && lastMsg) { - try { - const debugStr = JSON.stringify(lastMsg, (key, value) => { - if (typeof value === "string" && value.length > 100) { - if (value.startsWith("data:image")) { - const sizeKb = ((value.length * 0.75) / 1024).toFixed(1); - return `[${sizeKb}kb image]`; - } - return value.slice(0, 50) + "..."; - } - return value; - }); - lastContent = debugStr.slice(0, 300); - } catch { - lastContent = "(unserializable)"; - } - } - - const rolePrefix = toolName ? `tool result: ${toolName}()` : lastRole; - const contentTruncated = lastContent - ? truncateConversation(lastContent) + const promptText = extracted.text + ? truncateConversation(extracted.text) : "(no text)"; - const promptPreview = `${rolePrefix}: ${contentTruncated} +{${toolCount} tools}`; + const promptPreview = `${rolePrefix}: ${promptText} +{${toolCount} tools}`; SessionFileLogger.logLlmRequest( { @@ -1326,42 +1084,36 @@ export class SessionFileLogger { const result = await doGenerate(); - let outputPreview = ""; + // Extract output preview const res = result as { text?: string; content?: unknown; toolCalls?: unknown[]; }; - if (res.text) { - outputPreview = res.text; - } else if (res.content) { + let outputPreview = res.text || ""; + if (!outputPreview && res.content) { if (typeof res.content === "string") { outputPreview = res.content; } else if (Array.isArray(res.content)) { - outputPreview = res.content - .map((c: unknown) => { - const item = c as { - type?: string; - text?: string; - toolName?: string; - }; - if (item.type === "text") return item.text; - if (item.type === "tool-call") - return `tool call: ${item.toolName}()`; - return `[${item.type || "unknown"}]`; - }) + outputPreview = ( + res.content as Array<{ + type?: string; + text?: string; + toolName?: string; + }> + ) + .map( + (c) => + c.text || + (c.type === "tool-call" + ? `tool call: ${c.toolName}()` + : `[${c.type}]`), + ) .join(" "); - } else { - outputPreview = String(res.content); } - } else if (res.toolCalls?.length) { + } + if (!outputPreview && res.toolCalls?.length) { outputPreview = `[${res.toolCalls.length} tool calls]`; - } else if (typeof result === "object" && result !== null) { - const keys = Object.keys(result).filter( - (k) => k !== "usage" && k !== "rawResponse", - ); - outputPreview = - keys.length > 0 ? `{${keys.join(", ")}}` : "[empty response]"; } SessionFileLogger.logLlmResponse( @@ -1369,7 +1121,7 @@ export class SessionFileLogger { requestId: llmRequestId, model: modelId, operation: "generateText", - output: outputPreview, + output: outputPreview || "[empty]", inputTokens: result.usage?.inputTokens, outputTokens: result.usage?.outputTokens, }, From b651f8543c1fa1eb6a6df45b3dae035d807f6d32 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 17:39:52 -0800 Subject: [PATCH 14/32] make truncation consistent across all lines --- packages/core/lib/v3/flowLogger.ts | 60 ++++++++---------------------- 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 5ade9d278..9fea98536 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -11,9 +11,7 @@ import type { V3Options } from "./types/public"; // Constants // ============================================================================= -const MAX_ARG_LENGTH = 500; -const MAX_LINE_LENGTH = 140; -const MAX_LLM_LINE_LENGTH = 500; +const MAX_LINE_LENGTH = 160; const NOISY_CDP_EVENTS = new Set([ "Target.targetInfoChanged", @@ -123,14 +121,6 @@ const loggerContext = new AsyncLocalStorage(); const dataToKb = (data: string): string => ((data.length * 0.75) / 1024).toFixed(1); -/** Collapse whitespace and truncate */ -function truncate(value: string, maxLen = MAX_ARG_LENGTH): string { - const collapsed = value.replace(/\s+/g, " "); - return collapsed.length <= maxLen - ? collapsed - : `${collapsed.slice(0, maxLen)}…`; -} - /** Truncate CDP IDs: frameId:363F03EB...EF8 → frameId:363F…5EF8 */ function truncateCdpIds(value: string): string { return value.replace( @@ -139,19 +129,20 @@ function truncateCdpIds(value: string): string { ); } -/** Truncate showing first 30 + last 100 chars */ -function truncateConversation(value: string): string { +/** Truncate line showing start...end */ +function truncateLine(value: string, maxLen: number): string { const collapsed = value.replace(/\s+/g, " "); - return collapsed.length <= 130 - ? collapsed - : `${collapsed.slice(0, 30)}…${collapsed.slice(-100)}`; + if (collapsed.length <= maxLen) return collapsed; + const endLen = Math.floor(maxLen * 0.3); + const startLen = maxLen - endLen - 1; + return `${collapsed.slice(0, startLen)}…${collapsed.slice(-endLen)}`; } function formatValue(value: unknown): string { if (typeof value === "string") return `'${value}'`; if (value == null || typeof value !== "object") return String(value); try { - return truncate(JSON.stringify(value)); + return JSON.stringify(value); } catch { return "[unserializable]"; } @@ -277,9 +268,7 @@ function prettifyEvent(event: FlowEvent): string | null { details = `${icon} ${event.method}(${argsStr})`; } else if (event.category === "LLM") { if (event.event === "request") { - const promptStr = event.prompt - ? " " + truncateConversation(String(event.prompt)) - : ""; + const promptStr = event.prompt ? " " + String(event.prompt) : ""; details = `${event.model} ā“${promptStr}`; } else if (event.event === "response") { const hasTokens = @@ -287,34 +276,19 @@ function prettifyEvent(event: FlowEvent): string | null { const tokenStr = hasTokens ? ` źœ›${event.inputTokens ?? 0} ꜜ${event.outputTokens ?? 0} |` : ""; - const outputStr = event.output - ? " " + truncateConversation(String(event.output)) - : ""; + const outputStr = event.output ? " " + String(event.output) : ""; details = `${event.model} ↳${tokenStr}${outputStr}`; } } if (!details) return null; - // Assemble and post-process the line + // Assemble line and apply final truncation const fullLine = `${formatTimestamp()} ${parts.join(" ")} ${details}`; - const withoutQuotes = removeQuotes(fullLine); - - // Apply category-specific processing and truncation - if (event.category === "CDP") { - const truncatedIds = truncateCdpIds(withoutQuotes); - if (truncatedIds.length > MAX_LINE_LENGTH) { - return truncatedIds.slice(0, MAX_LINE_LENGTH - 1) + "…"; - } - return truncatedIds; - } else if (event.category === "LLM") { - if (withoutQuotes.length > MAX_LLM_LINE_LENGTH) { - return withoutQuotes.slice(0, MAX_LLM_LINE_LENGTH - 1) + "…"; - } - return withoutQuotes; - } - - return withoutQuotes; + const cleaned = removeQuotes(fullLine); + const processed = + event.category === "CDP" ? truncateCdpIds(cleaned) : cleaned; + return truncateLine(processed, MAX_LINE_LENGTH); } /** Check if a CDP event should be filtered from pretty output */ @@ -1067,9 +1041,7 @@ export class SessionFileLogger { } } - const promptText = extracted.text - ? truncateConversation(extracted.text) - : "(no text)"; + const promptText = extracted.text || "(no text)"; const promptPreview = `${rolePrefix}: ${promptText} +{${toolCount} tools}`; SessionFileLogger.logLlmRequest( From 5f3440800c156f209989397b8f920d0d87bd4c3c Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 17:45:44 -0800 Subject: [PATCH 15/32] disable logging by default unless BROWSERBASE_CONFIG_DIR is set --- packages/core/lib/v3/flowLogger.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 9fea98536..54e8fc749 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -13,6 +13,9 @@ import type { V3Options } from "./types/public"; const MAX_LINE_LENGTH = 160; +// Flow logging config dir - empty string disables logging entirely +const CONFIG_DIR = process.env.BROWSERBASE_CONFIG_DIR || ""; + const NOISY_CDP_EVENTS = new Set([ "Target.targetInfoChanged", "Runtime.executionContextCreated", @@ -346,14 +349,10 @@ function createPrettyStream( // ============================================================================= /** - * Get the config directory from environment or use default + * Get the config directory. Returns empty string if logging is disabled. */ export function getConfigDir(): string { - const fromEnv = process.env.BROWSERBASE_CONFIG_DIR; - if (fromEnv) { - return path.resolve(fromEnv); - } - return path.resolve(process.cwd(), ".browserbase"); + return CONFIG_DIR ? path.resolve(CONFIG_DIR) : ""; } // ============================================================================= @@ -525,9 +524,12 @@ export function formatCuaResponsePreview( export class SessionFileLogger { /** * Initialize a new logging context. Call this at the start of a session. + * If BROWSERBASE_CONFIG_DIR is not set, logging is disabled. */ static init(sessionId: string, v3Options?: V3Options): void { const configDir = getConfigDir(); + if (!configDir) return; // Logging disabled + const sessionDir = path.join(configDir, "sessions", sessionId); // Create context with placeholder logger (will be replaced after streams init) From f05434ed7fcf52148b6c0ada514dc3df667859e0 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 18:06:30 -0800 Subject: [PATCH 16/32] use decorator for cleaner logging, add skip arrows back --- packages/core/lib/v3/flowLogger.ts | 50 +++++--- packages/core/lib/v3/understudy/page.ts | 155 ++++++++++-------------- 2 files changed, 99 insertions(+), 106 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 54e8fc749..4a7483949 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -209,26 +209,24 @@ function removeQuotes(str: string): string { function prettifyEvent(event: FlowEvent): string | null { const parts: string[] = []; - // Build context tags based on category (only add tags when IDs are present) + // Build context tags - always add parent span tags (formatTag returns ⤑ for null IDs) if (event.category === "AgentTask") { - if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag("", event.taskId, "šŸ…°")); } else if (event.category === "StagehandStep") { - if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); - if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); } else if (event.category === "UnderstudyAction") { - if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); - if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); - if (event.actionId) - parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); } else if (event.category === "CDP") { - if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); - if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); - if (event.actionId) - parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag(event.actionLabel, event.actionId, "šŸ†„")); parts.push(formatTag("CDP", event.targetId, "šŸ…²")); } else if (event.category === "LLM") { - if (event.taskId) parts.push(formatTag("", event.taskId, "šŸ…°")); - if (event.stepId) parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); + parts.push(formatTag("", event.taskId, "šŸ…°")); + parts.push(formatTag(event.stepLabel, event.stepId, "šŸ†‚")); parts.push(formatTag("LLM", event.requestId, "🧠")); } @@ -1107,3 +1105,27 @@ export class SessionFileLogger { }; } } + +/** + * Method decorator for logging understudy actions with automatic start/complete. + * Logs all arguments automatically. + */ +export function logAction(actionType: string) { + return function Promise>( + originalMethod: T, + _context: ClassMethodDecoratorContext, + ): T { + return async function (this: unknown, ...args: unknown[]) { + SessionFileLogger.logUnderstudyActionEvent({ + actionType, + args: args.length > 0 ? args : undefined, + }); + + try { + return await originalMethod.apply(this, args as never[]); + } finally { + SessionFileLogger.logUnderstudyActionCompleted(); + } + } as T; + }; +} diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 5ae5174ce..c9e0ee23c 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -1,7 +1,7 @@ import { Protocol } from "devtools-protocol"; import { promises as fs } from "fs"; import { v3Logger } from "../logger"; -import { SessionFileLogger } from "../flowLogger"; +import { SessionFileLogger, logAction } from "../flowLogger"; import type { CDPSessionLike } from "./cdp"; import { CdpConnection } from "./cdp"; import { Frame } from "./frame"; @@ -625,10 +625,8 @@ export class Page { /** * Close this top-level page (tab). Best-effort via Target.closeTarget. */ + @logAction("Page.close") public async close(): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.close", - }); try { await this.conn.send("Target.closeTarget", { targetId: this._targetId }); } catch { @@ -762,15 +760,11 @@ export class Page { * Navigate the page; optionally wait for a lifecycle state. * Waits on the **current** main frame and follows root swaps during navigation. */ + @logAction("Page.goto") async goto( url: string, options?: { waitUntil?: LoadState; timeoutMs?: number }, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.goto", - target: url, - args: options, - }); const waitUntil: LoadState = options?.waitUntil ?? "domcontentloaded"; const timeout = options?.timeoutMs ?? 15000; @@ -829,15 +823,12 @@ export class Page { /** * Reload the page; optionally wait for a lifecycle state. */ + @logAction("Page.reload") async reload(options?: { waitUntil?: LoadState; timeoutMs?: number; ignoreCache?: boolean; }): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.reload", - args: options, - }); const waitUntil = options?.waitUntil; const timeout = options?.timeoutMs ?? 15000; @@ -879,14 +870,11 @@ export class Page { /** * Navigate back in history if possible; optionally wait for a lifecycle state. */ + @logAction("Page.goBack") async goBack(options?: { waitUntil?: LoadState; timeoutMs?: number; }): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.goBack", - args: options, - }); const { entries, currentIndex } = await this.mainSession.send( "Page.getNavigationHistory", @@ -935,14 +923,11 @@ export class Page { /** * Navigate forward in history if possible; optionally wait for a lifecycle state. */ + @logAction("Page.goForward") async goForward(options?: { waitUntil?: LoadState; timeoutMs?: number; }): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.goForward", - args: options, - }); const { entries, currentIndex } = await this.mainSession.send( "Page.getNavigationHistory", @@ -1068,11 +1053,8 @@ export class Page { * timeout error is thrown. * @param options.type Image format (`"png"` by default). */ + @logAction("Page.screenshot") async screenshot(options?: ScreenshotOptions): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.screenshot", - args: options, - }); const opts = options ?? {}; const type = opts.type ?? "png"; @@ -1194,11 +1176,8 @@ export class Page { * Wait until the page reaches a lifecycle state on the current main frame. * Mirrors Playwright's API signatures. */ + @logAction("Page.waitForLoadState") async waitForLoadState(state: LoadState, timeoutMs?: number): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.waitForLoadState", - args: [state, timeoutMs], - }); await this.waitForMainLoadState(state, timeoutMs ?? 15000); } @@ -1215,53 +1194,60 @@ export class Page { ): Promise { SessionFileLogger.logUnderstudyActionEvent({ actionType: "Page.evaluate", - args: [typeof pageFunctionOrExpression === "string" ? pageFunctionOrExpression : "[function]", arg], + args: [ + typeof pageFunctionOrExpression === "string" + ? pageFunctionOrExpression + : "[function]", + arg, + ], }); - await this.mainSession.send("Runtime.enable").catch(() => {}); - const ctxId = await this.mainWorldExecutionContextId(); - - const isString = typeof pageFunctionOrExpression === "string"; - let expression: string; - - if (isString) { - expression = String(pageFunctionOrExpression); - } else { - const fnSrc = pageFunctionOrExpression.toString(); - // Build an IIFE that calls the user's function with the argument and - // attempts to deep-serialize the result for returnByValue. - const argJson = JSON.stringify(arg); - expression = `(() => { - const __fn = ${fnSrc}; - const __arg = ${argJson}; - try { - const __res = __fn(__arg); - return Promise.resolve(__res).then(v => { - try { return JSON.parse(JSON.stringify(v)); } catch { return v; } - }); - } catch (e) { throw e; } - })()`; - } + try { + await this.mainSession.send("Runtime.enable").catch(() => {}); + const ctxId = await this.mainWorldExecutionContextId(); - const { result, exceptionDetails } = - await this.mainSession.send( - "Runtime.evaluate", - { - expression, - contextId: ctxId, - returnByValue: true, - awaitPromise: true, - }, - ); + const isString = typeof pageFunctionOrExpression === "string"; + let expression: string; - if (exceptionDetails) { - const msg = - exceptionDetails.text || - exceptionDetails.exception?.description || - "Evaluation failed"; - throw new StagehandEvalError(msg); - } + if (isString) { + expression = String(pageFunctionOrExpression); + } else { + const fnSrc = pageFunctionOrExpression.toString(); + const argJson = JSON.stringify(arg); + expression = `(() => { + const __fn = ${fnSrc}; + const __arg = ${argJson}; + try { + const __res = __fn(__arg); + return Promise.resolve(__res).then(v => { + try { return JSON.parse(JSON.stringify(v)); } catch { return v; } + }); + } catch (e) { throw e; } + })()`; + } + + const { result, exceptionDetails } = + await this.mainSession.send( + "Runtime.evaluate", + { + expression, + contextId: ctxId, + returnByValue: true, + awaitPromise: true, + }, + ); - return result?.value as R; + if (exceptionDetails) { + const msg = + exceptionDetails.text || + exceptionDetails.exception?.description || + "Evaluation failed"; + throw new StagehandEvalError(msg); + } + + return result?.value as R; + } finally { + SessionFileLogger.logUnderstudyActionCompleted(); + } } /** @@ -1331,6 +1317,7 @@ export class Page { returnXpath: boolean; }, ): Promise; + @logAction("Page.click") async click( x: number, y: number, @@ -1340,10 +1327,6 @@ export class Page { returnXpath?: boolean; }, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.click", - args: [x, y, options], - }); const button = options?.button ?? "left"; const clickCount = options?.clickCount ?? 1; @@ -1431,6 +1414,7 @@ export class Page { deltaY: number, options: { returnXpath: boolean }, ): Promise; + @logAction("Page.scroll") async scroll( x: number, y: number, @@ -1438,10 +1422,6 @@ export class Page { deltaY: number, options?: { returnXpath?: boolean }, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.scroll", - args: [x, y, deltaX, deltaY, options], - }); let xpathResult: string | undefined; if (options?.returnXpath) { try { @@ -1513,6 +1493,7 @@ export class Page { returnXpath: boolean; }, ): Promise; + @logAction("Page.dragAndDrop") async dragAndDrop( fromX: number, fromY: number, @@ -1525,10 +1506,6 @@ export class Page { returnXpath?: boolean; }, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.dragAndDrop", - args: [fromX, fromY, toX, toY, options], - }); const button = options?.button ?? "left"; const steps = Math.max(1, Math.floor(options?.steps ?? 1)); const delay = Math.max(0, options?.delay ?? 0); @@ -1621,14 +1598,11 @@ export class Page { * and never falls back to Input.insertText. Optional delay applies between * successive characters. */ + @logAction("Page.type") async type( text: string, options?: { delay?: number; withMistakes?: boolean }, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.type", - args: [text, options], - }); const delay = Math.max(0, options?.delay ?? 0); const withMistakes = !!options?.withMistakes; @@ -1722,11 +1696,8 @@ export class Page { * For printable characters, uses the text path on keyDown; for named keys, sets key/code/VK. * Supports key combinations with modifiers like "Cmd+A", "Ctrl+C", "Shift+Tab", etc. */ + @logAction("Page.keyPress") async keyPress(key: string, options?: { delay?: number }): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.keyPress", - args: [key, options], - }); const delay = Math.max(0, options?.delay ?? 0); const sleep = (ms: number) => new Promise((r) => (ms > 0 ? setTimeout(r, ms) : r())); From bc1c2395d853371a1407da97c64662087c67f1c2 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 18:10:44 -0800 Subject: [PATCH 17/32] fix no-op screenshot logging in v3cua --- .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index 428027a7f..0f131c621 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -74,15 +74,20 @@ export class V3CuaAgentHandler { } } await new Promise((r) => setTimeout(r, 300)); - SessionFileLogger.logUnderstudyActionEvent({ - actionType: `v3CUA.${action.type}`, - target: this.computePointerTarget(action), - args: [action], - }); + // Skip logging for screenshot actions - they're no-ops, the actual + // Page.screenshot in captureAndSendScreenshot() is logged separately + const shouldLog = action.type !== "screenshot"; + if (shouldLog) { + SessionFileLogger.logUnderstudyActionEvent({ + actionType: `v3CUA.${action.type}`, + target: this.computePointerTarget(action), + args: [action], + }); + } try { await this.executeAction(action); } finally { - SessionFileLogger.logUnderstudyActionCompleted(); + if (shouldLog) SessionFileLogger.logUnderstudyActionCompleted(); } action.timestamp = Date.now(); @@ -381,7 +386,7 @@ export class V3CuaAgentHandler { return { success: true }; } case "screenshot": { - // Already handled around actions + // No-op - screenshot is captured by captureAndSendScreenshot() after all actions return { success: true }; } case "goto": { From 90054892ac59547ce630fe36bdb62185622fa061 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 5 Dec 2025 18:17:18 -0800 Subject: [PATCH 18/32] fix imports from bad rebase --- packages/core/lib/v3/v3.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 72b72aa59..08e94189e 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -73,16 +73,7 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; -import { - logTaskProgress, - logStepProgress, - setSessionFileLogger, - FlowLoggerContext, -} from "./flowLogger"; -import { - createSessionFileLogger, - SessionFileLogger, -} from "./sessionFileLogger"; +import { SessionFileLogger } from "./flowLogger"; import { createTimeoutGuard } from "./handlers/handlerUtils/timeoutGuard"; import { ActTimeoutError } from "./types/public/sdkErrors"; From 9453a318010833dc64055e6fb27cb0abc58a2c87 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 09:54:47 -0800 Subject: [PATCH 19/32] reduce diff in page.ts by using decorator --- packages/core/lib/v3/understudy/page.ts | 76 +++++++++++-------------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index c9e0ee23c..4ecd9b755 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -1188,32 +1188,23 @@ export class Page { * - The return value should be JSON-serializable. Non-serializable objects will * best-effort serialize via JSON.stringify inside the page context. */ + @logAction("Page.evaluate") async evaluate( pageFunctionOrExpression: string | ((arg: Arg) => R | Promise), arg?: Arg, ): Promise { - SessionFileLogger.logUnderstudyActionEvent({ - actionType: "Page.evaluate", - args: [ - typeof pageFunctionOrExpression === "string" - ? pageFunctionOrExpression - : "[function]", - arg, - ], - }); - try { - await this.mainSession.send("Runtime.enable").catch(() => {}); - const ctxId = await this.mainWorldExecutionContextId(); - - const isString = typeof pageFunctionOrExpression === "string"; - let expression: string; - - if (isString) { - expression = String(pageFunctionOrExpression); - } else { - const fnSrc = pageFunctionOrExpression.toString(); - const argJson = JSON.stringify(arg); - expression = `(() => { + await this.mainSession.send("Runtime.enable").catch(() => {}); + const ctxId = await this.mainWorldExecutionContextId(); + + const isString = typeof pageFunctionOrExpression === "string"; + let expression: string; + + if (isString) { + expression = String(pageFunctionOrExpression); + } else { + const fnSrc = pageFunctionOrExpression.toString(); + const argJson = JSON.stringify(arg); + expression = `(() => { const __fn = ${fnSrc}; const __arg = ${argJson}; try { @@ -1223,31 +1214,28 @@ export class Page { }); } catch (e) { throw e; } })()`; - } - - const { result, exceptionDetails } = - await this.mainSession.send( - "Runtime.evaluate", - { - expression, - contextId: ctxId, - returnByValue: true, - awaitPromise: true, - }, - ); + } - if (exceptionDetails) { - const msg = - exceptionDetails.text || - exceptionDetails.exception?.description || - "Evaluation failed"; - throw new StagehandEvalError(msg); - } + const { result, exceptionDetails } = + await this.mainSession.send( + "Runtime.evaluate", + { + expression, + contextId: ctxId, + returnByValue: true, + awaitPromise: true, + }, + ); - return result?.value as R; - } finally { - SessionFileLogger.logUnderstudyActionCompleted(); + if (exceptionDetails) { + const msg = + exceptionDetails.text || + exceptionDetails.exception?.description || + "Evaluation failed"; + throw new StagehandEvalError(msg); } + + return result?.value as R; } /** From 86a54842de42c022ff5611189578a8fd933dd1c2 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 09:59:24 -0800 Subject: [PATCH 20/32] reduce diff in v3.ts by using decorator --- packages/core/lib/v3/flowLogger.ts | 32 ++ packages/core/lib/v3/v3.ts | 460 ++++++++++++++--------------- 2 files changed, 246 insertions(+), 246 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index 4a7483949..f9173d0aa 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -6,6 +6,7 @@ import path from "node:path"; import pino from "pino"; import type { LanguageModelMiddleware } from "ai"; import type { V3Options } from "./types/public"; +import { withInstanceLogContext } from "./logger"; // ============================================================================= // Constants @@ -1129,3 +1130,34 @@ export function logAction(actionType: string) { } as T; }; } + +/** + * Method decorator for logging Stagehand step events (act, extract, observe). + * Wraps the method with withInstanceLogContext and automatic step start/complete logging. + * Requires `this` to have an `instanceId: string` property. + */ +export function logStagehandStep(invocation: string, label: string) { + return function Promise>( + originalMethod: T, + _context: ClassMethodDecoratorContext, + ): T { + return async function ( + this: { instanceId: string }, + ...args: unknown[] + ): Promise { + return await withInstanceLogContext(this.instanceId, async () => { + SessionFileLogger.logStagehandStepEvent({ + invocation, + args: args.length > 0 ? args : undefined, + label, + }); + + try { + return await originalMethod.apply(this, args as never[]); + } finally { + SessionFileLogger.logStagehandStepCompleted(); + } + }); + } as T; + }; +} diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 08e94189e..60d3acd4f 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -73,7 +73,7 @@ import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; import { resolveModel } from "../modelUtils"; import { StagehandAPIClient } from "./api"; -import { SessionFileLogger } from "./flowLogger"; +import { SessionFileLogger, logStagehandStep } from "./flowLogger"; import { createTimeoutGuard } from "./handlers/handlerUtils/timeoutGuard"; import { ActTimeoutError } from "./types/public/sdkErrors"; @@ -983,140 +983,130 @@ export class V3 { async act(instruction: string, options?: ActOptions): Promise; async act(action: Action, options?: ActOptions): Promise; + @logStagehandStep("Stagehand.act", "ACT") async act(input: string | Action, options?: ActOptions): Promise { - return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStagehandStepEvent({ - invocation: "Stagehand.act", - args: [input, options], - label: "ACT", - }); - try { - if (!this.actHandler) throw new StagehandNotInitializedError("act()"); + if (!this.actHandler) throw new StagehandNotInitializedError("act()"); - let actResult: ActResult; + let actResult: ActResult; - if (isObserveResult(input)) { - // Resolve page: use provided page if any, otherwise default active page - const v3Page = await this.resolvePage(options?.page); + if (isObserveResult(input)) { + // Resolve page: use provided page if any, otherwise default active page + const v3Page = await this.resolvePage(options?.page); - // Use selector as provided to support XPath, CSS, and other engines - const selector = input.selector; - if (this.apiClient) { - actResult = await this.apiClient.act({ - input, - options, - frameId: v3Page.mainFrameId(), - }); - } else { - const effectiveTimeoutMs = - typeof options?.timeout === "number" && options.timeout > 0 - ? options.timeout - : undefined; - const ensureTimeRemaining = createTimeoutGuard( - effectiveTimeoutMs, - (ms) => new ActTimeoutError(ms), - ); - actResult = await this.actHandler.takeDeterministicAction( - { ...input, selector }, - v3Page, - this.domSettleTimeoutMs, - this.resolveLlmClient(options?.model), - ensureTimeRemaining, - ); - } + // Use selector as provided to support XPath, CSS, and other engines + const selector = input.selector; + if (this.apiClient) { + actResult = await this.apiClient.act({ + input, + options, + frameId: v3Page.mainFrameId(), + }); + } else { + const effectiveTimeoutMs = + typeof options?.timeout === "number" && options.timeout > 0 + ? options.timeout + : undefined; + const ensureTimeRemaining = createTimeoutGuard( + effectiveTimeoutMs, + (ms) => new ActTimeoutError(ms), + ); + actResult = await this.actHandler.takeDeterministicAction( + { ...input, selector }, + v3Page, + this.domSettleTimeoutMs, + this.resolveLlmClient(options?.model), + ensureTimeRemaining, + ); + } + + // history: record ObserveResult-based act call + this.addToHistory( + "act", + { + observeResult: input, + }, + actResult, + ); + return actResult; + } + // instruction path + if (typeof input !== "string" || !input.trim()) { + throw new StagehandInvalidArgumentError( + "act(): instruction string is required unless passing an Action", + ); + } - // history: record ObserveResult-based act call + // Resolve page from options or default + const page = await this.resolvePage(options?.page); + + let actCacheContext: Awaited< + ReturnType + > | null = null; + const canUseCache = + typeof input === "string" && + !this.isAgentReplayRecording() && + this.actCache.enabled; + if (canUseCache) { + actCacheContext = await this.actCache.prepareContext( + input, + page, + options?.variables, + ); + if (actCacheContext) { + const cachedResult = await this.actCache.tryReplay( + actCacheContext, + page, + options?.timeout, + ); + if (cachedResult) { this.addToHistory( "act", { - observeResult: input, + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + cacheHit: true, }, - actResult, - ); - return actResult; - } - // instruction path - if (typeof input !== "string" || !input.trim()) { - throw new StagehandInvalidArgumentError( - "act(): instruction string is required unless passing an Action", + cachedResult, ); + return cachedResult; } + } + } - // Resolve page from options or default - const page = await this.resolvePage(options?.page); - - let actCacheContext: Awaited< - ReturnType - > | null = null; - const canUseCache = - typeof input === "string" && - !this.isAgentReplayRecording() && - this.actCache.enabled; - if (canUseCache) { - actCacheContext = await this.actCache.prepareContext( - input, - page, - options?.variables, - ); - if (actCacheContext) { - const cachedResult = await this.actCache.tryReplay( - actCacheContext, - page, - options?.timeout, - ); - if (cachedResult) { - this.addToHistory( - "act", - { - instruction: input, - variables: options?.variables, - timeout: options?.timeout, - cacheHit: true, - }, - cachedResult, - ); - return cachedResult; - } - } - } - - const handlerParams: ActHandlerParams = { - instruction: input, - page, - variables: options?.variables, - timeout: options?.timeout, - model: options?.model, - }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - actResult = await this.apiClient.act({ input, options, frameId }); - } else { - actResult = await this.actHandler.act(handlerParams); - } - // history: record instruction-based act call (omit page object) - this.addToHistory( - "act", - { - instruction: input, - variables: options?.variables, - timeout: options?.timeout, - }, - actResult, - ); + const handlerParams: ActHandlerParams = { + instruction: input, + page, + variables: options?.variables, + timeout: options?.timeout, + model: options?.model, + }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + actResult = await this.apiClient.act({ input, options, frameId }); + } else { + actResult = await this.actHandler.act(handlerParams); + } + // history: record instruction-based act call (omit page object) + this.addToHistory( + "act", + { + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + }, + actResult, + ); - if ( - actCacheContext && - actResult.success && - Array.isArray(actResult.actions) && - actResult.actions.length > 0 - ) { - await this.actCache.store(actCacheContext, actResult); - } - return actResult; - } finally { - SessionFileLogger.logStagehandStepCompleted(); - } - }); + if ( + actCacheContext && + actResult.success && + Array.isArray(actResult.actions) && + actResult.actions.length > 0 + ) { + await this.actCache.store(actCacheContext, actResult); + } + return actResult; } /** @@ -1144,86 +1134,74 @@ export class V3 { options?: ExtractOptions, ): Promise>; + @logStagehandStep("Stagehand.extract", "EXTRACT") async extract( a?: string | ExtractOptions, b?: StagehandZodSchema | ExtractOptions, c?: ExtractOptions, ): Promise { - return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStagehandStepEvent({ - invocation: "Stagehand.extract", - args: [a, b, c], - label: "EXTRACT", - }); - try { - if (!this.extractHandler) { - throw new StagehandNotInitializedError("extract()"); - } + if (!this.extractHandler) { + throw new StagehandNotInitializedError("extract()"); + } - // Normalize args - let instruction: string | undefined; - let schema: StagehandZodSchema | undefined; - let options: ExtractOptions | undefined; - - if (typeof a === "string") { - instruction = a; - const isZodSchema = (val: unknown): val is StagehandZodSchema => - !!val && - typeof val === "object" && - "parse" in val && - "safeParse" in val; - if (isZodSchema(b)) { - schema = b as StagehandZodSchema; - options = c as ExtractOptions | undefined; - } else { - options = b as ExtractOptions | undefined; - } - } else { - // a is options or undefined - options = (a as ExtractOptions) || undefined; - } + // Normalize args + let instruction: string | undefined; + let schema: StagehandZodSchema | undefined; + let options: ExtractOptions | undefined; + + if (typeof a === "string") { + instruction = a; + const isZodSchema = (val: unknown): val is StagehandZodSchema => + !!val && + typeof val === "object" && + "parse" in val && + "safeParse" in val; + if (isZodSchema(b)) { + schema = b as StagehandZodSchema; + options = c as ExtractOptions | undefined; + } else { + options = b as ExtractOptions | undefined; + } + } else { + // a is options or undefined + options = (a as ExtractOptions) || undefined; + } - if (!instruction && schema) { - throw new StagehandInvalidArgumentError( - "extract(): schema provided without instruction", - ); - } + if (!instruction && schema) { + throw new StagehandInvalidArgumentError( + "extract(): schema provided without instruction", + ); + } - // If instruction without schema → defaultExtractSchema - const effectiveSchema = - instruction && !schema ? defaultExtractSchema : schema; + // If instruction without schema → defaultExtractSchema + const effectiveSchema = + instruction && !schema ? defaultExtractSchema : schema; - // Resolve page from options or use active page - const page = await this.resolvePage(options?.page); + // Resolve page from options or use active page + const page = await this.resolvePage(options?.page); - const handlerParams: ExtractHandlerParams = { - instruction, - schema: effectiveSchema as StagehandZodSchema | undefined, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page, - }; - let result: z.infer | { pageText: string }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - result = await this.apiClient.extract({ - instruction: handlerParams.instruction, - schema: handlerParams.schema, - options, - frameId, - }); - } else { - result = - await this.extractHandler.extract( - handlerParams, - ); - } - return result; - } finally { - SessionFileLogger.logStagehandStepCompleted(); - } - }); + const handlerParams: ExtractHandlerParams = { + instruction, + schema: effectiveSchema as StagehandZodSchema | undefined, + model: options?.model, + timeout: options?.timeout, + selector: options?.selector, + page, + }; + let result: z.infer | { pageText: string }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + result = await this.apiClient.extract({ + instruction: handlerParams.instruction, + schema: handlerParams.schema, + options, + frameId, + }); + } else { + result = + await this.extractHandler.extract(handlerParams); + } + return result; } /** @@ -1235,68 +1213,58 @@ export class V3 { instruction: string, options?: ObserveOptions, ): Promise; + @logStagehandStep("Stagehand.observe", "OBSERVE") async observe( a?: string | ObserveOptions, b?: ObserveOptions, ): Promise { - return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStagehandStepEvent({ - invocation: "Stagehand.observe", - args: [a, b], - label: "OBSERVE", - }); - try { - if (!this.observeHandler) { - throw new StagehandNotInitializedError("observe()"); - } + if (!this.observeHandler) { + throw new StagehandNotInitializedError("observe()"); + } - // Normalize args - let instruction: string | undefined; - let options: ObserveOptions | undefined; - if (typeof a === "string") { - instruction = a; - options = b; - } else { - options = a as ObserveOptions | undefined; - } + // Normalize args + let instruction: string | undefined; + let options: ObserveOptions | undefined; + if (typeof a === "string") { + instruction = a; + options = b; + } else { + options = a as ObserveOptions | undefined; + } - // Resolve to our internal Page type - const page = await this.resolvePage(options?.page); + // Resolve to our internal Page type + const page = await this.resolvePage(options?.page); - const handlerParams: ObserveHandlerParams = { - instruction, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page: page!, - }; - - let results: Action[]; - if (this.apiClient) { - const frameId = page.mainFrameId(); - results = await this.apiClient.observe({ - instruction, - options, - frameId, - }); - } else { - results = await this.observeHandler.observe(handlerParams); - } + const handlerParams: ObserveHandlerParams = { + instruction, + model: options?.model, + timeout: options?.timeout, + selector: options?.selector, + page: page!, + }; - // history: record observe call (omit page object) - this.addToHistory( - "observe", - { - instruction, - timeout: options?.timeout, - }, - results, - ); - return results; - } finally { - SessionFileLogger.logStagehandStepCompleted(); - } - }); + let results: Action[]; + if (this.apiClient) { + const frameId = page.mainFrameId(); + results = await this.apiClient.observe({ + instruction, + options, + frameId, + }); + } else { + results = await this.observeHandler.observe(handlerParams); + } + + // history: record observe call (omit page object) + this.addToHistory( + "observe", + { + instruction, + timeout: options?.timeout, + }, + results, + ); + return results; } /** Return the browser-level CDP WebSocket endpoint. */ From 3b62ab276f7e536b5462ec819eef1d0d74791a8f Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:05:05 -0800 Subject: [PATCH 21/32] add safety guards to prevent errors in main logic --- packages/core/examples/flowLoggingJourney.ts | 76 -------- packages/core/lib/v3/flowLogger.ts | 172 +++++++++++------- .../core/lib/v3/handlers/v3CuaAgentHandler.ts | 1 + 3 files changed, 104 insertions(+), 145 deletions(-) delete mode 100644 packages/core/examples/flowLoggingJourney.ts diff --git a/packages/core/examples/flowLoggingJourney.ts b/packages/core/examples/flowLoggingJourney.ts deleted file mode 100644 index 3b2963f08..000000000 --- a/packages/core/examples/flowLoggingJourney.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Stagehand } from "../lib/v3"; - -async function run(): Promise { - const openaiKey = process.env.OPENAI_API_KEY; - const anthropicKey = process.env.ANTHROPIC_API_KEY; - - if (!openaiKey || !anthropicKey) { - throw new Error( - "Set both OPENAI_API_KEY and ANTHROPIC_API_KEY before running this demo.", - ); - } - - const stagehand = new Stagehand({ - env: "LOCAL", - verbose: 2, - model: { modelName: "openai/gpt-4.1-mini", apiKey: openaiKey }, - 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" }); - - // Test standard agent path - 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); - - // Test CUA (Computer Use Agent) path - await page.goto("https://example.com/", { waitUntil: "load" }); - const cuaAgent = stagehand.agent({ - cua: true, - model: { - modelName: "anthropic/claude-sonnet-4-5-20250929", - apiKey: anthropicKey, - }, - }); - const cuaResult = await cuaAgent.execute({ - instruction: "Click on the 'More information...' link on the page.", - maxSteps: 3, - }); - console.log("CUA Agent result:", cuaResult); - - const observations = await stagehand.observe("Find any links on the page"); - console.log("Observe result:", observations); - - if (observations.length > 0) { - await stagehand.act(observations[0]); - } else { - await stagehand.act("click the link on the page"); - } - - const extraction = await stagehand.extract( - "Summarize the current page title and contents in a single sentence", - ); - 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 index f9173d0aa..fef195fdd 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -423,31 +423,35 @@ export function formatLlmPromptPreview( messages: Array<{ role: string; content: unknown }>, options?: { toolCount?: number; hasSchema?: boolean }, ): string | undefined { - const lastUserMsg = messages.filter((m) => m.role === "user").pop(); - if (!lastUserMsg) return undefined; + try { + const lastUserMsg = messages.filter((m) => m.role === "user").pop(); + if (!lastUserMsg) return undefined; - const result = { - text: undefined as string | undefined, - extras: [] as string[], - }; + const result = { + text: undefined as string | undefined, + extras: [] as string[], + }; - if (typeof lastUserMsg.content === "string") { - result.text = lastUserMsg.content; - } else if (Array.isArray(lastUserMsg.content)) { - extractFromContent(lastUserMsg.content, result); - } else { - return undefined; - } + if (typeof lastUserMsg.content === "string") { + result.text = lastUserMsg.content; + } else if (Array.isArray(lastUserMsg.content)) { + extractFromContent(lastUserMsg.content, result); + } else { + return undefined; + } - // Clean instruction prefix - if (result.text) { - result.text = result.text.replace(/^[Ii]nstruction: /, ""); - } + // Clean instruction prefix + if (result.text) { + result.text = result.text.replace(/^[Ii]nstruction: /, ""); + } - if (options?.hasSchema) result.extras.push("schema"); - if (options?.toolCount) result.extras.push(`${options.toolCount} tools`); + if (options?.hasSchema) result.extras.push("schema"); + if (options?.toolCount) result.extras.push(`${options.toolCount} tools`); - return buildPreview(result.text, result.extras); + return buildPreview(result.text, result.extras); + } catch { + return undefined; + } } /** @@ -458,33 +462,37 @@ export function formatCuaPromptPreview( messages: unknown[], maxLen = 100, ): string | undefined { - const lastMsg = messages - .filter((m) => { - const msg = m as { role?: string; type?: string }; - return msg.role === "user" || msg.type === "tool_result"; - }) - .pop() as - | { content?: unknown; parts?: unknown[]; text?: string } - | undefined; - - if (!lastMsg) return undefined; - - const result = { - text: undefined as string | undefined, - extras: [] as string[], - }; + try { + const lastMsg = messages + .filter((m) => { + const msg = m as { role?: string; type?: string }; + return msg.role === "user" || msg.type === "tool_result"; + }) + .pop() as + | { content?: unknown; parts?: unknown[]; text?: string } + | undefined; + + if (!lastMsg) return undefined; + + const result = { + text: undefined as string | undefined, + extras: [] as string[], + }; - if (typeof lastMsg.content === "string") { - result.text = lastMsg.content; - } else if (typeof lastMsg.text === "string") { - result.text = lastMsg.text; - } else if (Array.isArray(lastMsg.parts)) { - extractFromContent(lastMsg.parts, result); - } else if (Array.isArray(lastMsg.content)) { - extractFromContent(lastMsg.content, result); - } + if (typeof lastMsg.content === "string") { + result.text = lastMsg.content; + } else if (typeof lastMsg.text === "string") { + result.text = lastMsg.text; + } else if (Array.isArray(lastMsg.parts)) { + extractFromContent(lastMsg.parts, result); + } else if (Array.isArray(lastMsg.content)) { + extractFromContent(lastMsg.content, result); + } - return buildPreview(result.text, result.extras, maxLen); + return buildPreview(result.text, result.extras, maxLen); + } catch { + return undefined; + } } /** Format CUA response output for logging */ @@ -492,28 +500,32 @@ export function formatCuaResponsePreview( output: unknown, maxLen = 100, ): string { - // Handle Google format or array - const items: unknown[] = - (output as { candidates?: [{ content?: { parts?: unknown[] } }] }) - ?.candidates?.[0]?.content?.parts ?? - (Array.isArray(output) ? output : []); - - const preview = items - .map((item) => { - const i = item as { - type?: string; - text?: string; - name?: string; - functionCall?: { name?: string }; - }; - if (i.text) return i.text.slice(0, 50); - if (i.functionCall?.name) return `fn:${i.functionCall.name}`; - if (i.type === "tool_use" && i.name) return `tool_use:${i.name}`; - return i.type ? `[${i.type}]` : "[item]"; - }) - .join(" "); - - return preview.slice(0, maxLen); + try { + // Handle Google format or array + const items: unknown[] = + (output as { candidates?: [{ content?: { parts?: unknown[] } }] }) + ?.candidates?.[0]?.content?.parts ?? + (Array.isArray(output) ? output : []); + + const preview = items + .map((item) => { + const i = item as { + type?: string; + text?: string; + name?: string; + functionCall?: { name?: string }; + }; + if (i.text) return i.text.slice(0, 50); + if (i.functionCall?.name) return `fn:${i.functionCall.name}`; + if (i.type === "tool_use" && i.name) return `tool_use:${i.name}`; + return i.type ? `[${i.type}]` : "[item]"; + }) + .join(" "); + + return preview.slice(0, maxLen); + } catch { + return "[error]"; + } } // ============================================================================= @@ -994,13 +1006,25 @@ export class SessionFileLogger { /** * Create middleware for wrapping language models with LLM call logging. + * Returns a no-op middleware when logging is disabled. */ static createLlmLoggingMiddleware( modelId: string, ): Pick { + // No-op middleware when logging is disabled + if (!CONFIG_DIR) { + return { + wrapGenerate: async ({ doGenerate }) => doGenerate(), + }; + } + return { wrapGenerate: async ({ doGenerate, params }) => { const ctx = SessionFileLogger.getContext(); + // Skip logging overhead if no context (shouldn't happen but be safe) + if (!ctx) { + return doGenerate(); + } const llmRequestId = uuidv7(); const toolCount = Array.isArray(params.tools) ? params.tools.length : 0; @@ -1109,13 +1133,18 @@ export class SessionFileLogger { /** * Method decorator for logging understudy actions with automatic start/complete. - * Logs all arguments automatically. + * Logs all arguments automatically. No-op when CONFIG_DIR is empty. */ export function logAction(actionType: string) { return function Promise>( originalMethod: T, _context: ClassMethodDecoratorContext, ): T { + // No-op when logging is disabled + if (!CONFIG_DIR) { + return originalMethod; + } + return async function (this: unknown, ...args: unknown[]) { SessionFileLogger.logUnderstudyActionEvent({ actionType, @@ -1134,13 +1163,18 @@ export function logAction(actionType: string) { /** * Method decorator for logging Stagehand step events (act, extract, observe). * Wraps the method with withInstanceLogContext and automatic step start/complete logging. - * Requires `this` to have an `instanceId: string` property. + * Requires `this` to have an `instanceId: string` property. No-op when CONFIG_DIR is empty. */ export function logStagehandStep(invocation: string, label: string) { return function Promise>( originalMethod: T, _context: ClassMethodDecoratorContext, ): T { + // No-op when logging is disabled + if (!CONFIG_DIR) { + return originalMethod; + } + return async function ( this: { instanceId: string }, ...args: unknown[] diff --git a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts index 0f131c621..f06b99c3e 100644 --- a/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3CuaAgentHandler.ts @@ -439,6 +439,7 @@ export class V3CuaAgentHandler { } } + // helper to make pointer target human-readable for logging private computePointerTarget(action: AgentAction): string | undefined { return typeof action.x === "number" && typeof action.y === "number" ? `(${action.x}, ${action.y})` From a7863f94b979020c870e640e5bf9d2eb86ea2fcf Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:10:06 -0800 Subject: [PATCH 22/32] fix indentation diffs --- packages/core/lib/v3/flowLogger.ts | 29 +- packages/core/lib/v3/v3.ts | 414 +++++++++++++++-------------- 2 files changed, 223 insertions(+), 220 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index fef195fdd..da90cecdb 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -6,7 +6,6 @@ import path from "node:path"; import pino from "pino"; import type { LanguageModelMiddleware } from "ai"; import type { V3Options } from "./types/public"; -import { withInstanceLogContext } from "./logger"; // ============================================================================= // Constants @@ -1162,8 +1161,8 @@ export function logAction(actionType: string) { /** * Method decorator for logging Stagehand step events (act, extract, observe). - * Wraps the method with withInstanceLogContext and automatic step start/complete logging. - * Requires `this` to have an `instanceId: string` property. No-op when CONFIG_DIR is empty. + * Only adds logging - does NOT wrap with withInstanceLogContext (caller handles that). + * No-op when CONFIG_DIR is empty. */ export function logStagehandStep(invocation: string, label: string) { return function Promise>( @@ -1176,22 +1175,20 @@ export function logStagehandStep(invocation: string, label: string) { } return async function ( - this: { instanceId: string }, + this: unknown, ...args: unknown[] ): Promise { - return await withInstanceLogContext(this.instanceId, async () => { - SessionFileLogger.logStagehandStepEvent({ - invocation, - args: args.length > 0 ? args : undefined, - label, - }); - - try { - return await originalMethod.apply(this, args as never[]); - } finally { - SessionFileLogger.logStagehandStepCompleted(); - } + SessionFileLogger.logStagehandStepEvent({ + invocation, + args: args.length > 0 ? args : undefined, + label, }); + + try { + return await originalMethod.apply(this, args as never[]); + } finally { + SessionFileLogger.logStagehandStepCompleted(); + } } as T; }; } diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 60d3acd4f..43ccd12b7 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -985,128 +985,130 @@ export class V3 { @logStagehandStep("Stagehand.act", "ACT") async act(input: string | Action, options?: ActOptions): Promise { - if (!this.actHandler) throw new StagehandNotInitializedError("act()"); - - let actResult: ActResult; - - if (isObserveResult(input)) { - // Resolve page: use provided page if any, otherwise default active page - const v3Page = await this.resolvePage(options?.page); + return await withInstanceLogContext(this.instanceId, async () => { + if (!this.actHandler) throw new StagehandNotInitializedError("act()"); + + let actResult: ActResult; + + if (isObserveResult(input)) { + // Resolve page: use provided page if any, otherwise default active page + const v3Page = await this.resolvePage(options?.page); + + // Use selector as provided to support XPath, CSS, and other engines + const selector = input.selector; + if (this.apiClient) { + actResult = await this.apiClient.act({ + input, + options, + frameId: v3Page.mainFrameId(), + }); + } else { + const effectiveTimeoutMs = + typeof options?.timeout === "number" && options.timeout > 0 + ? options.timeout + : undefined; + const ensureTimeRemaining = createTimeoutGuard( + effectiveTimeoutMs, + (ms) => new ActTimeoutError(ms), + ); + actResult = await this.actHandler.takeDeterministicAction( + { ...input, selector }, + v3Page, + this.domSettleTimeoutMs, + this.resolveLlmClient(options?.model), + ensureTimeRemaining, + ); + } - // Use selector as provided to support XPath, CSS, and other engines - const selector = input.selector; - if (this.apiClient) { - actResult = await this.apiClient.act({ - input, - options, - frameId: v3Page.mainFrameId(), - }); - } else { - const effectiveTimeoutMs = - typeof options?.timeout === "number" && options.timeout > 0 - ? options.timeout - : undefined; - const ensureTimeRemaining = createTimeoutGuard( - effectiveTimeoutMs, - (ms) => new ActTimeoutError(ms), + // history: record ObserveResult-based act call + this.addToHistory( + "act", + { + observeResult: input, + }, + actResult, ); - actResult = await this.actHandler.takeDeterministicAction( - { ...input, selector }, - v3Page, - this.domSettleTimeoutMs, - this.resolveLlmClient(options?.model), - ensureTimeRemaining, + return actResult; + } + // instruction path + if (typeof input !== "string" || !input.trim()) { + throw new StagehandInvalidArgumentError( + "act(): instruction string is required unless passing an Action", ); } - // history: record ObserveResult-based act call - this.addToHistory( - "act", - { - observeResult: input, - }, - actResult, - ); - return actResult; - } - // instruction path - if (typeof input !== "string" || !input.trim()) { - throw new StagehandInvalidArgumentError( - "act(): instruction string is required unless passing an Action", - ); - } - - // Resolve page from options or default - const page = await this.resolvePage(options?.page); - - let actCacheContext: Awaited< - ReturnType - > | null = null; - const canUseCache = - typeof input === "string" && - !this.isAgentReplayRecording() && - this.actCache.enabled; - if (canUseCache) { - actCacheContext = await this.actCache.prepareContext( - input, - page, - options?.variables, - ); - if (actCacheContext) { - const cachedResult = await this.actCache.tryReplay( - actCacheContext, + // Resolve page from options or default + const page = await this.resolvePage(options?.page); + + let actCacheContext: Awaited< + ReturnType + > | null = null; + const canUseCache = + typeof input === "string" && + !this.isAgentReplayRecording() && + this.actCache.enabled; + if (canUseCache) { + actCacheContext = await this.actCache.prepareContext( + input, page, - options?.timeout, + options?.variables, ); - if (cachedResult) { - this.addToHistory( - "act", - { - instruction: input, - variables: options?.variables, - timeout: options?.timeout, - cacheHit: true, - }, - cachedResult, + if (actCacheContext) { + const cachedResult = await this.actCache.tryReplay( + actCacheContext, + page, + options?.timeout, ); - return cachedResult; + if (cachedResult) { + this.addToHistory( + "act", + { + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + cacheHit: true, + }, + cachedResult, + ); + return cachedResult; + } } } - } - const handlerParams: ActHandlerParams = { - instruction: input, - page, - variables: options?.variables, - timeout: options?.timeout, - model: options?.model, - }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - actResult = await this.apiClient.act({ input, options, frameId }); - } else { - actResult = await this.actHandler.act(handlerParams); - } - // history: record instruction-based act call (omit page object) - this.addToHistory( - "act", - { + const handlerParams: ActHandlerParams = { instruction: input, + page, variables: options?.variables, timeout: options?.timeout, - }, - actResult, - ); + model: options?.model, + }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + actResult = await this.apiClient.act({ input, options, frameId }); + } else { + actResult = await this.actHandler.act(handlerParams); + } + // history: record instruction-based act call (omit page object) + this.addToHistory( + "act", + { + instruction: input, + variables: options?.variables, + timeout: options?.timeout, + }, + actResult, + ); - if ( - actCacheContext && - actResult.success && - Array.isArray(actResult.actions) && - actResult.actions.length > 0 - ) { - await this.actCache.store(actCacheContext, actResult); - } - return actResult; + if ( + actCacheContext && + actResult.success && + Array.isArray(actResult.actions) && + actResult.actions.length > 0 + ) { + await this.actCache.store(actCacheContext, actResult); + } + return actResult; + }); } /** @@ -1140,68 +1142,70 @@ export class V3 { b?: StagehandZodSchema | ExtractOptions, c?: ExtractOptions, ): Promise { - if (!this.extractHandler) { - throw new StagehandNotInitializedError("extract()"); - } + return await withInstanceLogContext(this.instanceId, async () => { + if (!this.extractHandler) { + throw new StagehandNotInitializedError("extract()"); + } - // Normalize args - let instruction: string | undefined; - let schema: StagehandZodSchema | undefined; - let options: ExtractOptions | undefined; - - if (typeof a === "string") { - instruction = a; - const isZodSchema = (val: unknown): val is StagehandZodSchema => - !!val && - typeof val === "object" && - "parse" in val && - "safeParse" in val; - if (isZodSchema(b)) { - schema = b as StagehandZodSchema; - options = c as ExtractOptions | undefined; + // Normalize args + let instruction: string | undefined; + let schema: StagehandZodSchema | undefined; + let options: ExtractOptions | undefined; + + if (typeof a === "string") { + instruction = a; + const isZodSchema = (val: unknown): val is StagehandZodSchema => + !!val && + typeof val === "object" && + "parse" in val && + "safeParse" in val; + if (isZodSchema(b)) { + schema = b as StagehandZodSchema; + options = c as ExtractOptions | undefined; + } else { + options = b as ExtractOptions | undefined; + } } else { - options = b as ExtractOptions | undefined; + // a is options or undefined + options = (a as ExtractOptions) || undefined; } - } else { - // a is options or undefined - options = (a as ExtractOptions) || undefined; - } - if (!instruction && schema) { - throw new StagehandInvalidArgumentError( - "extract(): schema provided without instruction", - ); - } + if (!instruction && schema) { + throw new StagehandInvalidArgumentError( + "extract(): schema provided without instruction", + ); + } - // If instruction without schema → defaultExtractSchema - const effectiveSchema = - instruction && !schema ? defaultExtractSchema : schema; + // If instruction without schema → defaultExtractSchema + const effectiveSchema = + instruction && !schema ? defaultExtractSchema : schema; - // Resolve page from options or use active page - const page = await this.resolvePage(options?.page); + // Resolve page from options or use active page + const page = await this.resolvePage(options?.page); - const handlerParams: ExtractHandlerParams = { - instruction, - schema: effectiveSchema as StagehandZodSchema | undefined, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page, - }; - let result: z.infer | { pageText: string }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - result = await this.apiClient.extract({ - instruction: handlerParams.instruction, - schema: handlerParams.schema, - options, - frameId, - }); - } else { - result = - await this.extractHandler.extract(handlerParams); - } - return result; + const handlerParams: ExtractHandlerParams = { + instruction, + schema: effectiveSchema as StagehandZodSchema | undefined, + model: options?.model, + timeout: options?.timeout, + selector: options?.selector, + page, + }; + let result: z.infer | { pageText: string }; + if (this.apiClient) { + const frameId = page.mainFrameId(); + result = await this.apiClient.extract({ + instruction: handlerParams.instruction, + schema: handlerParams.schema, + options, + frameId, + }); + } else { + result = + await this.extractHandler.extract(handlerParams); + } + return result; + }); } /** @@ -1218,53 +1222,55 @@ export class V3 { a?: string | ObserveOptions, b?: ObserveOptions, ): Promise { - if (!this.observeHandler) { - throw new StagehandNotInitializedError("observe()"); - } - - // Normalize args - let instruction: string | undefined; - let options: ObserveOptions | undefined; - if (typeof a === "string") { - instruction = a; - options = b; - } else { - options = a as ObserveOptions | undefined; - } - - // Resolve to our internal Page type - const page = await this.resolvePage(options?.page); + return await withInstanceLogContext(this.instanceId, async () => { + if (!this.observeHandler) { + throw new StagehandNotInitializedError("observe()"); + } - const handlerParams: ObserveHandlerParams = { - instruction, - model: options?.model, - timeout: options?.timeout, - selector: options?.selector, - page: page!, - }; + // Normalize args + let instruction: string | undefined; + let options: ObserveOptions | undefined; + if (typeof a === "string") { + instruction = a; + options = b; + } else { + options = a as ObserveOptions | undefined; + } - let results: Action[]; - if (this.apiClient) { - const frameId = page.mainFrameId(); - results = await this.apiClient.observe({ - instruction, - options, - frameId, - }); - } else { - results = await this.observeHandler.observe(handlerParams); - } + // Resolve to our internal Page type + const page = await this.resolvePage(options?.page); - // history: record observe call (omit page object) - this.addToHistory( - "observe", - { + const handlerParams: ObserveHandlerParams = { instruction, + model: options?.model, timeout: options?.timeout, - }, - results, - ); - return results; + selector: options?.selector, + page: page!, + }; + + let results: Action[]; + if (this.apiClient) { + const frameId = page.mainFrameId(); + results = await this.apiClient.observe({ + instruction, + options, + frameId, + }); + } else { + results = await this.observeHandler.observe(handlerParams); + } + + // history: record observe call (omit page object) + this.addToHistory( + "observe", + { + instruction, + timeout: options?.timeout, + }, + results, + ); + return results; + }); } /** Return the browser-level CDP WebSocket endpoint. */ From 4cb2a69cf00db4d74cdf06c92089984880a4de05 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:17:57 -0800 Subject: [PATCH 23/32] fix lint issues --- packages/core/lib/v3/flowLogger.ts | 2 -- packages/core/lib/v3/understudy/page.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index da90cecdb..e93e2b472 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -1137,7 +1137,6 @@ export class SessionFileLogger { export function logAction(actionType: string) { return function Promise>( originalMethod: T, - _context: ClassMethodDecoratorContext, ): T { // No-op when logging is disabled if (!CONFIG_DIR) { @@ -1167,7 +1166,6 @@ export function logAction(actionType: string) { export function logStagehandStep(invocation: string, label: string) { return function Promise>( originalMethod: T, - _context: ClassMethodDecoratorContext, ): T { // No-op when logging is disabled if (!CONFIG_DIR) { diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 4ecd9b755..eafc03911 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -1,7 +1,7 @@ import { Protocol } from "devtools-protocol"; import { promises as fs } from "fs"; import { v3Logger } from "../logger"; -import { SessionFileLogger, logAction } from "../flowLogger"; +import { logAction } from "../flowLogger"; import type { CDPSessionLike } from "./cdp"; import { CdpConnection } from "./cdp"; import { Frame } from "./frame"; From 081c201abf26ba4c60f68b62a6da362e79057f81 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:27:26 -0800 Subject: [PATCH 24/32] remove uneeded pkg --- packages/core/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 5bcc338c7..cbf70b663 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -83,7 +83,6 @@ }, "devDependencies": { "@playwright/test": "^1.42.1", - "@types/uuid": "^10.0.0", "eslint": "^9.16.0", "prettier": "^3.2.5", "tsup": "^8.2.1", From 1a4ba75725a7de5b9c5e2c9b803240b08b22ebf9 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:32:16 -0800 Subject: [PATCH 25/32] also log llm response even in the case of parsing error --- packages/core/lib/v3/llm/aisdk.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index f1be3a392..1a813792d 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -159,6 +159,14 @@ export class AISdkClient extends LLMClient { : undefined, }); } catch (err) { + // Log error response to maintain request/response pairing + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateObject", + output: `[error: ${err instanceof Error ? err.message : "unknown"}]`, + }); + if (NoObjectGeneratedError.isInstance(err)) { this.logger?.({ category: "AISDK error", From 2f47d8a089194ae57ba7d5c29f20def2b32b2673 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:34:26 -0800 Subject: [PATCH 26/32] log errors in the case of llm response parsing issue --- packages/core/lib/v3/llm/aisdk.ts | 40 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index 1a813792d..354eb107c 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -273,20 +273,32 @@ export class AISdkClient extends LLMClient { prompt: promptPreview, }); - const textResponse = await generateText({ - model: this.model, - messages: formattedMessages, - tools: Object.keys(tools).length > 0 ? tools : undefined, - toolChoice: - Object.keys(tools).length > 0 - ? options.tool_choice === "required" - ? "required" - : options.tool_choice === "none" - ? "none" - : "auto" - : undefined, - temperature: options.temperature, - }); + let textResponse: Awaited>; + try { + textResponse = await generateText({ + model: this.model, + messages: formattedMessages, + tools: Object.keys(tools).length > 0 ? tools : undefined, + toolChoice: + Object.keys(tools).length > 0 + ? options.tool_choice === "required" + ? "required" + : options.tool_choice === "none" + ? "none" + : "auto" + : undefined, + temperature: options.temperature, + }); + } catch (err) { + // Log error response to maintain request/response pairing + SessionFileLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + operation: "generateText", + output: `[error: ${err instanceof Error ? err.message : "unknown"}]`, + }); + throw err; + } // Transform AI SDK response to match LLMResponse format expected by operator handler const transformedToolCalls = (textResponse.toolCalls || []).map( From 558aef126118fd7273cdc76f9191fc22fd79c7a2 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:38:54 -0800 Subject: [PATCH 27/32] fix missing SessionFileLogger.logAgentTaskCompleted in agent stream mode --- packages/core/lib/v3/v3.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 43ccd12b7..22b66bb55 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -1825,15 +1825,20 @@ export class V3 { ); if (cacheContext) { - return this.agentCache.wrapStreamForCaching( + const wrappedStream = this.agentCache.wrapStreamForCaching( cacheContext, streamResult, () => this.beginAgentReplayRecording(), () => this.endAgentReplayRecording(), () => this.discardAgentReplayRecording(), ); + // Log completion when stream is returned (stream completes asynchronously) + SessionFileLogger.logAgentTaskCompleted(); + return wrappedStream; } + // Log completion when stream is returned (stream completes asynchronously) + SessionFileLogger.logAgentTaskCompleted(); return streamResult; } From d8cd98b636a9fe4a923e832eacae569ea875cccc Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:42:34 -0800 Subject: [PATCH 28/32] bump lockfiles --- package.json | 2 +- pnpm-lock.yaml | 45 +++++++++++++++++++++++++-------------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 52ad35591..672a0d61c 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,5 @@ "overrides": { "whatwg-url": "^14.0.0" }, - "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" + "packageManager": "pnpm@10.25.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbd5a8576..dd40f412b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 1.2.0 chromium-bidi: specifier: ^0.10.0 - version: 0.10.2(devtools-protocol@0.0.1312386) + version: 0.10.2(devtools-protocol@0.0.1464554) esbuild: specifier: ^0.21.4 version: 0.21.5 @@ -232,9 +232,6 @@ importers: '@playwright/test': specifier: ^1.42.1 version: 1.54.2 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 eslint: specifier: ^9.16.0 version: 9.25.1(jiti@1.21.7) @@ -6506,18 +6503,18 @@ packages: snapshots: - '@ai-sdk/anthropic@2.0.34(zod@3.25.67)': + '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true - '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': + '@ai-sdk/anthropic@2.0.53(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) + zod: 3.25.67 optional: true '@ai-sdk/anthropic@2.0.53(zod@4.1.12)': @@ -6601,18 +6598,18 @@ snapshots: - supports-color optional: true - '@ai-sdk/google@2.0.23(zod@3.25.67)': + '@ai-sdk/google@2.0.23(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true - '@ai-sdk/google@2.0.23(zod@4.1.12)': + '@ai-sdk/google@2.0.44(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) + zod: 3.25.67 optional: true '@ai-sdk/google@2.0.44(zod@4.1.12)': @@ -6706,6 +6703,14 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.12 + '@ai-sdk/provider-utils@3.0.18(zod@3.25.67)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.67 + optional: true + '@ai-sdk/provider-utils@3.0.18(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -6861,11 +6866,11 @@ snapshots: zod: 3.25.67 zod-to-json-schema: 3.25.0(zod@3.25.67) optionalDependencies: - '@ai-sdk/anthropic': 2.0.34(zod@3.25.67) + '@ai-sdk/anthropic': 2.0.53(zod@3.25.67) '@ai-sdk/azure': 2.0.54(zod@3.25.67) '@ai-sdk/cerebras': 1.0.25(zod@3.25.67) '@ai-sdk/deepseek': 1.0.23(zod@3.25.67) - '@ai-sdk/google': 2.0.23(zod@3.25.67) + '@ai-sdk/google': 2.0.44(zod@3.25.67) '@ai-sdk/groq': 2.0.24(zod@3.25.67) '@ai-sdk/mistral': 2.0.19(zod@3.25.67) '@ai-sdk/openai': 2.0.53(zod@3.25.67) @@ -9194,9 +9199,9 @@ snapshots: transitivePeerDependencies: - supports-color - chromium-bidi@0.10.2(devtools-protocol@0.0.1312386): + chromium-bidi@0.10.2(devtools-protocol@0.0.1464554): dependencies: - devtools-protocol: 0.0.1312386 + devtools-protocol: 0.0.1464554 mitt: 3.0.1 zod: 3.23.8 From 74f4567932fc355309441cb88ef0af3ff739e9df Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:44:20 -0800 Subject: [PATCH 29/32] dont modify root lockfiles --- package.json | 2 +- pnpm-lock.yaml | 51 +++++++++++++++++--------------------------------- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 672a0d61c..52ad35591 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,5 @@ "overrides": { "whatwg-url": "^14.0.0" }, - "packageManager": "pnpm@10.25.0" + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd40f412b..f563ab992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 1.2.0 chromium-bidi: specifier: ^0.10.0 - version: 0.10.2(devtools-protocol@0.0.1464554) + version: 0.10.2(devtools-protocol@0.0.1312386) esbuild: specifier: ^0.21.4 version: 0.21.5 @@ -161,9 +161,6 @@ importers: playwright: specifier: ^1.52.0 version: 1.54.2 - uuid: - specifier: ^11.1.0 - version: 11.1.0 ws: specifier: ^8.18.0 version: 8.18.3(bufferutil@4.0.9) @@ -6212,10 +6209,6 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -6503,18 +6496,18 @@ packages: snapshots: - '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': + '@ai-sdk/anthropic@2.0.34(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) + zod: 3.25.67 optional: true - '@ai-sdk/anthropic@2.0.53(zod@3.25.67)': + '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true '@ai-sdk/anthropic@2.0.53(zod@4.1.12)': @@ -6598,18 +6591,18 @@ snapshots: - supports-color optional: true - '@ai-sdk/google@2.0.23(zod@4.1.12)': + '@ai-sdk/google@2.0.23(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) + zod: 3.25.67 optional: true - '@ai-sdk/google@2.0.44(zod@3.25.67)': + '@ai-sdk/google@2.0.23(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true '@ai-sdk/google@2.0.44(zod@4.1.12)': @@ -6703,14 +6696,6 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.12 - '@ai-sdk/provider-utils@3.0.18(zod@3.25.67)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 - eventsource-parser: 3.0.6 - zod: 3.25.67 - optional: true - '@ai-sdk/provider-utils@3.0.18(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -6866,11 +6851,11 @@ snapshots: zod: 3.25.67 zod-to-json-schema: 3.25.0(zod@3.25.67) optionalDependencies: - '@ai-sdk/anthropic': 2.0.53(zod@3.25.67) + '@ai-sdk/anthropic': 2.0.34(zod@3.25.67) '@ai-sdk/azure': 2.0.54(zod@3.25.67) '@ai-sdk/cerebras': 1.0.25(zod@3.25.67) '@ai-sdk/deepseek': 1.0.23(zod@3.25.67) - '@ai-sdk/google': 2.0.44(zod@3.25.67) + '@ai-sdk/google': 2.0.23(zod@3.25.67) '@ai-sdk/groq': 2.0.24(zod@3.25.67) '@ai-sdk/mistral': 2.0.19(zod@3.25.67) '@ai-sdk/openai': 2.0.53(zod@3.25.67) @@ -9199,9 +9184,9 @@ snapshots: transitivePeerDependencies: - supports-color - chromium-bidi@0.10.2(devtools-protocol@0.0.1464554): + chromium-bidi@0.10.2(devtools-protocol@0.0.1312386): dependencies: - devtools-protocol: 0.0.1464554 + devtools-protocol: 0.0.1312386 mitt: 3.0.1 zod: 3.23.8 @@ -13630,8 +13615,6 @@ snapshots: uuid@10.0.0: {} - uuid@11.1.0: {} - uuid@9.0.1: {} validate.io-array@1.0.6: {} From 705b957bd439ccdef1ba33240a480539aafb5388 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:50:12 -0800 Subject: [PATCH 30/32] comment why Page.setViewportSize is disabled --- packages/core/lib/v3/understudy/page.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index eafc03911..9d315e803 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -1242,15 +1242,12 @@ export class Page { * Force the page viewport to an exact CSS size and device scale factor. * Ensures screenshots match width x height pixels when deviceScaleFactor = 1. */ + // @logAction("Page.setViewportSize") // disabled because it's pretty noisy, can always re-enable if needed for debugging async setViewportSize( width: number, height: number, options?: { deviceScaleFactor?: number }, ): Promise { - // SessionFileLogger.logUnderstudyActionEvent({ - // actionType: "Page.setViewportSize", - // args: [width, height, options], - // }); const dsf = Math.max(0.01, options?.deviceScaleFactor ?? 1); await this.mainSession .send("Emulation.setDeviceMetricsOverride", { From 23aa321b592c850e4be9ff06c18912bb902cebb7 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 12:52:10 -0800 Subject: [PATCH 31/32] bump lockfiles --- pnpm-lock.yaml | 51 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f563ab992..dd40f412b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 1.2.0 chromium-bidi: specifier: ^0.10.0 - version: 0.10.2(devtools-protocol@0.0.1312386) + version: 0.10.2(devtools-protocol@0.0.1464554) esbuild: specifier: ^0.21.4 version: 0.21.5 @@ -161,6 +161,9 @@ importers: playwright: specifier: ^1.52.0 version: 1.54.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 ws: specifier: ^8.18.0 version: 8.18.3(bufferutil@4.0.9) @@ -6209,6 +6212,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -6496,18 +6503,18 @@ packages: snapshots: - '@ai-sdk/anthropic@2.0.34(zod@3.25.67)': + '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true - '@ai-sdk/anthropic@2.0.34(zod@4.1.12)': + '@ai-sdk/anthropic@2.0.53(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) + zod: 3.25.67 optional: true '@ai-sdk/anthropic@2.0.53(zod@4.1.12)': @@ -6591,18 +6598,18 @@ snapshots: - supports-color optional: true - '@ai-sdk/google@2.0.23(zod@3.25.67)': + '@ai-sdk/google@2.0.23(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.67) - zod: 3.25.67 + '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) + zod: 4.1.12 optional: true - '@ai-sdk/google@2.0.23(zod@4.1.12)': + '@ai-sdk/google@2.0.44(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 3.0.18(zod@3.25.67) + zod: 3.25.67 optional: true '@ai-sdk/google@2.0.44(zod@4.1.12)': @@ -6696,6 +6703,14 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.12 + '@ai-sdk/provider-utils@3.0.18(zod@3.25.67)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.67 + optional: true + '@ai-sdk/provider-utils@3.0.18(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -6851,11 +6866,11 @@ snapshots: zod: 3.25.67 zod-to-json-schema: 3.25.0(zod@3.25.67) optionalDependencies: - '@ai-sdk/anthropic': 2.0.34(zod@3.25.67) + '@ai-sdk/anthropic': 2.0.53(zod@3.25.67) '@ai-sdk/azure': 2.0.54(zod@3.25.67) '@ai-sdk/cerebras': 1.0.25(zod@3.25.67) '@ai-sdk/deepseek': 1.0.23(zod@3.25.67) - '@ai-sdk/google': 2.0.23(zod@3.25.67) + '@ai-sdk/google': 2.0.44(zod@3.25.67) '@ai-sdk/groq': 2.0.24(zod@3.25.67) '@ai-sdk/mistral': 2.0.19(zod@3.25.67) '@ai-sdk/openai': 2.0.53(zod@3.25.67) @@ -9184,9 +9199,9 @@ snapshots: transitivePeerDependencies: - supports-color - chromium-bidi@0.10.2(devtools-protocol@0.0.1312386): + chromium-bidi@0.10.2(devtools-protocol@0.0.1464554): dependencies: - devtools-protocol: 0.0.1312386 + devtools-protocol: 0.0.1464554 mitt: 3.0.1 zod: 3.23.8 @@ -13615,6 +13630,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.0: {} + uuid@9.0.1: {} validate.io-array@1.0.6: {} From 81621b1edffc76672a5c56a2998fbc05e1cff894 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 8 Dec 2025 16:34:51 -0800 Subject: [PATCH 32/32] Update packages/core/lib/v3/flowLogger.ts Co-authored-by: Miguel <36487034+miguelg719@users.noreply.github.com> --- packages/core/lib/v3/flowLogger.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/lib/v3/flowLogger.ts b/packages/core/lib/v3/flowLogger.ts index e93e2b472..7adce0a00 100644 --- a/packages/core/lib/v3/flowLogger.ts +++ b/packages/core/lib/v3/flowLogger.ts @@ -608,7 +608,6 @@ export class SessionFileLogger { // Symlink creation can fail on Windows or due to permissions } - // Create file streams // Create file streams const dir = ctx.sessionDir; ctx.fileStreams.agent = fs.createWriteStream(