diff --git a/docs/best-practices/caching.mdx b/docs/best-practices/caching.mdx index 6835192b0..bd332b72a 100644 --- a/docs/best-practices/caching.mdx +++ b/docs/best-practices/caching.mdx @@ -5,6 +5,8 @@ description: You can cache actions in Stagehand to avoid redundant LLM calls. Caching actions in Stagehand is useful for actions that are expensive to run, or when the underlying DOM structure is not expected to change. +> Set the `cacheDir` constructor option or the `STAGEHAND_CACHE_DIR` environment variable to change where Stagehand stores cache files. This is helpful in environments where the default `/tmp/.cache` path is not writable. + ## Using `observe` to preview an action `observe` lets you preview an action before taking it. If you are satisfied with the action preview, you can run it in `page.act` with no further LLM calls. @@ -203,4 +205,4 @@ page_content = await page.content() ``` -You may also want to use the accessibility tree, the DOM, or any other information to create a more unique key. You can do this as you please, with very similar logic to the above example. \ No newline at end of file +You may also want to use the accessibility tree, the DOM, or any other information to create a more unique key. You can do this as you please, with very similar logic to the above example. diff --git a/lib/cache/BaseCache.ts b/lib/cache/BaseCache.ts index 18ef61690..92df28d4f 100644 --- a/lib/cache/BaseCache.ts +++ b/lib/cache/BaseCache.ts @@ -31,17 +31,26 @@ export class BaseCache { constructor( logger: (message: LogLine) => void, - cacheDir: string = path.join(process.cwd(), "tmp", ".cache"), + cacheDir?: string, cacheFile: string = "cache.json", ) { this.logger = logger; - this.cacheDir = cacheDir; - this.cacheFile = path.join(cacheDir, cacheFile); - this.lockFile = path.join(cacheDir, "cache.lock"); + const resolvedCacheDir = this.resolveCacheDirectory(cacheDir); + this.cacheDir = resolvedCacheDir; + this.cacheFile = path.join(resolvedCacheDir, cacheFile); + this.lockFile = path.join(resolvedCacheDir, "cache.lock"); this.ensureCacheDirectory(); this.setupProcessHandlers(); } + private resolveCacheDirectory(override?: string): string { + const candidate = override ?? process.env.STAGEHAND_CACHE_DIR; + if (candidate?.trim()) { + return path.isAbsolute(candidate) ? candidate : path.resolve(candidate); + } + return path.join(process.cwd(), "tmp", ".cache"); + } + private setupProcessHandlers(): void { const releaseLockAndExit = () => { this.releaseLock(); diff --git a/lib/index.ts b/lib/index.ts index 7a1a95470..e8feedfaa 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -375,6 +375,7 @@ export class Stagehand { public verbose: 0 | 1 | 2; public llmProvider: LLMProvider; public enableCaching: boolean; + public readonly cacheDir?: string; protected apiKey: string | undefined; private projectId: string | undefined; private externalLogger?: (logLine: LogLine) => void; @@ -523,6 +524,7 @@ export class Stagehand { browserbaseSessionCreateParams, domSettleTimeoutMs, enableCaching, + cacheDir, browserbaseSessionID, modelName, modelClientOptions, @@ -555,8 +557,12 @@ export class Stagehand { enableCaching ?? (process.env.ENABLE_CACHING && process.env.ENABLE_CACHING === "true"); + const cacheDirCandidate = cacheDir ?? process.env.STAGEHAND_CACHE_DIR; + this.cacheDir = cacheDirCandidate ? path.resolve(cacheDirCandidate) : undefined; + this.llmProvider = - llmProvider || new LLMProvider(this.logger, this.enableCaching); + llmProvider || + new LLMProvider(this.logger, this.enableCaching, this.cacheDir); this.apiKey = apiKey ?? process.env.BROWSERBASE_API_KEY; this.projectId = projectId ?? process.env.BROWSERBASE_PROJECT_ID; diff --git a/lib/llm/LLMProvider.ts b/lib/llm/LLMProvider.ts index f99d6edf8..ef8296de2 100644 --- a/lib/llm/LLMProvider.ts +++ b/lib/llm/LLMProvider.ts @@ -133,10 +133,14 @@ export class LLMProvider { private enableCaching: boolean; private cache: LLMCache | undefined; - constructor(logger: (message: LogLine) => void, enableCaching: boolean) { + constructor( + logger: (message: LogLine) => void, + enableCaching: boolean, + cacheDir?: string, + ) { this.logger = logger; this.enableCaching = enableCaching; - this.cache = enableCaching ? new LLMCache(logger) : undefined; + this.cache = enableCaching ? new LLMCache(logger, cacheDir) : undefined; } cleanRequestCache(requestId: string): void { diff --git a/tests/cacheDir.test.ts b/tests/cacheDir.test.ts new file mode 100644 index 000000000..ed102ac06 --- /dev/null +++ b/tests/cacheDir.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { LLMCache } from "../lib/cache/LLMCache"; +import { LLMProvider } from "../lib/llm/LLMProvider"; +import { LogLine } from "../types/log"; + +const noopLogger: (line: LogLine) => void = () => {}; + +test("LLMCache respects explicit cache directory override", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stagehand-cache-")); + const customDir = path.join(tmpRoot, "custom-cache"); + + try { + new LLMCache(noopLogger, customDir); + assert.ok( + fs.existsSync(customDir), + "expected custom cache directory to be created", + ); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +test( + "LLMCache reads cache directory from STAGEHAND_CACHE_DIR env var", + () => { + const tmpRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "stagehand-env-cache-"), + ); + const envDir = path.join(tmpRoot, "env-cache"); + const previous = process.env.STAGEHAND_CACHE_DIR; + process.env.STAGEHAND_CACHE_DIR = envDir; + + try { + new LLMCache(noopLogger); + assert.ok( + fs.existsSync(envDir), + "expected env-configured cache directory to be created", + ); + } finally { + if (previous === undefined) { + delete process.env.STAGEHAND_CACHE_DIR; + } else { + process.env.STAGEHAND_CACHE_DIR = previous; + } + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }, +); + +test("LLMProvider skips creating cache directory when caching disabled", () => { + const tmpRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "stagehand-provider-cache-"), + ); + const disabledDir = path.join(tmpRoot, "disabled-cache"); + + try { + new LLMProvider(noopLogger, false, disabledDir); + assert.ok( + !fs.existsSync(disabledDir), + "expected cache directory to be absent when caching is disabled", + ); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + diff --git a/types/stagehand.ts b/types/stagehand.ts index 50f2ac2a5..4696093d0 100644 --- a/types/stagehand.ts +++ b/types/stagehand.ts @@ -57,6 +57,11 @@ export interface ConstructorParams { * @default true */ enableCaching?: boolean; + /** + * Directory used to persist cache files when caching is enabled + * @default `/tmp/.cache` + */ + cacheDir?: string; /** * The ID of a Browserbase session to resume */