@browserbasehq/stagehand 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ import { LogLine } from "../../types/log";
2
+ import { BaseCache, CacheEntry } from "./BaseCache";
3
+
4
+ export interface PlaywrightCommand {
5
+ method: string;
6
+ args: string[];
7
+ }
8
+
9
+ export interface ActionEntry extends CacheEntry {
10
+ data: {
11
+ playwrightCommand: PlaywrightCommand;
12
+ componentString: string;
13
+ xpaths: string[];
14
+ newStepString: string;
15
+ completed: boolean;
16
+ previousSelectors: string[];
17
+ action: string;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * ActionCache handles logging and retrieving actions along with their Playwright commands.
23
+ */
24
+ export class ActionCache extends BaseCache<ActionEntry> {
25
+ constructor(
26
+ logger: (message: LogLine) => void,
27
+ cacheDir?: string,
28
+ cacheFile?: string,
29
+ ) {
30
+ super(logger, cacheDir, cacheFile || "action_cache.json");
31
+ }
32
+
33
+ public async addActionStep({
34
+ url,
35
+ action,
36
+ previousSelectors,
37
+ playwrightCommand,
38
+ componentString,
39
+ xpaths,
40
+ newStepString,
41
+ completed,
42
+ requestId,
43
+ }: {
44
+ url: string;
45
+ action: string;
46
+ previousSelectors: string[];
47
+ playwrightCommand: PlaywrightCommand;
48
+ componentString: string;
49
+ requestId: string;
50
+ xpaths: string[];
51
+ newStepString: string;
52
+ completed: boolean;
53
+ }): Promise<void> {
54
+ this.logger({
55
+ category: "action_cache",
56
+ message: "adding action step to cache",
57
+ level: 1,
58
+ auxiliary: {
59
+ action: {
60
+ value: action,
61
+ type: "string",
62
+ },
63
+ requestId: {
64
+ value: requestId,
65
+ type: "string",
66
+ },
67
+ url: {
68
+ value: url,
69
+ type: "string",
70
+ },
71
+ previousSelectors: {
72
+ value: JSON.stringify(previousSelectors),
73
+ type: "object",
74
+ },
75
+ },
76
+ });
77
+
78
+ await this.set(
79
+ { url, action, previousSelectors },
80
+ {
81
+ playwrightCommand,
82
+ componentString,
83
+ xpaths,
84
+ newStepString,
85
+ completed,
86
+ previousSelectors,
87
+ action,
88
+ },
89
+ requestId,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Retrieves all actions for a specific trajectory.
95
+ * @param trajectoryId - Unique identifier for the trajectory.
96
+ * @param requestId - The identifier for the current request.
97
+ * @returns An array of TrajectoryEntry objects or null if not found.
98
+ */
99
+ public async getActionStep({
100
+ url,
101
+ action,
102
+ previousSelectors,
103
+ requestId,
104
+ }: {
105
+ url: string;
106
+ action: string;
107
+ previousSelectors: string[];
108
+ requestId: string;
109
+ }): Promise<ActionEntry["data"] | null> {
110
+ const data = await super.get({ url, action, previousSelectors }, requestId);
111
+ if (!data) {
112
+ return null;
113
+ }
114
+
115
+ return data;
116
+ }
117
+
118
+ public async removeActionStep(cacheHashObj: {
119
+ url: string;
120
+ action: string;
121
+ previousSelectors: string[];
122
+ requestId: string;
123
+ }): Promise<void> {
124
+ await super.delete(cacheHashObj);
125
+ }
126
+
127
+ /**
128
+ * Clears all actions for a specific trajectory.
129
+ * @param trajectoryId - Unique identifier for the trajectory.
130
+ * @param requestId - The identifier for the current request.
131
+ */
132
+ public async clearAction(requestId: string): Promise<void> {
133
+ await super.deleteCacheForRequestId(requestId);
134
+ this.logger({
135
+ category: "action_cache",
136
+ message: "cleared action for ID",
137
+ level: 1,
138
+ auxiliary: {
139
+ requestId: {
140
+ value: requestId,
141
+ type: "string",
142
+ },
143
+ },
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Resets the entire action cache.
149
+ */
150
+ public async resetCache(): Promise<void> {
151
+ await super.resetCache();
152
+ this.logger({
153
+ category: "action_cache",
154
+ message: "Action cache has been reset.",
155
+ level: 1,
156
+ });
157
+ }
158
+ }
@@ -0,0 +1,553 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as crypto from "crypto";
4
+ import { LogLine } from "../../types/log";
5
+
6
+ export interface CacheEntry {
7
+ timestamp: number;
8
+ data: any;
9
+ requestId: string;
10
+ }
11
+
12
+ export interface CacheStore {
13
+ [key: string]: CacheEntry;
14
+ }
15
+
16
+ export class BaseCache<T extends CacheEntry> {
17
+ private readonly CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
18
+ private readonly CLEANUP_PROBABILITY = 0.01; // 1% chance
19
+
20
+ protected cacheDir: string;
21
+ protected cacheFile: string;
22
+ protected lockFile: string;
23
+ protected logger: (message: LogLine) => void;
24
+
25
+ private readonly LOCK_TIMEOUT_MS = 1_000;
26
+ protected lockAcquired = false;
27
+ protected lockAcquireFailures = 0;
28
+
29
+ // Added for request ID tracking
30
+ protected requestIdToUsedHashes: { [key: string]: string[] } = {};
31
+
32
+ constructor(
33
+ logger: (message: LogLine) => void,
34
+ cacheDir: string = path.join(process.cwd(), "tmp", ".cache"),
35
+ cacheFile: string = "cache.json",
36
+ ) {
37
+ this.logger = logger;
38
+ this.cacheDir = cacheDir;
39
+ this.cacheFile = path.join(cacheDir, cacheFile);
40
+ this.lockFile = path.join(cacheDir, "cache.lock");
41
+ this.ensureCacheDirectory();
42
+ this.setupProcessHandlers();
43
+ }
44
+
45
+ private setupProcessHandlers(): void {
46
+ const releaseLockAndExit = () => {
47
+ this.releaseLock();
48
+ process.exit();
49
+ };
50
+
51
+ process.on("exit", releaseLockAndExit);
52
+ process.on("SIGINT", releaseLockAndExit);
53
+ process.on("SIGTERM", releaseLockAndExit);
54
+ process.on("uncaughtException", (err) => {
55
+ this.logger({
56
+ category: "base_cache",
57
+ message: "uncaught exception",
58
+ level: 2,
59
+ auxiliary: {
60
+ error: {
61
+ value: err.message,
62
+ type: "string",
63
+ },
64
+ trace: {
65
+ value: err.stack,
66
+ type: "string",
67
+ },
68
+ },
69
+ });
70
+ if (this.lockAcquired) {
71
+ releaseLockAndExit();
72
+ }
73
+ });
74
+ }
75
+
76
+ protected ensureCacheDirectory(): void {
77
+ if (!fs.existsSync(this.cacheDir)) {
78
+ fs.mkdirSync(this.cacheDir, { recursive: true });
79
+ this.logger({
80
+ category: "base_cache",
81
+ message: "created cache directory",
82
+ level: 1,
83
+ auxiliary: {
84
+ cacheDir: {
85
+ value: this.cacheDir,
86
+ type: "string",
87
+ },
88
+ },
89
+ });
90
+ }
91
+ }
92
+
93
+ protected createHash(data: any): string {
94
+ const hash = crypto.createHash("sha256");
95
+ return hash.update(JSON.stringify(data)).digest("hex");
96
+ }
97
+
98
+ protected sleep(ms: number): Promise<void> {
99
+ return new Promise((resolve) => setTimeout(resolve, ms));
100
+ }
101
+
102
+ public async acquireLock(): Promise<boolean> {
103
+ const startTime = Date.now();
104
+ while (Date.now() - startTime < this.LOCK_TIMEOUT_MS) {
105
+ try {
106
+ if (fs.existsSync(this.lockFile)) {
107
+ const lockAge = Date.now() - fs.statSync(this.lockFile).mtimeMs;
108
+ if (lockAge > this.LOCK_TIMEOUT_MS) {
109
+ fs.unlinkSync(this.lockFile);
110
+ this.logger({
111
+ category: "base_cache",
112
+ message: "Stale lock file removed",
113
+ level: 1,
114
+ });
115
+ }
116
+ }
117
+
118
+ fs.writeFileSync(this.lockFile, process.pid.toString(), { flag: "wx" });
119
+ this.lockAcquireFailures = 0;
120
+ this.lockAcquired = true;
121
+ this.logger({
122
+ category: "base_cache",
123
+ message: "Lock acquired",
124
+ level: 1,
125
+ });
126
+ return true;
127
+ } catch (error) {
128
+ await this.sleep(5);
129
+ }
130
+ }
131
+ this.logger({
132
+ category: "base_cache",
133
+ message: "Failed to acquire lock after timeout",
134
+ level: 2,
135
+ });
136
+ this.lockAcquireFailures++;
137
+ if (this.lockAcquireFailures >= 3) {
138
+ this.logger({
139
+ category: "base_cache",
140
+ message:
141
+ "Failed to acquire lock 3 times in a row. Releasing lock manually.",
142
+ level: 1,
143
+ });
144
+ this.releaseLock();
145
+ }
146
+ return false;
147
+ }
148
+
149
+ public releaseLock(): void {
150
+ try {
151
+ if (fs.existsSync(this.lockFile)) {
152
+ fs.unlinkSync(this.lockFile);
153
+ this.logger({
154
+ category: "base_cache",
155
+ message: "Lock released",
156
+ level: 1,
157
+ });
158
+ }
159
+ this.lockAcquired = false;
160
+ } catch (error) {
161
+ this.logger({
162
+ category: "base_cache",
163
+ message: "error releasing lock",
164
+ level: 2,
165
+ auxiliary: {
166
+ error: {
167
+ value: error.message,
168
+ type: "string",
169
+ },
170
+ trace: {
171
+ value: error.stack,
172
+ type: "string",
173
+ },
174
+ },
175
+ });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Cleans up stale cache entries that exceed the maximum age.
181
+ */
182
+ public async cleanupStaleEntries(): Promise<void> {
183
+ if (!(await this.acquireLock())) {
184
+ this.logger({
185
+ category: "llm_cache",
186
+ message: "failed to acquire lock for cleanup",
187
+ level: 2,
188
+ });
189
+ return;
190
+ }
191
+
192
+ try {
193
+ const cache = this.readCache();
194
+ const now = Date.now();
195
+ let entriesRemoved = 0;
196
+
197
+ for (const [hash, entry] of Object.entries(cache)) {
198
+ if (now - entry.timestamp > this.CACHE_MAX_AGE_MS) {
199
+ delete cache[hash];
200
+ entriesRemoved++;
201
+ }
202
+ }
203
+
204
+ if (entriesRemoved > 0) {
205
+ this.writeCache(cache);
206
+ this.logger({
207
+ category: "llm_cache",
208
+ message: "cleaned up stale cache entries",
209
+ level: 1,
210
+ auxiliary: {
211
+ entriesRemoved: {
212
+ value: entriesRemoved.toString(),
213
+ type: "integer",
214
+ },
215
+ },
216
+ });
217
+ }
218
+ } catch (error) {
219
+ this.logger({
220
+ category: "llm_cache",
221
+ message: "error during cache cleanup",
222
+ level: 2,
223
+ auxiliary: {
224
+ error: {
225
+ value: error.message,
226
+ type: "string",
227
+ },
228
+ trace: {
229
+ value: error.stack,
230
+ type: "string",
231
+ },
232
+ },
233
+ });
234
+ } finally {
235
+ this.releaseLock();
236
+ }
237
+ }
238
+
239
+ protected readCache(): CacheStore {
240
+ if (fs.existsSync(this.cacheFile)) {
241
+ try {
242
+ const data = fs.readFileSync(this.cacheFile, "utf-8");
243
+ return JSON.parse(data) as CacheStore;
244
+ } catch (error) {
245
+ this.logger({
246
+ category: "base_cache",
247
+ message: "error reading cache file. resetting cache.",
248
+ level: 1,
249
+ auxiliary: {
250
+ error: {
251
+ value: error.message,
252
+ type: "string",
253
+ },
254
+ trace: {
255
+ value: error.stack,
256
+ type: "string",
257
+ },
258
+ },
259
+ });
260
+ this.resetCache();
261
+ return {};
262
+ }
263
+ }
264
+ return {};
265
+ }
266
+
267
+ protected writeCache(cache: CacheStore): void {
268
+ try {
269
+ fs.writeFileSync(this.cacheFile, JSON.stringify(cache, null, 2));
270
+ this.logger({
271
+ category: "base_cache",
272
+ message: "Cache written to file",
273
+ level: 1,
274
+ });
275
+ } catch (error) {
276
+ this.logger({
277
+ category: "base_cache",
278
+ message: "error writing cache file",
279
+ level: 2,
280
+ auxiliary: {
281
+ error: {
282
+ value: error.message,
283
+ type: "string",
284
+ },
285
+ trace: {
286
+ value: error.stack,
287
+ type: "string",
288
+ },
289
+ },
290
+ });
291
+ } finally {
292
+ this.releaseLock();
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Retrieves data from the cache based on the provided options.
298
+ * @param hashObj - The options used to generate the cache key.
299
+ * @param requestId - The identifier for the current request.
300
+ * @returns The cached data if available, otherwise null.
301
+ */
302
+ public async get(
303
+ hashObj: Record<string, any> | string,
304
+ requestId: string,
305
+ ): Promise<T["data"] | null> {
306
+ if (!(await this.acquireLock())) {
307
+ this.logger({
308
+ category: "base_cache",
309
+ message: "Failed to acquire lock for getting cache",
310
+ level: 2,
311
+ });
312
+ return null;
313
+ }
314
+
315
+ try {
316
+ const hash = this.createHash(hashObj);
317
+ const cache = this.readCache();
318
+
319
+ if (cache[hash]) {
320
+ this.trackRequestIdUsage(requestId, hash);
321
+ return cache[hash].data;
322
+ }
323
+ return null;
324
+ } catch (error) {
325
+ this.logger({
326
+ category: "base_cache",
327
+ message: "error getting cache. resetting cache.",
328
+ level: 1,
329
+ auxiliary: {
330
+ error: {
331
+ value: error.message,
332
+ type: "string",
333
+ },
334
+ trace: {
335
+ value: error.stack,
336
+ type: "string",
337
+ },
338
+ },
339
+ });
340
+
341
+ this.resetCache();
342
+ return null;
343
+ } finally {
344
+ this.releaseLock();
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Stores data in the cache based on the provided options and requestId.
350
+ * @param hashObj - The options used to generate the cache key.
351
+ * @param data - The data to be cached.
352
+ * @param requestId - The identifier for the cache entry.
353
+ */
354
+ public async set(
355
+ hashObj: Record<string, any>,
356
+ data: T["data"],
357
+ requestId: string,
358
+ ): Promise<void> {
359
+ if (!(await this.acquireLock())) {
360
+ this.logger({
361
+ category: "base_cache",
362
+ message: "Failed to acquire lock for setting cache",
363
+ level: 2,
364
+ });
365
+ return;
366
+ }
367
+
368
+ try {
369
+ const hash = this.createHash(hashObj);
370
+ const cache = this.readCache();
371
+ cache[hash] = {
372
+ data,
373
+ timestamp: Date.now(),
374
+ requestId,
375
+ };
376
+
377
+ this.writeCache(cache);
378
+ this.trackRequestIdUsage(requestId, hash);
379
+ } catch (error) {
380
+ this.logger({
381
+ category: "base_cache",
382
+ message: "error setting cache. resetting cache.",
383
+ level: 1,
384
+ auxiliary: {
385
+ error: {
386
+ value: error.message,
387
+ type: "string",
388
+ },
389
+ trace: {
390
+ value: error.stack,
391
+ type: "string",
392
+ },
393
+ },
394
+ });
395
+
396
+ this.resetCache();
397
+ } finally {
398
+ this.releaseLock();
399
+
400
+ if (Math.random() < this.CLEANUP_PROBABILITY) {
401
+ this.cleanupStaleEntries();
402
+ }
403
+ }
404
+ }
405
+
406
+ public async delete(hashObj: Record<string, any>): Promise<void> {
407
+ if (!(await this.acquireLock())) {
408
+ this.logger({
409
+ category: "base_cache",
410
+ message: "Failed to acquire lock for removing cache entry",
411
+ level: 2,
412
+ });
413
+ return;
414
+ }
415
+
416
+ try {
417
+ const hash = this.createHash(hashObj);
418
+ const cache = this.readCache();
419
+
420
+ if (cache[hash]) {
421
+ delete cache[hash];
422
+ this.writeCache(cache);
423
+ } else {
424
+ this.logger({
425
+ category: "base_cache",
426
+ message: "Cache entry not found to delete",
427
+ level: 1,
428
+ });
429
+ }
430
+ } catch (error) {
431
+ this.logger({
432
+ category: "base_cache",
433
+ message: "error removing cache entry",
434
+ level: 2,
435
+ auxiliary: {
436
+ error: {
437
+ value: error.message,
438
+ type: "string",
439
+ },
440
+ trace: {
441
+ value: error.stack,
442
+ type: "string",
443
+ },
444
+ },
445
+ });
446
+ } finally {
447
+ this.releaseLock();
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Tracks the usage of a hash with a specific requestId.
453
+ * @param requestId - The identifier for the current request.
454
+ * @param hash - The cache key hash.
455
+ */
456
+ protected trackRequestIdUsage(requestId: string, hash: string): void {
457
+ this.requestIdToUsedHashes[requestId] ??= [];
458
+ this.requestIdToUsedHashes[requestId].push(hash);
459
+ }
460
+
461
+ /**
462
+ * Deletes all cache entries associated with a specific requestId.
463
+ * @param requestId - The identifier for the request whose cache entries should be deleted.
464
+ */
465
+ public async deleteCacheForRequestId(requestId: string): Promise<void> {
466
+ if (!(await this.acquireLock())) {
467
+ this.logger({
468
+ category: "base_cache",
469
+ message: "Failed to acquire lock for deleting cache",
470
+ level: 2,
471
+ });
472
+ return;
473
+ }
474
+ try {
475
+ const cache = this.readCache();
476
+ const hashes = this.requestIdToUsedHashes[requestId] ?? [];
477
+ let entriesRemoved = 0;
478
+ for (const hash of hashes) {
479
+ if (cache[hash]) {
480
+ delete cache[hash];
481
+ entriesRemoved++;
482
+ }
483
+ }
484
+ if (entriesRemoved > 0) {
485
+ this.writeCache(cache);
486
+ } else {
487
+ this.logger({
488
+ category: "base_cache",
489
+ message: "no cache entries found for requestId",
490
+ level: 1,
491
+ auxiliary: {
492
+ requestId: {
493
+ value: requestId,
494
+ type: "string",
495
+ },
496
+ },
497
+ });
498
+ }
499
+ // Remove the requestId from the mapping after deletion
500
+ delete this.requestIdToUsedHashes[requestId];
501
+ } catch (error) {
502
+ this.logger({
503
+ category: "base_cache",
504
+ message: "error deleting cache for requestId",
505
+ level: 2,
506
+ auxiliary: {
507
+ error: {
508
+ value: error.message,
509
+ type: "string",
510
+ },
511
+ trace: {
512
+ value: error.stack,
513
+ type: "string",
514
+ },
515
+ requestId: {
516
+ value: requestId,
517
+ type: "string",
518
+ },
519
+ },
520
+ });
521
+ } finally {
522
+ this.releaseLock();
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Resets the entire cache by clearing the cache file.
528
+ */
529
+ public resetCache(): void {
530
+ try {
531
+ fs.writeFileSync(this.cacheFile, "{}");
532
+ this.requestIdToUsedHashes = {}; // Reset requestId tracking
533
+ } catch (error) {
534
+ this.logger({
535
+ category: "base_cache",
536
+ message: "error resetting cache",
537
+ level: 2,
538
+ auxiliary: {
539
+ error: {
540
+ value: error.message,
541
+ type: "string",
542
+ },
543
+ trace: {
544
+ value: error.stack,
545
+ type: "string",
546
+ },
547
+ },
548
+ });
549
+ } finally {
550
+ this.releaseLock();
551
+ }
552
+ }
553
+ }