@thoughtflow/browser 0.2.1 → 0.3.1
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.
package/package.json
CHANGED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserSessionPool — manages multiple headless browser sessions with
|
|
3
|
+
* isolated pages, console logs, and network activity per session.
|
|
4
|
+
*
|
|
5
|
+
* Auto-cleans up sessions after 5 minutes of inactivity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface ConsoleEntry {
|
|
9
|
+
type: string
|
|
10
|
+
text: string
|
|
11
|
+
location: string
|
|
12
|
+
timestamp: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface NetworkEntry {
|
|
16
|
+
idx: number
|
|
17
|
+
method: string
|
|
18
|
+
url: string
|
|
19
|
+
status?: number
|
|
20
|
+
statusText?: string
|
|
21
|
+
duration?: number
|
|
22
|
+
error?: string
|
|
23
|
+
reqHeaders?: Record<string, string>
|
|
24
|
+
reqBody?: string
|
|
25
|
+
resHeaders?: Record<string, string>
|
|
26
|
+
resBody?: string
|
|
27
|
+
timestamp: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BrowserPage {
|
|
31
|
+
id: string
|
|
32
|
+
page: import("playwright").Page
|
|
33
|
+
consoleEntries: ConsoleEntry[]
|
|
34
|
+
networkEntries: NetworkEntry[]
|
|
35
|
+
netIdx: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BrowserSession {
|
|
39
|
+
browser: import("playwright").Browser
|
|
40
|
+
pages: BrowserPage[]
|
|
41
|
+
activePageId: string
|
|
42
|
+
initUrl: string
|
|
43
|
+
createdAt: number
|
|
44
|
+
lastActivity: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SESSION_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
48
|
+
|
|
49
|
+
export class BrowserSessionPool {
|
|
50
|
+
private sessions = new Map<string, BrowserSession>()
|
|
51
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
|
52
|
+
|
|
53
|
+
/** Create or retrieve a session. If sessionId is empty, creates a new one. */
|
|
54
|
+
async getOrCreate(sessionId?: string): Promise<{ sessionId: string; page: import("playwright").Page }> {
|
|
55
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
56
|
+
const session = this.sessions.get(sessionId)!
|
|
57
|
+
session.lastActivity = Date.now()
|
|
58
|
+
const activePage = session.pages.find(p => p.id === session.activePageId)
|
|
59
|
+
if (activePage && !activePage.page.isClosed()) {
|
|
60
|
+
return { sessionId, page: activePage.page }
|
|
61
|
+
}
|
|
62
|
+
// Active page closed — create a new one
|
|
63
|
+
return this.newPage(sessionId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create new session
|
|
67
|
+
const id = sessionId ?? `br_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
69
|
+
const { chromium } = require("playwright") as typeof import("playwright")
|
|
70
|
+
const browser = await chromium.launch({ headless: true })
|
|
71
|
+
const page = await browser.newPage()
|
|
72
|
+
|
|
73
|
+
const browserPage: BrowserPage = {
|
|
74
|
+
id: `pg_${Date.now()}`,
|
|
75
|
+
page,
|
|
76
|
+
consoleEntries: [],
|
|
77
|
+
networkEntries: [],
|
|
78
|
+
netIdx: 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const session: BrowserSession = {
|
|
82
|
+
browser,
|
|
83
|
+
pages: [browserPage],
|
|
84
|
+
activePageId: browserPage.id,
|
|
85
|
+
initUrl: "",
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
lastActivity: Date.now(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.sessions.set(id, session)
|
|
91
|
+
this.startCleanup()
|
|
92
|
+
|
|
93
|
+
// Attach listeners
|
|
94
|
+
this.attachListeners(browserPage)
|
|
95
|
+
|
|
96
|
+
return { sessionId: id, page }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Navigate the active page of a session. */
|
|
100
|
+
async getPage(sessionId: string): Promise<import("playwright").Page | null> {
|
|
101
|
+
const session = this.sessions.get(sessionId)
|
|
102
|
+
if (!session) return null
|
|
103
|
+
session.lastActivity = Date.now()
|
|
104
|
+
const active = session.pages.find(p => p.id === session.activePageId)
|
|
105
|
+
if (!active || active.page.isClosed()) return null
|
|
106
|
+
return active.page
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Open a new tab in an existing session. */
|
|
110
|
+
async newPage(sessionId: string): Promise<{ sessionId: string; page: import("playwright").Page }> {
|
|
111
|
+
const session = this.sessions.get(sessionId)
|
|
112
|
+
if (!session) throw new Error(`Session ${sessionId} not found`)
|
|
113
|
+
|
|
114
|
+
session.lastActivity = Date.now()
|
|
115
|
+
const page = await session.browser.newPage()
|
|
116
|
+
|
|
117
|
+
const browserPage: BrowserPage = {
|
|
118
|
+
id: `pg_${Date.now()}`,
|
|
119
|
+
page,
|
|
120
|
+
consoleEntries: [],
|
|
121
|
+
networkEntries: [],
|
|
122
|
+
netIdx: 0,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
session.pages.push(browserPage)
|
|
126
|
+
session.activePageId = browserPage.id
|
|
127
|
+
this.attachListeners(browserPage)
|
|
128
|
+
|
|
129
|
+
return { sessionId, page }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Switch active tab. */
|
|
133
|
+
switchPage(sessionId: string, pageId: string): boolean {
|
|
134
|
+
const session = this.sessions.get(sessionId)
|
|
135
|
+
if (!session) return false
|
|
136
|
+
const exists = session.pages.find(p => p.id === pageId && !p.page.isClosed())
|
|
137
|
+
if (!exists) return false
|
|
138
|
+
session.activePageId = pageId
|
|
139
|
+
session.lastActivity = Date.now()
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Close a tab (or the active one if pageId omitted). */
|
|
144
|
+
async closePage(sessionId: string, pageId?: string): Promise<boolean> {
|
|
145
|
+
const session = this.sessions.get(sessionId)
|
|
146
|
+
if (!session) return false
|
|
147
|
+
session.lastActivity = Date.now()
|
|
148
|
+
|
|
149
|
+
const targetId = pageId ?? session.activePageId
|
|
150
|
+
const idx = session.pages.findIndex(p => p.id === targetId)
|
|
151
|
+
if (idx === -1) return false
|
|
152
|
+
|
|
153
|
+
const bp = session.pages[idx]!
|
|
154
|
+
await bp.page.close().catch(() => {})
|
|
155
|
+
session.pages.splice(idx, 1)
|
|
156
|
+
|
|
157
|
+
// If we closed the active page, switch to another
|
|
158
|
+
if (session.activePageId === targetId && session.pages.length > 0) {
|
|
159
|
+
session.activePageId = session.pages[0]!.id
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Destroy a session completely. */
|
|
166
|
+
async destroy(sessionId: string): Promise<void> {
|
|
167
|
+
const session = this.sessions.get(sessionId)
|
|
168
|
+
if (!session) return
|
|
169
|
+
|
|
170
|
+
for (const bp of session.pages) {
|
|
171
|
+
await bp.page.close().catch(() => {})
|
|
172
|
+
}
|
|
173
|
+
await session.browser.close().catch(() => {})
|
|
174
|
+
this.sessions.delete(sessionId)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Destroy all sessions. */
|
|
178
|
+
async destroyAll(): Promise<number> {
|
|
179
|
+
let count = 0
|
|
180
|
+
for (const [id, session] of this.sessions) {
|
|
181
|
+
for (const bp of session.pages) {
|
|
182
|
+
await bp.page.close().catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
await session.browser.close().catch(() => {})
|
|
185
|
+
this.sessions.delete(id)
|
|
186
|
+
count++
|
|
187
|
+
}
|
|
188
|
+
if (this.cleanupTimer) {
|
|
189
|
+
clearInterval(this.cleanupTimer)
|
|
190
|
+
this.cleanupTimer = null
|
|
191
|
+
}
|
|
192
|
+
return count
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Set the initial navigation URL for a session. */
|
|
196
|
+
setInitUrl(sessionId: string, url: string): void {
|
|
197
|
+
const session = this.sessions.get(sessionId)
|
|
198
|
+
if (session && !session.initUrl) {
|
|
199
|
+
session.initUrl = url
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** List all active sessions with metadata. */
|
|
204
|
+
list(): Array<{
|
|
205
|
+
sessionId: string
|
|
206
|
+
initUrl: string
|
|
207
|
+
currentUrl: string
|
|
208
|
+
pageCount: number
|
|
209
|
+
activePageId: string
|
|
210
|
+
createdAt: number
|
|
211
|
+
lastActivity: number
|
|
212
|
+
status: "active" | "idle"
|
|
213
|
+
}> {
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
const result: Array<{
|
|
216
|
+
sessionId: string
|
|
217
|
+
initUrl: string
|
|
218
|
+
currentUrl: string
|
|
219
|
+
pageCount: number
|
|
220
|
+
activePageId: string
|
|
221
|
+
createdAt: number
|
|
222
|
+
lastActivity: number
|
|
223
|
+
status: "active" | "idle"
|
|
224
|
+
}> = []
|
|
225
|
+
|
|
226
|
+
for (const [id, session] of this.sessions) {
|
|
227
|
+
const active = session.pages.find(p => p.id === session.activePageId && !p.page.isClosed())
|
|
228
|
+
const idleMs = now - session.lastActivity
|
|
229
|
+
result.push({
|
|
230
|
+
sessionId: id,
|
|
231
|
+
initUrl: session.initUrl || "(no navigation yet)",
|
|
232
|
+
currentUrl: active ? active.page.url() : "(closed)",
|
|
233
|
+
pageCount: session.pages.filter(p => !p.page.isClosed()).length,
|
|
234
|
+
activePageId: session.activePageId,
|
|
235
|
+
createdAt: session.createdAt,
|
|
236
|
+
lastActivity: session.lastActivity,
|
|
237
|
+
status: idleMs > 60_000 ? "idle" : "active",
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return result
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Get console logs for active page. */
|
|
244
|
+
getConsole(sessionId: string): ConsoleEntry[] {
|
|
245
|
+
return this.getActivePageData(sessionId)?.consoleEntries ?? []
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Get network entries for active page. */
|
|
249
|
+
getNetwork(sessionId: string): NetworkEntry[] {
|
|
250
|
+
return this.getActivePageData(sessionId)?.networkEntries ?? []
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Clear console logs for active page. */
|
|
254
|
+
clearConsole(sessionId: string): void {
|
|
255
|
+
const bp = this.getActivePageData(sessionId)
|
|
256
|
+
if (bp) bp.consoleEntries = []
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Clear network entries for active page. */
|
|
260
|
+
clearNetwork(sessionId: string): void {
|
|
261
|
+
const bp = this.getActivePageData(sessionId)
|
|
262
|
+
if (bp) bp.networkEntries = []
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Cache a screenshot base64 string for the session. */
|
|
266
|
+
cacheScreenshot(sessionId: string, base64: string): void {
|
|
267
|
+
const session = this.sessions.get(sessionId)
|
|
268
|
+
if (session) (session as any).cachedScreenshot = base64
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get cached screenshot base64 string. */
|
|
272
|
+
getCachedScreenshot(sessionId: string): string | null {
|
|
273
|
+
const session = this.sessions.get(sessionId)
|
|
274
|
+
return (session as any).cachedScreenshot ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** List all open tabs in a session. */
|
|
278
|
+
listPages(sessionId: string): Array<{ id: string; url: string; title: string }> {
|
|
279
|
+
const session = this.sessions.get(sessionId)
|
|
280
|
+
if (!session) return []
|
|
281
|
+
return session.pages
|
|
282
|
+
.filter(p => !p.page.isClosed())
|
|
283
|
+
.map(p => ({
|
|
284
|
+
id: p.id,
|
|
285
|
+
url: p.page.url(),
|
|
286
|
+
title: "", // Will be filled by caller if needed
|
|
287
|
+
active: p.id === session.activePageId,
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
private getActivePageData(sessionId: string): BrowserPage | null {
|
|
294
|
+
const session = this.sessions.get(sessionId)
|
|
295
|
+
if (!session) return null
|
|
296
|
+
session.lastActivity = Date.now()
|
|
297
|
+
return session.pages.find(p => p.id === session.activePageId && !p.page.isClosed()) ?? null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private attachListeners(bp: BrowserPage): void {
|
|
301
|
+
// Console
|
|
302
|
+
bp.page.on("console", (msg) => {
|
|
303
|
+
bp.consoleEntries.push({
|
|
304
|
+
type: msg.type(),
|
|
305
|
+
text: msg.text(),
|
|
306
|
+
location: msg.location().url ? `${msg.location().url}:${msg.location().lineNumber}` : "",
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Network
|
|
312
|
+
const timings = new Map<string, number>()
|
|
313
|
+
bp.page.on("request", (req) => {
|
|
314
|
+
const idx = ++bp.netIdx
|
|
315
|
+
timings.set(req.url() + req.method(), Date.now())
|
|
316
|
+
let reqBody: string | undefined
|
|
317
|
+
try { reqBody = req.postData() ?? undefined } catch {}
|
|
318
|
+
bp.networkEntries.push({
|
|
319
|
+
idx, method: req.method(), url: req.url(),
|
|
320
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
321
|
+
reqBody, timestamp: Date.now(),
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
bp.page.on("response", async (resp) => {
|
|
325
|
+
const key = resp.url() + resp.request().method()
|
|
326
|
+
const start = timings.get(key)
|
|
327
|
+
const duration = start ? Date.now() - start : undefined
|
|
328
|
+
let entry = bp.networkEntries.find(e => e.url === resp.url() && e.method === resp.request().method())
|
|
329
|
+
if (!entry) {
|
|
330
|
+
entry = { idx: ++bp.netIdx, method: resp.request().method(), url: resp.url(), timestamp: Date.now() }
|
|
331
|
+
bp.networkEntries.push(entry)
|
|
332
|
+
}
|
|
333
|
+
entry.status = resp.status()
|
|
334
|
+
entry.statusText = resp.statusText()
|
|
335
|
+
entry.duration = duration
|
|
336
|
+
entry.resHeaders = resp.headers() as Record<string, string>
|
|
337
|
+
try {
|
|
338
|
+
const ct = resp.headers()["content-type"] ?? ""
|
|
339
|
+
if (ct.includes("json") || ct.includes("text") || ct.includes("xml") || ct.includes("javascript")) {
|
|
340
|
+
entry.resBody = await resp.text().catch(() => undefined)
|
|
341
|
+
}
|
|
342
|
+
} catch {}
|
|
343
|
+
})
|
|
344
|
+
bp.page.on("requestfailed", (req) => {
|
|
345
|
+
bp.networkEntries.push({
|
|
346
|
+
idx: ++bp.netIdx, method: req.method(), url: req.url(),
|
|
347
|
+
error: req.failure()?.errorText ?? "Unknown error",
|
|
348
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private startCleanup(): void {
|
|
355
|
+
if (this.cleanupTimer) return
|
|
356
|
+
this.cleanupTimer = setInterval(() => {
|
|
357
|
+
const now = Date.now()
|
|
358
|
+
for (const [id, session] of this.sessions) {
|
|
359
|
+
if (now - session.lastActivity > SESSION_TTL_MS) {
|
|
360
|
+
for (const bp of session.pages) {
|
|
361
|
+
bp.page.close().catch(() => {})
|
|
362
|
+
}
|
|
363
|
+
session.browser.close().catch(() => {})
|
|
364
|
+
this.sessions.delete(id)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (this.sessions.size === 0 && this.cleanupTimer) {
|
|
368
|
+
clearInterval(this.cleanupTimer)
|
|
369
|
+
this.cleanupTimer = null
|
|
370
|
+
}
|
|
371
|
+
}, 60_000).unref()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Singleton instance */
|
|
376
|
+
export const browserPool = new BrowserSessionPool()
|
|
@@ -1,176 +1,375 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ToolBase, ToolContext, ToolExecutionResult } from "@thoughtflow/core";
|
|
2
|
+
import { ToolBase, ToolContext, ToolExecutionResult, LlmAdapter } from "@thoughtflow/core";
|
|
3
|
+
import { browserPool } from "./browser-session-pool";
|
|
4
|
+
import { buildUserVisionMessage } from "@thoughtflow/core";
|
|
3
5
|
|
|
4
6
|
const BROWSER_ACTIONS = [
|
|
7
|
+
"open",
|
|
5
8
|
"navigate",
|
|
9
|
+
"snapshot",
|
|
6
10
|
"get_text",
|
|
7
|
-
"screenshot",
|
|
8
11
|
"get_dom",
|
|
9
|
-
"
|
|
12
|
+
"screenshot",
|
|
10
13
|
"click",
|
|
11
14
|
"type",
|
|
12
15
|
"select",
|
|
16
|
+
"scroll",
|
|
17
|
+
"console",
|
|
18
|
+
"network",
|
|
19
|
+
"vision",
|
|
20
|
+
"new_page",
|
|
21
|
+
"switch_page",
|
|
22
|
+
"list_pages",
|
|
23
|
+
"list",
|
|
24
|
+
"close_page",
|
|
13
25
|
"close",
|
|
26
|
+
"close_all",
|
|
14
27
|
] as const;
|
|
15
28
|
|
|
16
29
|
type BrowserAction = (typeof BROWSER_ACTIONS)[number];
|
|
17
30
|
|
|
18
|
-
const
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
const READ_ONLY_ACTIONS = new Set<BrowserAction>([
|
|
32
|
+
"snapshot", "get_text", "get_dom", "screenshot",
|
|
33
|
+
"console", "network", "list_pages", "list", "vision",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const DESTRUCTIVE_ACTIONS = new Set<BrowserAction>([
|
|
37
|
+
"close", "close_page", "close_all",
|
|
24
38
|
]);
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
|
-
* BrowserTool —
|
|
41
|
+
* BrowserTool — stateful headless browser with session support.
|
|
28
42
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* missing, run() returns `success: false` with a helpful install message.
|
|
43
|
+
* Sessions persist across calls via sessionId. Each session can have
|
|
44
|
+
* multiple tabs. Console and network activity is auto-captured.
|
|
32
45
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
46
|
+
* Use `open` to start, then navigate/interact/screenshot, finally `close`.
|
|
47
|
+
* Omit sessionId to use a default single session.
|
|
35
48
|
*/
|
|
36
49
|
export class BrowserTool extends ToolBase {
|
|
37
50
|
readonly name = "browser";
|
|
51
|
+
/** Static vision adapter — set once before use. Used by the `vision` action. */
|
|
52
|
+
static visionAdapter: LlmAdapter | null = null
|
|
53
|
+
static visionModel: string = "gemma3:12b"
|
|
38
54
|
readonly description =
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
55
|
+
"Stateful headless browser (Playwright/Chromium). " +
|
|
56
|
+
"Sessions persist across calls — open a session, navigate pages, " +
|
|
57
|
+
"interact, inspect, then close. Each session has its own browser process " +
|
|
58
|
+
"(isolated cookies/storage). Console and network auto-captured. " +
|
|
59
|
+
"Actions: open, navigate, snapshot (accessibility tree), get_text, " +
|
|
60
|
+
"get_dom (raw HTML), screenshot, click, type, select, scroll, " +
|
|
61
|
+
"console (browser logs), network (requests/responses), " +
|
|
62
|
+
"vision (visual AI analysis of current page), " +
|
|
63
|
+
"new_page, switch_page, list_pages, list (all sessions), " +
|
|
64
|
+
"close_page, close, close_all. " +
|
|
65
|
+
"Use snapshot/click with [ref=eN] selectors for reliable interaction. " +
|
|
66
|
+
"Sessions auto-expire after 5 minutes of inactivity.\n\n" +
|
|
67
|
+
"CANONICAL FLOW — follow this exactly:\n" +
|
|
68
|
+
"1. browser({action:\"open\"}) → save sessionId\n" +
|
|
69
|
+
"2. browser({action:\"navigate\", sessionId, url:\"...\"})\n" +
|
|
70
|
+
"3. browser({action:\"snapshot\", sessionId}) → find [ref=eN]\n" +
|
|
71
|
+
"4. browser({action:\"click\", sessionId, selector:\"e4\"})\n" +
|
|
72
|
+
"5. browser({action:\"vision\", sessionId, prompt:\"Is the button visible?\"})\n" +
|
|
73
|
+
"6. browser({action:\"console\", sessionId}) to check for JS errors\n" +
|
|
74
|
+
"7. browser({action:\"close\", sessionId}) when done\n" +
|
|
75
|
+
"ALWAYS reuse sessionId — NEVER call open again for the same browsing task."
|
|
43
76
|
|
|
44
|
-
/** Read-only actions: navigate/get_text/screenshot/get_dom/extract */
|
|
45
77
|
override isReadOnly(input: Record<string, unknown>): boolean {
|
|
46
|
-
return
|
|
78
|
+
return READ_ONLY_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override isDestructive(input: Record<string, unknown>): boolean {
|
|
82
|
+
return DESTRUCTIVE_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
constructor() {
|
|
50
86
|
super(
|
|
51
87
|
z.object({
|
|
52
|
-
action: z
|
|
53
|
-
|
|
54
|
-
.describe("
|
|
55
|
-
url: z.string().optional().describe("URL to navigate to
|
|
56
|
-
selector: z.string().optional()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
88
|
+
action: z.enum(BROWSER_ACTIONS).describe("Browser action to perform"),
|
|
89
|
+
sessionId: z.string().optional()
|
|
90
|
+
.describe("Session ID from `open`. Omit for default single session."),
|
|
91
|
+
url: z.string().optional().describe("URL to navigate to"),
|
|
92
|
+
selector: z.string().optional()
|
|
93
|
+
.describe("CSS selector or [ref=eN] from snapshot"),
|
|
94
|
+
text: z.string().optional().describe("Text to type"),
|
|
95
|
+
value: z.string().optional().describe("Value to select or scroll amount"),
|
|
96
|
+
pageId: z.string().optional()
|
|
97
|
+
.describe("Tab/page ID for switch_page/close_page"),
|
|
98
|
+
clear: z.boolean().optional().default(false)
|
|
99
|
+
.describe("Clear console/network logs after reading"),
|
|
100
|
+
fullPage: z.boolean().optional().default(false)
|
|
101
|
+
.describe("Screenshot full page (not just viewport)"),
|
|
102
|
+
isolated: z.boolean().optional().default(true)
|
|
103
|
+
.describe("Use separate browser process for cookie/storage isolation (default: true)"),
|
|
104
|
+
prompt: z.string().optional()
|
|
105
|
+
.describe("Vision analysis prompt — what to check visually on the page"),
|
|
106
|
+
model: z.string().optional()
|
|
107
|
+
.describe("Vision model override (default: gemma3:12b)"),
|
|
108
|
+
useCached: z.boolean().optional().default(false)
|
|
109
|
+
.describe("Re-analyze the cached screenshot instead of taking a new one"),
|
|
61
110
|
})
|
|
62
111
|
);
|
|
63
112
|
}
|
|
64
113
|
|
|
65
|
-
// Shared browser instance (reused across calls)
|
|
66
|
-
private browser: import("playwright").Browser | null = null;
|
|
67
|
-
private page: import("playwright").Page | null = null;
|
|
68
|
-
|
|
69
|
-
private async getPage(): Promise<import("playwright").Page> {
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
71
|
-
const { chromium } = require("playwright") as typeof import("playwright");
|
|
72
|
-
if (!this.browser || !this.browser.isConnected()) {
|
|
73
|
-
this.browser = await chromium.launch({ headless: true });
|
|
74
|
-
}
|
|
75
|
-
if (!this.page || this.page.isClosed()) {
|
|
76
|
-
this.page = await this.browser.newPage();
|
|
77
|
-
}
|
|
78
|
-
return this.page;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
114
|
async run(input: Record<string, unknown>, _ctx: ToolContext): Promise<ToolExecutionResult> {
|
|
82
|
-
// Lazy-load Playwright
|
|
115
|
+
// Lazy-load Playwright
|
|
83
116
|
try {
|
|
84
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
117
|
require("playwright");
|
|
86
118
|
} catch {
|
|
87
119
|
return {
|
|
88
120
|
success: false,
|
|
89
|
-
error:
|
|
90
|
-
"Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
|
|
121
|
+
error: "Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
|
|
91
122
|
};
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
const action = (input["action"] as BrowserAction) ?? "navigate";
|
|
95
|
-
const
|
|
126
|
+
const sessionId = input["sessionId"] as string | undefined;
|
|
127
|
+
const clear = (input["clear"] as boolean) ?? false;
|
|
96
128
|
|
|
97
129
|
try {
|
|
98
|
-
const page = await this.getPage();
|
|
99
|
-
|
|
100
130
|
switch (action) {
|
|
131
|
+
case "open": {
|
|
132
|
+
const { sessionId: sid, page } = await browserPool.getOrCreate(sessionId);
|
|
133
|
+
return {
|
|
134
|
+
success: true,
|
|
135
|
+
output: JSON.stringify({
|
|
136
|
+
sessionId: sid,
|
|
137
|
+
message: "Browser session created. Use this sessionId for all subsequent calls.",
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
case "navigate": {
|
|
143
|
+
const page = await this.requirePage(sessionId);
|
|
144
|
+
if (!page) return this.noSession();
|
|
102
145
|
const url = input["url"] as string;
|
|
103
|
-
if (!url) return { success: false, error: "url is required
|
|
104
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
146
|
+
if (!url) return { success: false, error: "url is required" };
|
|
147
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
105
148
|
const title = await page.title();
|
|
149
|
+
if (sessionId) browserPool.setInitUrl(sessionId, url);
|
|
106
150
|
return { success: true, output: JSON.stringify({ title, url: page.url() }) };
|
|
107
151
|
}
|
|
108
152
|
|
|
153
|
+
case "snapshot": {
|
|
154
|
+
const page = await this.requirePage(sessionId);
|
|
155
|
+
if (!page) return this.noSession();
|
|
156
|
+
const tree = await this.getAccessibilityTree(page);
|
|
157
|
+
return { success: true, output: tree };
|
|
158
|
+
}
|
|
159
|
+
|
|
109
160
|
case "get_text": {
|
|
110
|
-
const
|
|
111
|
-
|
|
161
|
+
const page = await this.requirePage(sessionId);
|
|
162
|
+
if (!page) return this.noSession();
|
|
163
|
+
const sel = (input["selector"] as string) ?? "body";
|
|
164
|
+
const text = await page.locator(sel).first().innerText({ timeout: 5000 });
|
|
112
165
|
return { success: true, output: text.trim() };
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
case "get_dom": {
|
|
116
|
-
const
|
|
117
|
-
|
|
169
|
+
const page = await this.requirePage(sessionId);
|
|
170
|
+
if (!page) return this.noSession();
|
|
171
|
+
const sel = (input["selector"] as string) ?? "body";
|
|
172
|
+
const html = await page.locator(sel).first().innerHTML({ timeout: 5000 });
|
|
118
173
|
return { success: true, output: html };
|
|
119
174
|
}
|
|
120
175
|
|
|
121
|
-
case "
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
for (const field of fields) {
|
|
133
|
-
const el = row.locator(`[class*="${field}"], [data-${field}], .${field}`).first();
|
|
134
|
-
obj[field] = (await el.innerText().catch(() => "")).trim();
|
|
135
|
-
}
|
|
136
|
-
results.push(obj);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { success: true, output: JSON.stringify(results) };
|
|
176
|
+
case "screenshot": {
|
|
177
|
+
const page = await this.requirePage(sessionId);
|
|
178
|
+
if (!page) return this.noSession();
|
|
179
|
+
const buf = await page.screenshot({
|
|
180
|
+
type: "png",
|
|
181
|
+
fullPage: (input["fullPage"] as boolean) ?? false,
|
|
182
|
+
});
|
|
183
|
+
const b64 = buf.toString("base64");
|
|
184
|
+
// Cache for vision action
|
|
185
|
+
if (sessionId) browserPool.cacheScreenshot(sessionId, b64);
|
|
186
|
+
return { success: true, output: `screenshot:base64:${b64}` };
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
case "click": {
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
145
|
-
|
|
146
|
-
return { success:
|
|
190
|
+
const page = await this.requirePage(sessionId);
|
|
191
|
+
if (!page) return this.noSession();
|
|
192
|
+
const sel = input["selector"] as string;
|
|
193
|
+
if (!sel) return { success: false, error: "selector is required" };
|
|
194
|
+
const locator = this.resolveSelector(page, sel);
|
|
195
|
+
await locator.first().click({ timeout: 5000 });
|
|
196
|
+
return { success: true, output: `Clicked: ${sel}` };
|
|
147
197
|
}
|
|
148
198
|
|
|
149
199
|
case "type": {
|
|
150
|
-
const
|
|
200
|
+
const page = await this.requirePage(sessionId);
|
|
201
|
+
if (!page) return this.noSession();
|
|
202
|
+
const sel = input["selector"] as string;
|
|
151
203
|
const text = input["text"] as string;
|
|
152
|
-
if (!
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
if (!sel || text === undefined)
|
|
205
|
+
return { success: false, error: "selector and text required" };
|
|
206
|
+
const locator = this.resolveSelector(page, sel);
|
|
207
|
+
await locator.first().fill(text, { timeout: 5000 });
|
|
208
|
+
return { success: true, output: `Typed into ${sel}` };
|
|
155
209
|
}
|
|
156
210
|
|
|
157
211
|
case "select": {
|
|
158
|
-
const
|
|
212
|
+
const page = await this.requirePage(sessionId);
|
|
213
|
+
if (!page) return this.noSession();
|
|
214
|
+
const sel = input["selector"] as string;
|
|
159
215
|
const value = input["value"] as string;
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
if (!sel || !value)
|
|
217
|
+
return { success: false, error: "selector and value required" };
|
|
218
|
+
const locator = this.resolveSelector(page, sel);
|
|
219
|
+
await locator.first().selectOption(value, { timeout: 5000 });
|
|
220
|
+
return { success: true, output: `Selected ${value} in ${sel}` };
|
|
163
221
|
}
|
|
164
222
|
|
|
165
|
-
case "
|
|
166
|
-
const
|
|
167
|
-
|
|
223
|
+
case "scroll": {
|
|
224
|
+
const page = await this.requirePage(sessionId);
|
|
225
|
+
if (!page) return this.noSession();
|
|
226
|
+
const amount = parseInt(input["value"] as string ?? "") || 500;
|
|
227
|
+
await page.evaluate((px) => window.scrollBy(0, px), amount);
|
|
228
|
+
return { success: true, output: `Scrolled ${amount}px` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "vision": {
|
|
232
|
+
if (!BrowserTool.visionAdapter) {
|
|
233
|
+
return { success: false, error: "Vision adapter not configured. Set BrowserTool.visionAdapter before use." };
|
|
234
|
+
}
|
|
235
|
+
const prompt = input["prompt"] as string;
|
|
236
|
+
if (!prompt) return { success: false, error: "prompt is required for vision analysis" };
|
|
237
|
+
const useCached = (input["useCached"] as boolean) ?? false;
|
|
238
|
+
const model = input["model"] as string | undefined;
|
|
239
|
+
|
|
240
|
+
let base64Data: string | null = null;
|
|
241
|
+
if (useCached && sessionId) {
|
|
242
|
+
base64Data = browserPool.getCachedScreenshot(sessionId);
|
|
243
|
+
}
|
|
244
|
+
if (!base64Data) {
|
|
245
|
+
const page = await this.requirePage(sessionId);
|
|
246
|
+
if (!page) return this.noSession();
|
|
247
|
+
const buf = await page.screenshot({
|
|
248
|
+
type: "png",
|
|
249
|
+
fullPage: (input["fullPage"] as boolean) ?? false,
|
|
250
|
+
});
|
|
251
|
+
base64Data = buf.toString("base64");
|
|
252
|
+
if (sessionId) browserPool.cacheScreenshot(sessionId, base64Data);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const visionMessage = buildUserVisionMessage(
|
|
256
|
+
`You are a senior UI/UX designer and graphic design specialist. ` +
|
|
257
|
+
`Analyze this screenshot with extreme attention to visual detail:\n` +
|
|
258
|
+
`- Colors: exact hex/rgb values of backgrounds, text, borders, buttons\n` +
|
|
259
|
+
`- Borders: presence, color, width (px), radius, style (solid/dashed)\n` +
|
|
260
|
+
`- Spacing: padding, margins, gaps between elements (estimate px)\n` +
|
|
261
|
+
`- Typography: font sizes, weights, alignment, line heights\n` +
|
|
262
|
+
`- Layout: element positions, centering, whitespace balance\n` +
|
|
263
|
+
`- Visual bugs: overflow, misalignment, overlapping, cut-off text\n` +
|
|
264
|
+
`\n` +
|
|
265
|
+
`User request: ${prompt}\n` +
|
|
266
|
+
`\n` +
|
|
267
|
+
`Prioritize the user's request above these defaults. ` +
|
|
268
|
+
`If the user asks about something specific, focus on that ` +
|
|
269
|
+
`while still noting any obvious visual issues.`,
|
|
270
|
+
base64Data
|
|
271
|
+
);
|
|
272
|
+
const response = await BrowserTool.visionAdapter.chat({
|
|
273
|
+
model: model || BrowserTool.visionModel,
|
|
274
|
+
messages: [visionMessage],
|
|
275
|
+
tools: [],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return { success: true, output: response.message.content ?? "(no vision analysis)" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "console": {
|
|
282
|
+
if (!sessionId) return this.noSession();
|
|
283
|
+
const entries = browserPool.getConsole(sessionId);
|
|
284
|
+
if (clear) browserPool.clearConsole(sessionId);
|
|
285
|
+
if (entries.length === 0) return { success: true, output: "(no console output)" };
|
|
286
|
+
const lines = entries.map((e) => {
|
|
287
|
+
const tag = e.type === "error" ? "error" : e.type === "warning" ? "warn " : e.type;
|
|
288
|
+
const loc = e.location ? ` — ${e.location}` : "";
|
|
289
|
+
return `[${tag}] ${e.text.slice(0, 500)}${loc}`;
|
|
290
|
+
});
|
|
291
|
+
return { success: true, output: `Console (${entries.length} entries):\n${lines.join("\n")}` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "network": {
|
|
295
|
+
if (!sessionId) return this.noSession();
|
|
296
|
+
const entries = browserPool.getNetwork(sessionId);
|
|
297
|
+
if (clear) browserPool.clearNetwork(sessionId);
|
|
298
|
+
if (entries.length === 0) return { success: true, output: "(no network activity)" };
|
|
299
|
+
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
|
|
300
|
+
const lines = sorted.map((e) => {
|
|
301
|
+
const status = e.error ? "FAILED" : `${e.status} ${e.statusText ?? ""}`;
|
|
302
|
+
const dur = e.duration != null ? ` (${e.duration}ms)` : "";
|
|
303
|
+
return `${e.error ? "✗" : "✓"} ${e.method} ${e.url.slice(0, 120)} → ${status}${dur}`;
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
success: true,
|
|
307
|
+
output: `Network (${entries.length} requests):\n${lines.join("\n")}`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case "new_page": {
|
|
312
|
+
if (!sessionId) return { success: false, error: "sessionId required — use `open` first" };
|
|
313
|
+
const { page } = await browserPool.newPage(sessionId);
|
|
314
|
+
return {
|
|
315
|
+
success: true,
|
|
316
|
+
output: JSON.stringify({ pageId: `pg_active`, url: page.url(), message: "New tab created" }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case "switch_page": {
|
|
321
|
+
if (!sessionId) return this.noSession();
|
|
322
|
+
const pageId = input["pageId"] as string;
|
|
323
|
+
if (!pageId) return { success: false, error: "pageId required" };
|
|
324
|
+
const ok = browserPool.switchPage(sessionId, pageId);
|
|
325
|
+
return { success: ok, output: ok ? `Switched to ${pageId}` : `Page ${pageId} not found` };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case "list_pages": {
|
|
329
|
+
if (!sessionId) return this.noSession();
|
|
330
|
+
const pages = browserPool.listPages(sessionId);
|
|
331
|
+
const session = (browserPool as any).sessions?.get(sessionId);
|
|
332
|
+
if (session) {
|
|
333
|
+
for (const p of pages) {
|
|
334
|
+
const bp = session.pages?.find((bp2: any) => bp2.id === p.id && !bp2.page.isClosed());
|
|
335
|
+
if (bp) {
|
|
336
|
+
try { p.title = await bp.page.title(); } catch {}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
output: JSON.stringify(pages.map(p => ({
|
|
343
|
+
id: p.id,
|
|
344
|
+
url: p.url,
|
|
345
|
+
title: p.title,
|
|
346
|
+
active: (p as any).active,
|
|
347
|
+
}))),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "list": {
|
|
352
|
+
const sessions = browserPool.list();
|
|
353
|
+
if (sessions.length === 0) return { success: true, output: "(no active browser sessions)" };
|
|
354
|
+
return { success: true, output: JSON.stringify(sessions, null, 2) };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case "close_page": {
|
|
358
|
+
if (!sessionId) return this.noSession();
|
|
359
|
+
const pageId = input["pageId"] as string | undefined;
|
|
360
|
+
await browserPool.closePage(sessionId, pageId);
|
|
361
|
+
return { success: true, output: "Page closed" };
|
|
168
362
|
}
|
|
169
363
|
|
|
170
364
|
case "close": {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return { success: true, output:
|
|
365
|
+
if (!sessionId) return { success: false, error: "sessionId required" };
|
|
366
|
+
await browserPool.destroy(sessionId);
|
|
367
|
+
return { success: true, output: `Session ${sessionId} closed` };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case "close_all": {
|
|
371
|
+
const count = await browserPool.destroyAll();
|
|
372
|
+
return { success: true, output: `Closed ${count} browser session(s)` };
|
|
174
373
|
}
|
|
175
374
|
|
|
176
375
|
default:
|
|
@@ -180,4 +379,70 @@ export class BrowserTool extends ToolBase {
|
|
|
180
379
|
return { success: false, error: String(err instanceof Error ? err.message : err) };
|
|
181
380
|
}
|
|
182
381
|
}
|
|
183
|
-
|
|
382
|
+
|
|
383
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
private async requirePage(sessionId?: string): Promise<any> {
|
|
386
|
+
const { page } = await browserPool.getOrCreate(sessionId);
|
|
387
|
+
return page;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private noSession(): ToolExecutionResult {
|
|
391
|
+
return { success: false, error: "No active session. Use action: 'open' first." };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Resolve [ref=eN] selectors from snapshot, or pass through CSS selectors. */
|
|
395
|
+
private resolveSelector(page: any, sel: string) {
|
|
396
|
+
const refMatch = sel.match(/^e(\d+)$/);
|
|
397
|
+
if (refMatch) {
|
|
398
|
+
return page.locator(`[data-ref="e${refMatch[1]}"]`);
|
|
399
|
+
}
|
|
400
|
+
const bracketMatch = sel.match(/^\[ref=e(\d+)\]$/);
|
|
401
|
+
if (bracketMatch) {
|
|
402
|
+
return page.locator(`[data-ref="e${bracketMatch[1]}"]`);
|
|
403
|
+
}
|
|
404
|
+
return page.locator(sel);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Build a simplified accessibility tree with [ref=eN] markers. */
|
|
408
|
+
private async getAccessibilityTree(page: any): Promise<string> {
|
|
409
|
+
const lines: string[] = [];
|
|
410
|
+
|
|
411
|
+
// Inject data-ref attributes into interactive elements
|
|
412
|
+
await page.evaluate(() => {
|
|
413
|
+
let counter = 0;
|
|
414
|
+
const interactive = document.querySelectorAll(
|
|
415
|
+
'a, button, input, select, textarea, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [onclick], summary, details'
|
|
416
|
+
);
|
|
417
|
+
interactive.forEach((el) => {
|
|
418
|
+
(el as HTMLElement).setAttribute("data-ref", `e${++counter}`);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Use Playwright's accessibility snapshot
|
|
423
|
+
try {
|
|
424
|
+
const snapshot = await (page as any).accessibility.snapshot({ interestingOnly: false });
|
|
425
|
+
if (snapshot) {
|
|
426
|
+
this.flattenA11yTree(snapshot, lines, 0);
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
const text = await page.locator("body").innerText();
|
|
430
|
+
lines.push(text.slice(0, 5000));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return lines.join("\n") || "(empty page)";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private flattenA11yTree(node: any, lines: string[], depth: number): void {
|
|
437
|
+
const indent = " ".repeat(Math.min(depth, 6));
|
|
438
|
+
const role = node.role || "unknown";
|
|
439
|
+
const name = node.name ? ` "${node.name.slice(0, 80)}"` : "";
|
|
440
|
+
lines.push(`${indent}- ${role}${name}`);
|
|
441
|
+
|
|
442
|
+
if (node.children) {
|
|
443
|
+
for (const child of node.children) {
|
|
444
|
+
this.flattenA11yTree(child, lines, depth + 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
|
-
import { ToolBase, ToolContext, ToolExecutionResult, LlmAdapter
|
|
2
|
+
import { ToolBase, ToolContext, ToolExecutionResult, LlmAdapter } from "@thoughtflow/core"
|
|
3
|
+
import { buildUserVisionMessage } from "@thoughtflow/core"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* VisionBrowserTool — navigates to a URL, optionally interacts with the page,
|
|
@@ -23,7 +24,11 @@ export class VisionBrowserTool extends ToolBase {
|
|
|
23
24
|
"Analyzes it according to the provided prompt using a vision-capable model. " +
|
|
24
25
|
"Use this tool when you need to visually inspect a rendered web page — checking layout, " +
|
|
25
26
|
"colors, visibility of elements, UI correctness, or any visual property that cannot " +
|
|
26
|
-
"be determined from DOM text alone.
|
|
27
|
+
"be determined from DOM text alone. " +
|
|
28
|
+
"Optionally captures browser console logs (captureConsole: true) and network " +
|
|
29
|
+
"requests with full request/response details (captureNetwork: true). " +
|
|
30
|
+
"Network defaults to summary + full detail for failed requests — use " +
|
|
31
|
+
"\"all\" for every request body/headers, or \"failed\" for only errors."
|
|
27
32
|
readonly namespace = "web"
|
|
28
33
|
readonly tags = ["read", "analyze"]
|
|
29
34
|
readonly readonly = true
|
|
@@ -70,6 +75,14 @@ export class VisionBrowserTool extends ToolBase {
|
|
|
70
75
|
selector: z.string().optional(),
|
|
71
76
|
value: z.string().optional(),
|
|
72
77
|
})).optional().describe("Interactions to perform before screenshot"),
|
|
78
|
+
captureConsole: z.union([z.boolean(), z.enum(["error", "all"])]).optional().default(false)
|
|
79
|
+
.describe("Capture browser console output: true for last 50 entries, " +
|
|
80
|
+
"\"error\" for only errors/warnings, \"all\" for everything"),
|
|
81
|
+
captureNetwork: z.union([z.boolean(), z.enum(["failed", "all"])]).optional().default(false)
|
|
82
|
+
.describe("Capture network requests: true for summary + full detail on errors, " +
|
|
83
|
+
"\"failed\" for only errors with full detail, \"all\" for every request/response with headers and body"),
|
|
84
|
+
consoleMaxEntries: z.number().optional().default(50).describe("Max console entries (ignored when \"all\")"),
|
|
85
|
+
networkMaxEntries: z.number().optional().default(50).describe("Max network entries (ignored when \"all\")"),
|
|
73
86
|
})
|
|
74
87
|
)
|
|
75
88
|
}
|
|
@@ -142,10 +155,86 @@ export class VisionBrowserTool extends ToolBase {
|
|
|
142
155
|
if (sources.length > 1) return { success: false, error: "Provide exactly one of: url, html, or htmlFile" }
|
|
143
156
|
if (!prompt) return { success: false, error: "prompt is required" }
|
|
144
157
|
|
|
158
|
+
const captureConsole = input["captureConsole"] as boolean | "error" | "all" | undefined
|
|
159
|
+
const captureNetwork = input["captureNetwork"] as boolean | "failed" | "all" | undefined
|
|
160
|
+
const consoleMax = (input["consoleMaxEntries"] as number) ?? 50
|
|
161
|
+
const networkMax = (input["networkMaxEntries"] as number) ?? 50
|
|
162
|
+
|
|
163
|
+
// ── Console & network collectors ─────────────────────────────────
|
|
164
|
+
interface ConsoleEntry { type: string; text: string; location: string }
|
|
165
|
+
interface NetworkEntry {
|
|
166
|
+
idx: number; method: string; url: string; status?: number; statusText?: string
|
|
167
|
+
duration?: number; error?: string
|
|
168
|
+
reqHeaders?: Record<string, string>; reqBody?: string
|
|
169
|
+
resHeaders?: Record<string, string>; resBody?: string
|
|
170
|
+
}
|
|
171
|
+
const consoleEntries: ConsoleEntry[] = []
|
|
172
|
+
const networkEntries: NetworkEntry[] = []
|
|
173
|
+
let netIdx = 0
|
|
174
|
+
|
|
145
175
|
try {
|
|
146
176
|
const page = await this.getPage()
|
|
147
177
|
await page.setViewportSize({ width, height })
|
|
148
178
|
|
|
179
|
+
// ── Register console & network listeners (before navigation) ──
|
|
180
|
+
if (captureConsole) {
|
|
181
|
+
page.on("console", (msg) => {
|
|
182
|
+
const type = msg.type()
|
|
183
|
+
if (captureConsole === "error" && type !== "error" && type !== "warning") return
|
|
184
|
+
consoleEntries.push({
|
|
185
|
+
type,
|
|
186
|
+
text: msg.text(),
|
|
187
|
+
location: msg.location().url ? `${msg.location().url}:${msg.location().lineNumber}` : "",
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
if (captureNetwork) {
|
|
192
|
+
const timings = new Map<string, number>()
|
|
193
|
+
page.on("request", (req) => {
|
|
194
|
+
if (captureNetwork === "failed") return // only collect on response/failure
|
|
195
|
+
const idx = ++netIdx
|
|
196
|
+
timings.set(req.url() + req.method(), Date.now())
|
|
197
|
+
// Capture request body for POST/PUT/PATCH
|
|
198
|
+
let reqBody: string | undefined
|
|
199
|
+
try { reqBody = req.postData() ?? undefined } catch {}
|
|
200
|
+
networkEntries.push({
|
|
201
|
+
idx, method: req.method(), url: req.url(),
|
|
202
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
203
|
+
reqBody,
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
page.on("response", async (resp) => {
|
|
207
|
+
const key = resp.url() + resp.request().method()
|
|
208
|
+
const start = timings.get(key)
|
|
209
|
+
const duration = start ? Date.now() - start : undefined
|
|
210
|
+
// Find existing entry or create
|
|
211
|
+
let entry = networkEntries.find(e => e.url === resp.url() && e.method === resp.request().method())
|
|
212
|
+
if (!entry) {
|
|
213
|
+
entry = { idx: ++netIdx, method: resp.request().method(), url: resp.url() }
|
|
214
|
+
networkEntries.push(entry)
|
|
215
|
+
}
|
|
216
|
+
entry.status = resp.status()
|
|
217
|
+
entry.statusText = resp.statusText()
|
|
218
|
+
entry.duration = duration
|
|
219
|
+
// Capture response headers & body
|
|
220
|
+
entry.resHeaders = resp.headers() as Record<string, string>
|
|
221
|
+
try {
|
|
222
|
+
const ct = resp.headers()["content-type"] ?? ""
|
|
223
|
+
if (ct.includes("json") || ct.includes("text") || ct.includes("xml") || ct.includes("javascript")) {
|
|
224
|
+
entry.resBody = await resp.text().catch(() => undefined)
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
})
|
|
228
|
+
page.on("requestfailed", (req) => {
|
|
229
|
+
const idx = ++netIdx
|
|
230
|
+
networkEntries.push({
|
|
231
|
+
idx, method: req.method(), url: req.url(),
|
|
232
|
+
error: req.failure()?.errorText ?? "Unknown error",
|
|
233
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
149
238
|
// Set custom headers if provided (for auth, etc.)
|
|
150
239
|
if (headers && url) {
|
|
151
240
|
await page.setExtraHTTPHeaders(headers)
|
|
@@ -274,9 +363,74 @@ export class VisionBrowserTool extends ToolBase {
|
|
|
274
363
|
tools: [],
|
|
275
364
|
})
|
|
276
365
|
|
|
277
|
-
|
|
366
|
+
// ── Build final output with optional console & network ──────────
|
|
367
|
+
let output = response.message.content ?? ""
|
|
368
|
+
|
|
369
|
+
if (captureConsole && consoleEntries.length > 0) {
|
|
370
|
+
const entries = captureConsole === "all" ? consoleEntries : consoleEntries.slice(-consoleMax)
|
|
371
|
+
output += "\n\n── Console"
|
|
372
|
+
if (captureConsole !== "all" && consoleEntries.length > consoleMax) {
|
|
373
|
+
output += ` (${consoleEntries.length} total, showing last ${consoleMax})`
|
|
374
|
+
}
|
|
375
|
+
output += " ──\n"
|
|
376
|
+
for (const e of entries) {
|
|
377
|
+
const tag = e.type === "error" ? "error" : e.type === "warning" ? "warn " : e.type
|
|
378
|
+
const loc = e.location ? ` — ${e.location}` : ""
|
|
379
|
+
output += ` [${tag}] ${e.text.slice(0, 500)}${loc}\n`
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (captureNetwork && networkEntries.length > 0) {
|
|
384
|
+
const sorted = [...networkEntries].sort((a, b) => a.idx - b.idx)
|
|
385
|
+
const failed = sorted.filter(e => e.error || (e.status && e.status >= 400))
|
|
386
|
+
const isAll = captureNetwork === "all"
|
|
387
|
+
const isFailed = captureNetwork === "failed"
|
|
388
|
+
const toShow = isAll ? sorted : sorted.slice(-networkMax)
|
|
389
|
+
|
|
390
|
+
output += `\n\n── Network (${sorted.length} requests`
|
|
391
|
+
if (failed.length > 0) output += `, ${failed.length} failed`
|
|
392
|
+
output += ") ──\n"
|
|
393
|
+
for (const e of toShow) {
|
|
394
|
+
const status = e.error ? "FAILED" : `${e.status} ${e.statusText ?? ""}`
|
|
395
|
+
const dur = e.duration != null ? ` (${e.duration}ms)` : ""
|
|
396
|
+
const icon = e.error || (e.status && e.status >= 400) ? "✗" : "✓"
|
|
397
|
+
output += ` ${icon} ${e.method.padEnd(6)} ${e.url.slice(0, 120)} → ${status}${dur}\n`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Detail section: failed by default, all if requested
|
|
401
|
+
const detailEntries = isAll ? toShow : isFailed ? failed : failed
|
|
402
|
+
if (detailEntries.length > 0) {
|
|
403
|
+
output += `\n── Request Detail ──\n`
|
|
404
|
+
for (const e of detailEntries) {
|
|
405
|
+
output += `\n### ${e.method} ${e.url}`
|
|
406
|
+
if (e.error) {
|
|
407
|
+
output += ` → FAILED\nError: ${e.error}\n`
|
|
408
|
+
} else {
|
|
409
|
+
output += ` → ${e.status} ${e.statusText ?? ""}`
|
|
410
|
+
if (e.duration != null) output += ` (${e.duration}ms)`
|
|
411
|
+
output += "\n"
|
|
412
|
+
}
|
|
413
|
+
if (e.reqHeaders && Object.keys(e.reqHeaders).length > 0) {
|
|
414
|
+
output += `Request headers:\n`
|
|
415
|
+
for (const [k, v] of Object.entries(e.reqHeaders)) {
|
|
416
|
+
output += ` ${k}: ${v}\n`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (e.reqBody) output += `Request body:\n ${e.reqBody.slice(0, 2000)}\n`
|
|
420
|
+
if (e.resHeaders && Object.keys(e.resHeaders).length > 0) {
|
|
421
|
+
output += `Response headers:\n`
|
|
422
|
+
for (const [k, v] of Object.entries(e.resHeaders)) {
|
|
423
|
+
output += ` ${k}: ${v}\n`
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (e.resBody) output += `Response body:\n ${e.resBody.slice(0, 3000)}\n`
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { success: true, output }
|
|
278
432
|
} catch (err) {
|
|
279
433
|
return { success: false, error: String(err instanceof Error ? err.message : err) }
|
|
280
434
|
}
|
|
281
435
|
}
|
|
282
|
-
}
|
|
436
|
+
}
|