@swarmclawai/swarmclaw 0.7.1 → 0.7.3
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/README.md +155 -150
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +26 -0
- package/src/app/api/agents/[id]/thread/route.ts +37 -9
- package/src/app/api/agents/route.ts +13 -2
- package/src/app/api/auth/route.ts +76 -7
- package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
- package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
- package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
- package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
- package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
- package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
- package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
- package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
- package/src/app/api/{sessions → chats}/route.ts +21 -7
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/skills/route.ts +11 -3
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +6 -26
- package/src/app/api/plugins/settings/route.ts +40 -0
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/settings/route.ts +49 -7
- package/src/app/api/tasks/[id]/route.ts +15 -6
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/usage/route.ts +30 -0
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +9 -2
- package/src/cli/index.js +39 -33
- package/src/cli/index.ts +43 -49
- package/src/cli/spec.js +29 -27
- package/src/components/agents/agent-card.tsx +16 -13
- package/src/components/agents/agent-chat-list.tsx +104 -4
- package/src/components/agents/agent-list.tsx +54 -22
- package/src/components/agents/agent-sheet.tsx +209 -18
- package/src/components/agents/cron-job-form.tsx +3 -3
- package/src/components/agents/inspector-panel.tsx +110 -50
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +5 -38
- package/src/components/chat/chat-area.tsx +39 -27
- package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
- package/src/components/chat/chat-header.tsx +299 -314
- package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
- package/src/components/chat/chat-tool-toggles.tsx +26 -17
- package/src/components/chat/checkpoint-timeline.tsx +4 -4
- package/src/components/chat/message-bubble.tsx +4 -1
- package/src/components/chat/message-list.tsx +5 -3
- package/src/components/chat/session-debug-panel.tsx +1 -1
- package/src/components/chat/tool-request-banner.tsx +3 -3
- package/src/components/chatrooms/agent-hover-card.tsx +3 -3
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
- package/src/components/chatrooms/chatroom-view.tsx +347 -205
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +218 -1
- package/src/components/home/home-view.tsx +129 -5
- package/src/components/layout/app-layout.tsx +392 -182
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/plugins/plugin-list.tsx +487 -254
- package/src/components/plugins/plugin-sheet.tsx +236 -13
- package/src/components/projects/project-detail.tsx +183 -0
- package/src/components/settings/gateway-connection-panel.tsx +1 -1
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/command-palette.tsx +111 -25
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +78 -1
- package/src/components/shared/settings/section-orchestrator.tsx +3 -3
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +244 -56
- package/src/components/tasks/approvals-panel.tsx +205 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/usage/metrics-dashboard.tsx +147 -1
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +8 -8
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/chat.ts +1 -1
- package/src/lib/{sessions.ts → chats.ts} +28 -18
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/providers/claude-cli.ts +1 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +205 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +36 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/capability-router.ts +10 -8
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
- package/src/lib/server/chat-execution.ts +285 -165
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +67 -2
- package/src/lib/server/chatroom-helpers.ts +48 -8
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +948 -112
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +188 -9
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/cost.ts +34 -1
- package/src/lib/server/daemon-state.ts +61 -3
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/heartbeat-service.ts +14 -40
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +28 -1103
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +5 -6
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +20 -9
- package/src/lib/server/orchestrator.ts +7 -7
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +207 -0
- package/src/lib/server/plugins.ts +927 -66
- package/src/lib/server/provider-health.ts +38 -6
- package/src/lib/server/queue.ts +13 -28
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -82
- package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
- package/src/lib/server/session-tools/calendar.ts +366 -0
- package/src/lib/server/session-tools/canvas.ts +1 -1
- package/src/lib/server/session-tools/chatroom.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +114 -10
- package/src/lib/server/session-tools/context.ts +21 -5
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +74 -28
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +497 -24
- package/src/lib/server/session-tools/discovery.ts +24 -6
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/edit_file.ts +4 -2
- package/src/lib/server/session-tools/email.ts +320 -0
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +241 -25
- package/src/lib/server/session-tools/git.ts +1 -1
- package/src/lib/server/session-tools/http.ts +1 -1
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +380 -0
- package/src/lib/server/session-tools/index.ts +130 -50
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/memory.ts +172 -3
- package/src/lib/server/session-tools/monitor.ts +151 -8
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
- package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +148 -7
- package/src/lib/server/session-tools/plugin-creator.ts +89 -26
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +301 -0
- package/src/lib/server/session-tools/sample-ui.ts +1 -1
- package/src/lib/server/session-tools/sandbox.ts +4 -2
- package/src/lib/server/session-tools/schedule.ts +24 -12
- package/src/lib/server/session-tools/session-info.ts +43 -7
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
- package/src/lib/server/session-tools/shell.ts +5 -2
- package/src/lib/server/session-tools/subagent.ts +194 -28
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +42 -12
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +926 -91
- package/src/lib/server/storage.ts +255 -16
- package/src/lib/server/stream-agent-chat.ts +116 -268
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -10
- package/src/lib/server/tool-aliases.ts +66 -18
- package/src/lib/server/tool-capability-policy.test.ts +9 -9
- package/src/lib/server/tool-capability-policy.ts +38 -27
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/tool-definitions.ts +4 -0
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +10 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +5 -11
- package/src/stores/use-chat-store.ts +38 -9
- package/src/types/index.ts +352 -47
- package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
- package/src/components/sessions/new-session-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -24
- package/src/lib/server/session-run-manager.test.ts +0 -23
- /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
|
@@ -13,6 +13,14 @@ import { withRetry } from '../tool-retry'
|
|
|
13
13
|
import type { Plugin, PluginHooks } from '@/types'
|
|
14
14
|
import { getPluginManager } from '../plugins'
|
|
15
15
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
16
|
+
import {
|
|
17
|
+
ensureSessionBrowserProfileId,
|
|
18
|
+
getBrowserProfileDir,
|
|
19
|
+
markBrowserSessionClosed,
|
|
20
|
+
recordBrowserObservation,
|
|
21
|
+
removeBrowserSessionRecord,
|
|
22
|
+
upsertBrowserSessionRecord,
|
|
23
|
+
} from '../browser-state'
|
|
16
24
|
|
|
17
25
|
// --- Search result compression logic ---
|
|
18
26
|
async function compressSearchResults(results: any[], query: string, bctx: any): Promise<string | null> {
|
|
@@ -48,13 +56,91 @@ async function compressSearchResults(results: any[], query: string, bctx: any):
|
|
|
48
56
|
return compressed.trim() || null
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
type BrowserRuntimeEntry = {
|
|
60
|
+
client: any
|
|
61
|
+
server: any
|
|
62
|
+
createdAt: number
|
|
63
|
+
profileId: string
|
|
64
|
+
profileDir: string
|
|
65
|
+
refCount: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const activeBrowsers = new Map<string, BrowserRuntimeEntry>()
|
|
69
|
+
const pendingBrowserInitializations = new Map<string, Promise<BrowserRuntimeEntry>>()
|
|
70
|
+
|
|
71
|
+
export function buildBrowserConnectionOptions(profileDir: string) {
|
|
72
|
+
return {
|
|
73
|
+
browser: {
|
|
74
|
+
userDataDir: profileDir,
|
|
75
|
+
launchOptions: { headless: true },
|
|
76
|
+
contextOptions: {
|
|
77
|
+
viewport: { width: 1440, height: 900 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
imageResponses: 'allow' as const,
|
|
81
|
+
capabilities: ['core', 'pdf', 'vision', 'network', 'storage'],
|
|
82
|
+
// Keep browser state isolated per session/profile. The upstream shared
|
|
83
|
+
// context mode is process-global and causes unrelated agent sessions to
|
|
84
|
+
// contend with each other.
|
|
85
|
+
sharedBrowserContext: false,
|
|
86
|
+
timeouts: {
|
|
87
|
+
action: 15_000,
|
|
88
|
+
navigation: 60_000,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildBrowserStdioServerParams(profileDir: string) {
|
|
94
|
+
const cliCandidates = [
|
|
95
|
+
path.join(process.cwd(), 'node_modules', '@playwright', 'mcp', 'cli.js'),
|
|
96
|
+
path.join(process.cwd(), '[project]', 'node_modules', '@playwright', 'mcp', 'cli.js'),
|
|
97
|
+
]
|
|
98
|
+
const cliPath = cliCandidates.find((candidate) => fs.existsSync(candidate)) || cliCandidates[0]
|
|
99
|
+
const outputDir = path.join(profileDir, 'mcp-output')
|
|
100
|
+
const env = sanitizePlaywrightMcpEnv()
|
|
101
|
+
return {
|
|
102
|
+
command: process.execPath,
|
|
103
|
+
args: [
|
|
104
|
+
cliPath,
|
|
105
|
+
'--headless',
|
|
106
|
+
'--user-data-dir', profileDir,
|
|
107
|
+
'--output-dir', outputDir,
|
|
108
|
+
'--caps', 'vision,pdf',
|
|
109
|
+
'--image-responses', 'allow',
|
|
110
|
+
'--output-mode', 'file',
|
|
111
|
+
'--timeout-action', '15000',
|
|
112
|
+
'--timeout-navigation', '60000',
|
|
113
|
+
],
|
|
114
|
+
env: {
|
|
115
|
+
...env,
|
|
116
|
+
PLAYWRIGHT_MCP_USER_DATA_DIR: profileDir,
|
|
117
|
+
PLAYWRIGHT_MCP_HEADLESS: '1',
|
|
118
|
+
PLAYWRIGHT_MCP_IMAGE_RESPONSES: 'allow',
|
|
119
|
+
PLAYWRIGHT_MCP_OUTPUT_DIR: outputDir,
|
|
120
|
+
PLAYWRIGHT_MCP_OUTPUT_MODE: 'file',
|
|
121
|
+
PLAYWRIGHT_MCP_TIMEOUT_ACTION: '15000',
|
|
122
|
+
PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION: '60000',
|
|
123
|
+
},
|
|
124
|
+
stderr: 'inherit' as const,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function sanitizePlaywrightMcpEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
129
|
+
const env: NodeJS.ProcessEnv = { ...baseEnv }
|
|
130
|
+
for (const key of Object.keys(env)) {
|
|
131
|
+
if (!key.toUpperCase().startsWith('PLAYWRIGHT_MCP_')) continue
|
|
132
|
+
delete env[key]
|
|
133
|
+
}
|
|
134
|
+
return env
|
|
135
|
+
}
|
|
52
136
|
export function sweepOrphanedBrowsers(maxAgeMs = 30 * 60 * 1000): number {
|
|
53
137
|
const now = Date.now(); let cleaned = 0
|
|
54
138
|
for (const [key, entry] of activeBrowsers) {
|
|
55
139
|
if (now - entry.createdAt > maxAgeMs) {
|
|
56
140
|
try { entry.client?.close?.() } catch { /* ignore */ }
|
|
57
141
|
try { entry.server?.close?.() } catch { /* ignore */ }
|
|
142
|
+
pendingBrowserInitializations.delete(key)
|
|
143
|
+
markBrowserSessionClosed(key, 'Browser was swept after inactivity.')
|
|
58
144
|
activeBrowsers.delete(key); cleaned++
|
|
59
145
|
}
|
|
60
146
|
}
|
|
@@ -66,6 +152,8 @@ export function cleanupSessionBrowser(sessionId: string): void {
|
|
|
66
152
|
try { entry.client?.close?.() } catch { /* ignore */ }
|
|
67
153
|
try { entry.server?.close?.() } catch { /* ignore */ }
|
|
68
154
|
activeBrowsers.delete(sessionId)
|
|
155
|
+
pendingBrowserInitializations.delete(sessionId)
|
|
156
|
+
markBrowserSessionClosed(sessionId)
|
|
69
157
|
}
|
|
70
158
|
}
|
|
71
159
|
export function getActiveBrowserCount(): number { return activeBrowsers.size }
|
|
@@ -133,7 +221,9 @@ async function executeWebAction(args: Record<string, unknown>, bctx: any) {
|
|
|
133
221
|
const WebPlugin: Plugin = {
|
|
134
222
|
name: 'Core Web',
|
|
135
223
|
description: 'Search the web and fetch content from URLs.',
|
|
136
|
-
hooks: {
|
|
224
|
+
hooks: {
|
|
225
|
+
getCapabilityDescription: () => 'I can search the web (`web_search`) for research, fact-checking, and discovery.',
|
|
226
|
+
} as PluginHooks,
|
|
137
227
|
tools: [
|
|
138
228
|
{
|
|
139
229
|
name: 'web',
|
|
@@ -162,7 +252,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
162
252
|
const tools: StructuredToolInterface[] = []
|
|
163
253
|
const { cwd, ctx, cleanupFns } = bctx
|
|
164
254
|
|
|
165
|
-
if (bctx.
|
|
255
|
+
if (bctx.hasPlugin('web')) {
|
|
166
256
|
tools.push(
|
|
167
257
|
tool(
|
|
168
258
|
async (args) => executeWebAction(args, bctx),
|
|
@@ -176,37 +266,110 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
176
266
|
}
|
|
177
267
|
|
|
178
268
|
// Browser tool (kept as direct injection for now due to complexity)
|
|
179
|
-
if (bctx.
|
|
269
|
+
if (bctx.hasPlugin('browser')) {
|
|
180
270
|
const sessionKey = ctx?.sessionId || `anon-${Date.now()}`
|
|
271
|
+
const currentSession = bctx.resolveCurrentSession?.()
|
|
272
|
+
const profileInfo = currentSession?.id
|
|
273
|
+
? ensureSessionBrowserProfileId(sessionKey)
|
|
274
|
+
: { profileId: sessionKey, inheritedFromSessionId: null as string | null }
|
|
275
|
+
const profileDir = getBrowserProfileDir(profileInfo.profileId)
|
|
181
276
|
let mcpClient: any = null
|
|
182
277
|
let mcpServer: any = null
|
|
183
278
|
let mcpInitializing: Promise<void> | null = null
|
|
279
|
+
let browserLeaseHeld = false
|
|
280
|
+
|
|
281
|
+
upsertBrowserSessionRecord({
|
|
282
|
+
sessionId: sessionKey,
|
|
283
|
+
profileId: profileInfo.profileId,
|
|
284
|
+
profileDir,
|
|
285
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
286
|
+
status: 'idle',
|
|
287
|
+
})
|
|
184
288
|
|
|
185
289
|
const ensureMcp = (): Promise<void> => {
|
|
186
290
|
if (mcpClient) return Promise.resolve()
|
|
187
291
|
if (mcpInitializing) return mcpInitializing
|
|
292
|
+
const acquireExistingEntry = (entry: BrowserRuntimeEntry) => {
|
|
293
|
+
mcpClient = entry.client
|
|
294
|
+
mcpServer = entry.server
|
|
295
|
+
if (!browserLeaseHeld) {
|
|
296
|
+
entry.refCount = Math.max(0, entry.refCount || 0) + 1
|
|
297
|
+
activeBrowsers.set(sessionKey, entry)
|
|
298
|
+
browserLeaseHeld = true
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const existing = activeBrowsers.get(sessionKey)
|
|
302
|
+
if (existing) {
|
|
303
|
+
acquireExistingEntry(existing)
|
|
304
|
+
return Promise.resolve()
|
|
305
|
+
}
|
|
188
306
|
mcpInitializing = (async () => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
307
|
+
try {
|
|
308
|
+
const pending = pendingBrowserInitializations.get(sessionKey)
|
|
309
|
+
if (pending) {
|
|
310
|
+
acquireExistingEntry(await pending)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const connectPromise = (async () => {
|
|
315
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
|
|
316
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
|
|
317
|
+
const transport = new StdioClientTransport(buildBrowserStdioServerParams(profileDir))
|
|
318
|
+
const client = new Client({ name: 'swarmclaw', version: '1.0' })
|
|
319
|
+
await client.connect(transport)
|
|
320
|
+
return {
|
|
321
|
+
client,
|
|
322
|
+
server: transport,
|
|
323
|
+
createdAt: Date.now(),
|
|
324
|
+
profileId: profileInfo.profileId,
|
|
325
|
+
profileDir,
|
|
326
|
+
refCount: 0,
|
|
327
|
+
}
|
|
328
|
+
})()
|
|
329
|
+
pendingBrowserInitializations.set(sessionKey, connectPromise)
|
|
330
|
+
const entry = await connectPromise
|
|
331
|
+
acquireExistingEntry(entry)
|
|
332
|
+
upsertBrowserSessionRecord({
|
|
333
|
+
sessionId: sessionKey,
|
|
334
|
+
profileId: profileInfo.profileId,
|
|
335
|
+
profileDir,
|
|
336
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
337
|
+
status: 'active',
|
|
338
|
+
lastAction: 'browser_open',
|
|
339
|
+
})
|
|
340
|
+
} finally {
|
|
341
|
+
if (pendingBrowserInitializations.get(sessionKey)) {
|
|
342
|
+
pendingBrowserInitializations.delete(sessionKey)
|
|
343
|
+
}
|
|
344
|
+
mcpInitializing = null
|
|
345
|
+
}
|
|
201
346
|
})()
|
|
202
347
|
return mcpInitializing
|
|
203
348
|
}
|
|
204
349
|
|
|
205
350
|
cleanupFns.push(async () => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
351
|
+
pendingBrowserInitializations.delete(sessionKey)
|
|
352
|
+
const entry = activeBrowsers.get(sessionKey)
|
|
353
|
+
const ownsActiveEntry = !!entry && entry.client === mcpClient && entry.server === mcpServer
|
|
354
|
+
if (ownsActiveEntry && browserLeaseHeld) {
|
|
355
|
+
entry.refCount = Math.max(0, (entry.refCount || 1) - 1)
|
|
356
|
+
if (entry.refCount === 0) {
|
|
357
|
+
try { entry.client?.close?.() } catch { /* ignore */ }
|
|
358
|
+
try { entry.server?.close?.() } catch { /* ignore */ }
|
|
359
|
+
activeBrowsers.delete(sessionKey)
|
|
360
|
+
markBrowserSessionClosed(sessionKey)
|
|
361
|
+
} else {
|
|
362
|
+
activeBrowsers.set(sessionKey, entry)
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
try { mcpClient?.close?.() } catch { /* ignore */ }
|
|
366
|
+
try { mcpServer?.close?.() } catch { /* ignore */ }
|
|
367
|
+
if (browserLeaseHeld) markBrowserSessionClosed(sessionKey)
|
|
368
|
+
}
|
|
369
|
+
mcpClient = null
|
|
370
|
+
mcpServer = null
|
|
371
|
+
mcpInitializing = null
|
|
372
|
+
browserLeaseHeld = false
|
|
210
373
|
})
|
|
211
374
|
|
|
212
375
|
const cleanPlaywrightOutput = (text: string): string => {
|
|
@@ -220,68 +383,257 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
220
383
|
return text.replace(/\n{3,}/g, '\n').trim()
|
|
221
384
|
}
|
|
222
385
|
|
|
386
|
+
const extractJsonPayload = (text: string): Record<string, unknown> | unknown[] | null => {
|
|
387
|
+
const candidates = [
|
|
388
|
+
[text.indexOf('{'), text.lastIndexOf('}')],
|
|
389
|
+
[text.indexOf('['), text.lastIndexOf(']')],
|
|
390
|
+
]
|
|
391
|
+
for (const [start, end] of candidates) {
|
|
392
|
+
if (start === -1 || end === -1 || end <= start) continue
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(text.slice(start, end + 1))
|
|
395
|
+
} catch {
|
|
396
|
+
// try next candidate
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const stringifyStructured = (value: unknown): string => truncate(JSON.stringify(value, null, 2), MAX_OUTPUT)
|
|
403
|
+
|
|
404
|
+
const captureStructuredObservation = async () => {
|
|
405
|
+
const expression = `(() => {
|
|
406
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
|
407
|
+
const visible = (el) => {
|
|
408
|
+
if (!el) return false;
|
|
409
|
+
const style = window.getComputedStyle(el);
|
|
410
|
+
return style && style.display !== 'none' && style.visibility !== 'hidden';
|
|
411
|
+
};
|
|
412
|
+
const links = Array.from(document.querySelectorAll('a[href]'))
|
|
413
|
+
.filter(visible)
|
|
414
|
+
.slice(0, 25)
|
|
415
|
+
.map((a) => ({
|
|
416
|
+
text: normalize(a.innerText || a.textContent || a.getAttribute('aria-label')),
|
|
417
|
+
href: a.href || a.getAttribute('href') || '',
|
|
418
|
+
}))
|
|
419
|
+
.filter((entry) => entry.href);
|
|
420
|
+
const forms = Array.from(document.forms).slice(0, 5).map((form, index) => ({
|
|
421
|
+
index,
|
|
422
|
+
action: form.getAttribute('action') || form.action || null,
|
|
423
|
+
method: normalize(form.getAttribute('method') || form.method || 'get') || 'get',
|
|
424
|
+
fields: Array.from(form.elements).slice(0, 20).map((el) => ({
|
|
425
|
+
name: el.getAttribute?.('name') || null,
|
|
426
|
+
label: normalize(el.labels?.[0]?.innerText || el.getAttribute?.('aria-label') || el.getAttribute?.('placeholder')) || null,
|
|
427
|
+
type: normalize(el.getAttribute?.('type') || el.tagName || 'field').toLowerCase(),
|
|
428
|
+
required: !!el.required,
|
|
429
|
+
})),
|
|
430
|
+
}));
|
|
431
|
+
const tables = Array.from(document.querySelectorAll('table')).slice(0, 3).map((table, index) => {
|
|
432
|
+
const headerCells = Array.from(table.querySelectorAll('thead th')).map((th) => normalize(th.innerText || th.textContent));
|
|
433
|
+
const bodyRows = Array.from(table.querySelectorAll('tbody tr')).slice(0, 5).map((tr) =>
|
|
434
|
+
Array.from(tr.querySelectorAll('th, td')).map((cell) => normalize(cell.innerText || cell.textContent))
|
|
435
|
+
);
|
|
436
|
+
return {
|
|
437
|
+
index,
|
|
438
|
+
headers: headerCells,
|
|
439
|
+
rowCount: table.querySelectorAll('tbody tr').length,
|
|
440
|
+
rows: bodyRows,
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
const errors = Array.from(document.querySelectorAll('[aria-invalid="true"], .error, .field-error, .invalid, [role="alert"]'))
|
|
444
|
+
.filter(visible)
|
|
445
|
+
.slice(0, 10)
|
|
446
|
+
.map((el) => normalize(el.innerText || el.textContent))
|
|
447
|
+
.filter(Boolean);
|
|
448
|
+
return JSON.stringify({
|
|
449
|
+
url: window.location.href,
|
|
450
|
+
title: document.title || null,
|
|
451
|
+
textPreview: normalize(document.body?.innerText || document.body?.textContent || '').slice(0, 1200),
|
|
452
|
+
links,
|
|
453
|
+
forms,
|
|
454
|
+
tables,
|
|
455
|
+
errors,
|
|
456
|
+
});
|
|
457
|
+
})()`
|
|
458
|
+
const raw = await callMcpTool('browser_evaluate', { expression })
|
|
459
|
+
const parsed = extractJsonPayload(raw)
|
|
460
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
461
|
+
const observation = {
|
|
462
|
+
capturedAt: Date.now(),
|
|
463
|
+
...parsed,
|
|
464
|
+
} as any
|
|
465
|
+
recordBrowserObservation(sessionKey, observation)
|
|
466
|
+
return observation
|
|
467
|
+
}
|
|
468
|
+
const fallback = {
|
|
469
|
+
capturedAt: Date.now(),
|
|
470
|
+
url: null,
|
|
471
|
+
title: null,
|
|
472
|
+
textPreview: cleanPlaywrightOutput(raw).slice(0, 1200),
|
|
473
|
+
}
|
|
474
|
+
recordBrowserObservation(sessionKey, fallback)
|
|
475
|
+
return fallback
|
|
476
|
+
}
|
|
477
|
+
|
|
223
478
|
const MCP_CALL_TIMEOUT_MS = 30000 // 30s timeout per browser action
|
|
224
479
|
const callMcpTool = async (toolName: string, args: Record<string, any>, options?: { saveTo?: string }): Promise<string> => {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
480
|
+
const rawCall = async (): Promise<string> => {
|
|
481
|
+
try {
|
|
482
|
+
await ensureMcp()
|
|
483
|
+
const result = await Promise.race([
|
|
484
|
+
mcpClient.callTool({ name: toolName, arguments: args }),
|
|
485
|
+
new Promise<never>((_resolve, reject) =>
|
|
486
|
+
setTimeout(() => reject(new Error(`Browser action "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS / 1000}s`)), MCP_CALL_TIMEOUT_MS),
|
|
487
|
+
),
|
|
488
|
+
])
|
|
489
|
+
const isError = result?.isError === true
|
|
490
|
+
const content = result?.content
|
|
491
|
+
const savedPaths: string[] = []
|
|
492
|
+
const artifacts: Array<{ kind: 'snapshot' | 'screenshot' | 'download' | 'pdf'; path: string; url?: string | null; filename?: string | null; createdAt: number }> = []
|
|
493
|
+
const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
|
|
494
|
+
const rawSaveTo = options?.saveTo?.trim()
|
|
495
|
+
if (!rawSaveTo) return
|
|
496
|
+
let resolved = safePath(cwd, rawSaveTo)
|
|
497
|
+
if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
|
|
498
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
|
499
|
+
fs.writeFileSync(resolved, buffer)
|
|
500
|
+
savedPaths.push(resolved)
|
|
501
|
+
}
|
|
502
|
+
if (Array.isArray(content)) {
|
|
503
|
+
let parts: string[] = []
|
|
504
|
+
const isScreenshotTool = toolName === 'browser_take_screenshot'
|
|
505
|
+
const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
|
|
506
|
+
for (const c of content) {
|
|
507
|
+
if (c.type === 'image' && c.data) {
|
|
508
|
+
const imageBuffer = Buffer.from(c.data, 'base64')
|
|
509
|
+
const filename = `screenshot-${Date.now()}.png`
|
|
510
|
+
const filepath = path.join(UPLOAD_DIR, filename)
|
|
511
|
+
fs.writeFileSync(filepath, imageBuffer)
|
|
512
|
+
saveArtifact(imageBuffer, 'png')
|
|
513
|
+
artifacts.push({ kind: 'screenshot', path: filepath, url: `/api/uploads/${filename}`, filename, createdAt: Date.now() })
|
|
514
|
+
parts.push(`Screenshot saved to /api/uploads/${filename}`)
|
|
515
|
+
parts.push(``)
|
|
516
|
+
} else if (c.type === 'resource' && c.resource?.blob) {
|
|
517
|
+
const ext = c.resource.mimeType?.includes('pdf') ? 'pdf' : 'bin'
|
|
518
|
+
const resourceBuffer = Buffer.from(c.resource.blob, 'base64')
|
|
519
|
+
const filename = `browser-${Date.now()}.${ext}`
|
|
520
|
+
const filepath = path.join(UPLOAD_DIR, filename)
|
|
521
|
+
fs.writeFileSync(filepath, resourceBuffer)
|
|
522
|
+
saveArtifact(resourceBuffer, ext)
|
|
523
|
+
artifacts.push({
|
|
524
|
+
kind: ext === 'pdf' ? 'pdf' : 'download',
|
|
525
|
+
path: filepath,
|
|
526
|
+
url: `/api/uploads/${filename}`,
|
|
527
|
+
filename,
|
|
528
|
+
createdAt: Date.now(),
|
|
529
|
+
})
|
|
530
|
+
parts.push(`[Download ${filename}](/api/uploads/${filename})`)
|
|
531
|
+
} else {
|
|
532
|
+
const text = c.text || ''
|
|
533
|
+
const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
|
|
534
|
+
if (fileMatch) {
|
|
535
|
+
const rawPath = fileMatch[1]
|
|
536
|
+
const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
|
|
537
|
+
if (fs.existsSync(srcPath)) {
|
|
538
|
+
const ext = path.extname(srcPath).slice(1).toLowerCase()
|
|
539
|
+
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
|
|
540
|
+
if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) {
|
|
541
|
+
continue
|
|
542
|
+
} else {
|
|
543
|
+
const filename = `browser-${Date.now()}.${ext}`
|
|
544
|
+
const destPath = path.join(UPLOAD_DIR, filename)
|
|
545
|
+
fs.copyFileSync(srcPath, destPath)
|
|
546
|
+
if (options?.saveTo?.trim()) {
|
|
547
|
+
let targetPath = safePath(cwd, options.saveTo.trim())
|
|
548
|
+
if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
|
|
549
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
|
550
|
+
fs.copyFileSync(srcPath, targetPath)
|
|
551
|
+
savedPaths.push(targetPath)
|
|
552
|
+
}
|
|
553
|
+
artifacts.push({
|
|
554
|
+
kind: ext === 'pdf' ? 'pdf' : 'download',
|
|
555
|
+
path: destPath,
|
|
556
|
+
url: `/api/uploads/${filename}`,
|
|
557
|
+
filename,
|
|
558
|
+
createdAt: Date.now(),
|
|
559
|
+
})
|
|
560
|
+
parts.push(IMAGE_EXTS.includes(ext) ? `` : `[Download ${filename}](/api/uploads/${filename})`)
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
parts.push(isError ? text : cleanPlaywrightOutput(text))
|
|
270
564
|
}
|
|
271
|
-
|
|
565
|
+
} else {
|
|
566
|
+
parts.push(isError ? text : cleanPlaywrightOutput(text))
|
|
272
567
|
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
|
|
571
|
+
if (savedPaths.length > 0) {
|
|
572
|
+
const unique = Array.from(new Set(savedPaths))
|
|
573
|
+
parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
|
|
574
|
+
}
|
|
575
|
+
upsertBrowserSessionRecord({
|
|
576
|
+
sessionId: sessionKey,
|
|
577
|
+
profileId: profileInfo.profileId,
|
|
578
|
+
profileDir,
|
|
579
|
+
status: 'active',
|
|
580
|
+
lastAction: toolName,
|
|
581
|
+
lastError: isError ? parts.join('\n').slice(0, 1000) : null,
|
|
582
|
+
artifacts,
|
|
583
|
+
})
|
|
584
|
+
return parts.join('\n')
|
|
275
585
|
}
|
|
586
|
+
const fallback = JSON.stringify(result)
|
|
587
|
+
upsertBrowserSessionRecord({
|
|
588
|
+
sessionId: sessionKey,
|
|
589
|
+
profileId: profileInfo.profileId,
|
|
590
|
+
profileDir,
|
|
591
|
+
status: 'active',
|
|
592
|
+
lastAction: toolName,
|
|
593
|
+
lastError: isError ? fallback.slice(0, 1000) : null,
|
|
594
|
+
})
|
|
595
|
+
return fallback
|
|
596
|
+
} catch (err: unknown) {
|
|
597
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
598
|
+
upsertBrowserSessionRecord({
|
|
599
|
+
sessionId: sessionKey,
|
|
600
|
+
profileId: profileInfo.profileId,
|
|
601
|
+
profileDir,
|
|
602
|
+
status: 'error',
|
|
603
|
+
lastAction: toolName,
|
|
604
|
+
lastError: message,
|
|
605
|
+
})
|
|
606
|
+
return `Error: ${message}`
|
|
276
607
|
}
|
|
277
|
-
if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
|
|
278
|
-
if (savedPaths.length > 0) {
|
|
279
|
-
const unique = Array.from(new Set(savedPaths))
|
|
280
|
-
parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
|
|
281
|
-
}
|
|
282
|
-
return parts.join('\n')
|
|
283
608
|
}
|
|
284
|
-
|
|
609
|
+
|
|
610
|
+
return withRetry(rawCall, undefined, {
|
|
611
|
+
maxAttempts: 3,
|
|
612
|
+
backoffMs: 1000,
|
|
613
|
+
retryable: [
|
|
614
|
+
/timed out/i,
|
|
615
|
+
/ERR_ABORTED/i,
|
|
616
|
+
/Target closed/i,
|
|
617
|
+
/Execution context was destroyed/i,
|
|
618
|
+
/SharedContextFactory already exists/i,
|
|
619
|
+
/ECONNRESET/i,
|
|
620
|
+
/temporarily unavailable/i,
|
|
621
|
+
],
|
|
622
|
+
onRetry: async (_attempt, result) => {
|
|
623
|
+
if (/SharedContextFactory already exists/i.test(result)) {
|
|
624
|
+
cleanupSessionBrowser(sessionKey)
|
|
625
|
+
upsertBrowserSessionRecord({
|
|
626
|
+
sessionId: sessionKey,
|
|
627
|
+
profileId: profileInfo.profileId,
|
|
628
|
+
profileDir,
|
|
629
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
630
|
+
status: 'idle',
|
|
631
|
+
lastAction: 'browser_recover',
|
|
632
|
+
lastError: 'Recovered browser transport after Playwright shared-context startup conflict.',
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
})
|
|
285
637
|
}
|
|
286
638
|
|
|
287
639
|
const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
|
|
@@ -296,35 +648,518 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
296
648
|
await mcpCall('browser_evaluate', { expression: js })
|
|
297
649
|
}
|
|
298
650
|
|
|
651
|
+
const performFillForm = async (params: Record<string, unknown>) => {
|
|
652
|
+
const fields = Array.isArray(params.fields) ? params.fields : []
|
|
653
|
+
if (fields.length === 0) return { ok: false, error: 'fields is required for fill_form.' }
|
|
654
|
+
const filled: Array<Record<string, unknown>> = []
|
|
655
|
+
for (const field of fields) {
|
|
656
|
+
if (!field || typeof field !== 'object') continue
|
|
657
|
+
const entry = field as Record<string, unknown>
|
|
658
|
+
const ref = typeof entry.ref === 'string' ? entry.ref : undefined
|
|
659
|
+
const element = typeof entry.element === 'string' ? entry.element : undefined
|
|
660
|
+
const fieldType = String(entry.type || 'text').toLowerCase()
|
|
661
|
+
const value = entry.value
|
|
662
|
+
if (!ref && !element) continue
|
|
663
|
+
if (fieldType === 'select') {
|
|
664
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value ?? '')]
|
|
665
|
+
await callMcpTool('browser_select_option', { ref, element, values })
|
|
666
|
+
} else if (fieldType === 'checkbox' || fieldType === 'radio') {
|
|
667
|
+
if (value === true || value === 'true' || value === 'on' || value === 'checked') {
|
|
668
|
+
await callMcpTool('browser_click', { ref, element })
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
await callMcpTool('browser_type', {
|
|
672
|
+
ref,
|
|
673
|
+
element,
|
|
674
|
+
text: String(value ?? ''),
|
|
675
|
+
slowly: fieldType === 'password' ? false : params.slowly === true,
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
filled.push({
|
|
679
|
+
ref: ref || null,
|
|
680
|
+
element: element || null,
|
|
681
|
+
type: fieldType,
|
|
682
|
+
value: value ?? null,
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
return { ok: true, filled }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const submitForm = async (params: Record<string, unknown>) => {
|
|
689
|
+
if (typeof params.submitRef === 'string' || typeof params.submitElement === 'string') {
|
|
690
|
+
await callMcpTool('browser_click', {
|
|
691
|
+
ref: typeof params.submitRef === 'string' ? params.submitRef : undefined,
|
|
692
|
+
element: typeof params.submitElement === 'string' ? params.submitElement : undefined,
|
|
693
|
+
})
|
|
694
|
+
} else {
|
|
695
|
+
await callMcpTool('browser_press_key', { key: typeof params.key === 'string' ? params.key : 'Enter' })
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const waitMs = typeof params.waitMs === 'number' ? Math.max(250, params.waitMs) : 1000
|
|
699
|
+
try {
|
|
700
|
+
await callMcpTool('browser_evaluate', {
|
|
701
|
+
expression: `await new Promise(resolve => setTimeout(resolve, ${Math.min(waitMs, 5000)}))`,
|
|
702
|
+
})
|
|
703
|
+
} catch {
|
|
704
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
ok: true,
|
|
709
|
+
submitted: true,
|
|
710
|
+
page: await captureStructuredObservation(),
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const scrollUntil = async (params: Record<string, unknown>) => {
|
|
715
|
+
const containsText = typeof params.containsText === 'string'
|
|
716
|
+
? params.containsText
|
|
717
|
+
: typeof params.text === 'string'
|
|
718
|
+
? params.text
|
|
719
|
+
: ''
|
|
720
|
+
const selector = typeof params.selector === 'string' ? params.selector : ''
|
|
721
|
+
if (!containsText && !selector) return { ok: false, error: 'containsText or selector is required for scroll_until.' }
|
|
722
|
+
|
|
723
|
+
const maxScrolls = typeof params.maxScrolls === 'number' ? Math.max(1, Math.min(20, params.maxScrolls)) : 8
|
|
724
|
+
let matchedAtStep = -1
|
|
725
|
+
for (let index = 0; index < maxScrolls; index += 1) {
|
|
726
|
+
const result = await callMcpTool('browser_evaluate', {
|
|
727
|
+
expression: `(() => {
|
|
728
|
+
const bodyText = String(document.body?.innerText || document.body?.textContent || '');
|
|
729
|
+
const selector = ${JSON.stringify(selector)};
|
|
730
|
+
const containsText = ${JSON.stringify(containsText)};
|
|
731
|
+
const match = (selector && !!document.querySelector(selector))
|
|
732
|
+
|| (containsText && bodyText.includes(containsText));
|
|
733
|
+
if (match) return JSON.stringify({ found: true, scrollY: window.scrollY, step: ${index} });
|
|
734
|
+
window.scrollBy({ top: Math.max(window.innerHeight * 0.85, 600), behavior: 'instant' });
|
|
735
|
+
return JSON.stringify({ found: false, scrollY: window.scrollY, step: ${index} });
|
|
736
|
+
})()`,
|
|
737
|
+
})
|
|
738
|
+
const payload = extractJsonPayload(result)
|
|
739
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && (payload as Record<string, unknown>).found === true) {
|
|
740
|
+
matchedAtStep = index
|
|
741
|
+
break
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const page = await captureStructuredObservation()
|
|
746
|
+
return {
|
|
747
|
+
ok: matchedAtStep >= 0,
|
|
748
|
+
found: matchedAtStep >= 0,
|
|
749
|
+
matchedAtStep: matchedAtStep >= 0 ? matchedAtStep : null,
|
|
750
|
+
page,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const resolveDownloadUrl = async (params: Record<string, unknown>) => {
|
|
755
|
+
if (typeof params.url === 'string' && params.url.trim()) return params.url.trim()
|
|
756
|
+
const linkText = typeof params.linkText === 'string' ? params.linkText.trim() : ''
|
|
757
|
+
const hrefContains = typeof params.hrefContains === 'string' ? params.hrefContains.trim() : ''
|
|
758
|
+
if (!linkText && !hrefContains) return null
|
|
759
|
+
const result = await callMcpTool('browser_evaluate', {
|
|
760
|
+
expression: `(() => {
|
|
761
|
+
const linkText = ${JSON.stringify(linkText)};
|
|
762
|
+
const hrefContains = ${JSON.stringify(hrefContains)};
|
|
763
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
764
|
+
const match = links.find((link) => {
|
|
765
|
+
const text = String(link.innerText || link.textContent || '').trim();
|
|
766
|
+
const href = String(link.href || link.getAttribute('href') || '').trim();
|
|
767
|
+
if (!href) return false;
|
|
768
|
+
if (linkText && text.toLowerCase().includes(linkText.toLowerCase())) return true;
|
|
769
|
+
if (hrefContains && href.toLowerCase().includes(hrefContains.toLowerCase())) return true;
|
|
770
|
+
return false;
|
|
771
|
+
});
|
|
772
|
+
return JSON.stringify({ href: match ? (match.href || match.getAttribute('href') || '') : null });
|
|
773
|
+
})()`,
|
|
774
|
+
})
|
|
775
|
+
const payload = extractJsonPayload(result)
|
|
776
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
777
|
+
const href = (payload as Record<string, unknown>).href
|
|
778
|
+
return typeof href === 'string' && href.trim() ? href.trim() : null
|
|
779
|
+
}
|
|
780
|
+
return null
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const downloadFile = async (params: Record<string, unknown>) => {
|
|
784
|
+
const downloadUrl = await resolveDownloadUrl(params)
|
|
785
|
+
if (!downloadUrl) return { ok: false, error: 'url, linkText, or hrefContains is required for download_file.' }
|
|
786
|
+
|
|
787
|
+
const current = await captureStructuredObservation()
|
|
788
|
+
let resolvedUrl = downloadUrl
|
|
789
|
+
if (!/^https?:\/\//i.test(resolvedUrl)) {
|
|
790
|
+
const base = typeof current.url === 'string' && current.url ? current.url : undefined
|
|
791
|
+
if (!base) return { ok: false, error: 'Relative download URL requires an active page URL.' }
|
|
792
|
+
resolvedUrl = new URL(resolvedUrl, base).toString()
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const res = await fetch(resolvedUrl, {
|
|
796
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
|
|
797
|
+
signal: AbortSignal.timeout(30_000),
|
|
798
|
+
})
|
|
799
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${res.statusText}`, url: resolvedUrl }
|
|
800
|
+
|
|
801
|
+
const arrayBuffer = await res.arrayBuffer()
|
|
802
|
+
const data = Buffer.from(arrayBuffer)
|
|
803
|
+
const inferredName = (() => {
|
|
804
|
+
try {
|
|
805
|
+
const pathname = new URL(resolvedUrl).pathname
|
|
806
|
+
const base = path.basename(pathname)
|
|
807
|
+
return base && base !== '/' ? base : `download-${Date.now()}`
|
|
808
|
+
} catch {
|
|
809
|
+
return `download-${Date.now()}`
|
|
810
|
+
}
|
|
811
|
+
})()
|
|
812
|
+
const targetPath = typeof params.saveTo === 'string' && params.saveTo.trim()
|
|
813
|
+
? safePath(cwd, params.saveTo.trim())
|
|
814
|
+
: path.join(UPLOAD_DIR, inferredName)
|
|
815
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
|
816
|
+
fs.writeFileSync(targetPath, data)
|
|
817
|
+
|
|
818
|
+
const artifactPath = targetPath.startsWith(UPLOAD_DIR)
|
|
819
|
+
? targetPath
|
|
820
|
+
: path.join(UPLOAD_DIR, `${Date.now()}-${path.basename(targetPath)}`)
|
|
821
|
+
if (artifactPath !== targetPath) fs.copyFileSync(targetPath, artifactPath)
|
|
822
|
+
const filename = path.basename(artifactPath)
|
|
823
|
+
upsertBrowserSessionRecord({
|
|
824
|
+
sessionId: sessionKey,
|
|
825
|
+
profileId: profileInfo.profileId,
|
|
826
|
+
profileDir,
|
|
827
|
+
status: 'active',
|
|
828
|
+
lastAction: 'download_file',
|
|
829
|
+
artifacts: [{
|
|
830
|
+
kind: 'download',
|
|
831
|
+
path: artifactPath,
|
|
832
|
+
url: `/api/uploads/${filename}`,
|
|
833
|
+
filename,
|
|
834
|
+
createdAt: Date.now(),
|
|
835
|
+
}],
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
ok: true,
|
|
840
|
+
url: resolvedUrl,
|
|
841
|
+
path: targetPath,
|
|
842
|
+
artifactUrl: `/api/uploads/${filename}`,
|
|
843
|
+
filename: path.basename(targetPath),
|
|
844
|
+
sizeBytes: data.byteLength,
|
|
845
|
+
contentType: res.headers.get('content-type') || null,
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const verifyOutcome = async (params: Record<string, unknown>) => {
|
|
850
|
+
const verification: Record<string, unknown> = {}
|
|
851
|
+
if (typeof params.expectText === 'string' && params.expectText.trim()) {
|
|
852
|
+
verification.expectText = await callMcpTool('browser_verify_text_visible', { text: params.expectText.trim() })
|
|
853
|
+
}
|
|
854
|
+
if (typeof params.expectElement === 'string' && params.expectElement.trim()) {
|
|
855
|
+
verification.expectElement = await callMcpTool('browser_verify_element_visible', { element: params.expectElement.trim() })
|
|
856
|
+
}
|
|
857
|
+
if (typeof params.expectValue === 'string' && params.expectValue.trim()) {
|
|
858
|
+
verification.expectValue = await callMcpTool('browser_verify_value', {
|
|
859
|
+
element: typeof params.expectValueElement === 'string' ? params.expectValueElement : undefined,
|
|
860
|
+
value: params.expectValue.trim(),
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
return verification
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const completeWebTask = async (params: Record<string, unknown>) => {
|
|
867
|
+
const steps: string[] = []
|
|
868
|
+
if (typeof params.url === 'string' && params.url.trim()) {
|
|
869
|
+
await callMcpTool('browser_navigate', { url: params.url.trim() })
|
|
870
|
+
steps.push(`navigate:${params.url.trim()}`)
|
|
871
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let initialPage = await captureStructuredObservation()
|
|
875
|
+
if (typeof params.scrollUntilText === 'string' || typeof params.scrollUntilSelector === 'string') {
|
|
876
|
+
const scroll = await scrollUntil({
|
|
877
|
+
containsText: typeof params.scrollUntilText === 'string' ? params.scrollUntilText : undefined,
|
|
878
|
+
selector: typeof params.scrollUntilSelector === 'string' ? params.scrollUntilSelector : undefined,
|
|
879
|
+
maxScrolls: typeof params.maxScrolls === 'number' ? params.maxScrolls : undefined,
|
|
880
|
+
})
|
|
881
|
+
steps.push('scroll_until')
|
|
882
|
+
if (scroll.ok) initialPage = scroll.page
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (Array.isArray(params.fields) && params.fields.length > 0) {
|
|
886
|
+
const filled = await performFillForm(params)
|
|
887
|
+
if (!filled.ok) return filled
|
|
888
|
+
steps.push('fill_form')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (params.submit === true) {
|
|
892
|
+
await submitForm(params)
|
|
893
|
+
steps.push('submit_form')
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let download: Record<string, unknown> | null = null
|
|
897
|
+
if (params.download === true || typeof params.downloadUrl === 'string' || typeof params.linkText === 'string' || typeof params.hrefContains === 'string') {
|
|
898
|
+
download = await downloadFile({
|
|
899
|
+
url: typeof params.downloadUrl === 'string' ? params.downloadUrl : params.url,
|
|
900
|
+
linkText: params.linkText,
|
|
901
|
+
hrefContains: params.hrefContains,
|
|
902
|
+
saveTo: params.saveTo,
|
|
903
|
+
})
|
|
904
|
+
steps.push('download_file')
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const verification = await verifyOutcome(params)
|
|
908
|
+
const page = await captureStructuredObservation()
|
|
909
|
+
return {
|
|
910
|
+
ok: true,
|
|
911
|
+
goal: typeof params.goal === 'string' ? params.goal : null,
|
|
912
|
+
steps,
|
|
913
|
+
verification,
|
|
914
|
+
initialPage,
|
|
915
|
+
page,
|
|
916
|
+
download,
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
299
920
|
const MCP_TOOL_MAP: Record<string, string> = {
|
|
300
|
-
navigate: 'browser_navigate',
|
|
301
|
-
|
|
302
|
-
|
|
921
|
+
navigate: 'browser_navigate',
|
|
922
|
+
back: 'browser_navigate_back',
|
|
923
|
+
close: 'browser_close',
|
|
924
|
+
screenshot: 'browser_take_screenshot',
|
|
925
|
+
snapshot: 'browser_snapshot',
|
|
926
|
+
click: 'browser_click',
|
|
927
|
+
hover: 'browser_hover',
|
|
928
|
+
type: 'browser_type',
|
|
929
|
+
press_key: 'browser_press_key',
|
|
930
|
+
select: 'browser_select_option',
|
|
931
|
+
fill_form: 'browser_fill_form',
|
|
932
|
+
dialog: 'browser_handle_dialog',
|
|
933
|
+
evaluate: 'browser_evaluate',
|
|
934
|
+
run_code: 'browser_run_code',
|
|
935
|
+
pdf: 'browser_pdf_save',
|
|
936
|
+
upload: 'browser_file_upload',
|
|
937
|
+
wait: 'browser_wait_for',
|
|
938
|
+
tabs: 'browser_tabs',
|
|
939
|
+
network: 'browser_network_requests',
|
|
940
|
+
verify_text: 'browser_verify_text_visible',
|
|
941
|
+
verify_element: 'browser_verify_element_visible',
|
|
942
|
+
verify_list: 'browser_verify_list_visible',
|
|
943
|
+
verify_value: 'browser_verify_value',
|
|
303
944
|
}
|
|
304
945
|
|
|
305
946
|
tools.push(
|
|
306
947
|
tool(
|
|
307
|
-
async (
|
|
948
|
+
async (rawParams) => {
|
|
949
|
+
const params = normalizeToolInputArgs((rawParams ?? {}) as Record<string, unknown>)
|
|
308
950
|
try {
|
|
309
|
-
const
|
|
951
|
+
const action = String(params.action || '').trim()
|
|
952
|
+
|
|
953
|
+
if (action === 'profile') {
|
|
954
|
+
const state = upsertBrowserSessionRecord({
|
|
955
|
+
sessionId: sessionKey,
|
|
956
|
+
profileId: profileInfo.profileId,
|
|
957
|
+
profileDir,
|
|
958
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
959
|
+
status: activeBrowsers.has(sessionKey) ? 'active' : 'idle',
|
|
960
|
+
})
|
|
961
|
+
return stringifyStructured({
|
|
962
|
+
sessionId: sessionKey,
|
|
963
|
+
active: activeBrowsers.has(sessionKey),
|
|
964
|
+
profileId: state.profileId,
|
|
965
|
+
profileDir: state.profileDir,
|
|
966
|
+
inheritedFromSessionId: state.inheritedFromSessionId,
|
|
967
|
+
currentUrl: state.currentUrl,
|
|
968
|
+
pageTitle: state.pageTitle,
|
|
969
|
+
lastObservation: state.lastObservation,
|
|
970
|
+
})
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (action === 'reset_profile') {
|
|
974
|
+
cleanupSessionBrowser(sessionKey)
|
|
975
|
+
fs.rmSync(profileDir, { recursive: true, force: true })
|
|
976
|
+
removeBrowserSessionRecord(sessionKey)
|
|
977
|
+
return stringifyStructured({
|
|
978
|
+
ok: true,
|
|
979
|
+
sessionId: sessionKey,
|
|
980
|
+
profileId: profileInfo.profileId,
|
|
981
|
+
profileDir,
|
|
982
|
+
reset: true,
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (action === 'read_page') {
|
|
987
|
+
const url = typeof params.url === 'string' ? params.url : ''
|
|
988
|
+
if (url) {
|
|
989
|
+
await callMcpTool('browser_navigate', { url })
|
|
990
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
991
|
+
}
|
|
992
|
+
return stringifyStructured(await captureStructuredObservation())
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (action === 'extract_links') {
|
|
996
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
997
|
+
return stringifyStructured({
|
|
998
|
+
url: observation.url || null,
|
|
999
|
+
title: observation.title || null,
|
|
1000
|
+
links: Array.isArray(observation.links) ? observation.links : [],
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (action === 'extract_form_fields') {
|
|
1005
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
1006
|
+
return stringifyStructured({
|
|
1007
|
+
url: observation.url || null,
|
|
1008
|
+
title: observation.title || null,
|
|
1009
|
+
forms: Array.isArray(observation.forms) ? observation.forms : [],
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (action === 'extract_table') {
|
|
1014
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
1015
|
+
const tables = Array.isArray(observation.tables) ? observation.tables : []
|
|
1016
|
+
const tableIndex = typeof params.tableIndex === 'number' ? params.tableIndex : 0
|
|
1017
|
+
return stringifyStructured({
|
|
1018
|
+
url: observation.url || null,
|
|
1019
|
+
title: observation.title || null,
|
|
1020
|
+
table: tables[tableIndex] || null,
|
|
1021
|
+
tables,
|
|
1022
|
+
})
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (action === 'fill_form') {
|
|
1026
|
+
const filled = await performFillForm(params)
|
|
1027
|
+
if (!filled.ok) return `Error: ${filled.error}`
|
|
1028
|
+
if (params.submit === true) {
|
|
1029
|
+
await submitForm(params)
|
|
1030
|
+
}
|
|
1031
|
+
return stringifyStructured({
|
|
1032
|
+
ok: true,
|
|
1033
|
+
filled: filled.filled,
|
|
1034
|
+
submitted: params.submit === true,
|
|
1035
|
+
page: await captureStructuredObservation(),
|
|
1036
|
+
})
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (action === 'submit_form') {
|
|
1040
|
+
return stringifyStructured(await submitForm(params))
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (action === 'scroll_until') {
|
|
1044
|
+
return stringifyStructured(await scrollUntil(params))
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (action === 'download_file') {
|
|
1048
|
+
return stringifyStructured(await downloadFile(params))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (action === 'complete_web_task') {
|
|
1052
|
+
return stringifyStructured(await completeWebTask(params))
|
|
1053
|
+
}
|
|
1054
|
+
|
|
310
1055
|
const mcpTool = MCP_TOOL_MAP[action]
|
|
311
1056
|
if (!mcpTool) return `Unknown browser action: "${action}"`
|
|
1057
|
+
const rest = { ...params }
|
|
1058
|
+
delete rest.action
|
|
312
1059
|
const args: Record<string, any> = {}
|
|
313
|
-
for (const [k, v] of Object.entries(rest)) {
|
|
314
|
-
|
|
315
|
-
|
|
1060
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1061
|
+
if (v !== undefined && v !== null && v !== '') args[k] = v
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (action === 'tabs') {
|
|
1065
|
+
args.action = typeof params.tabAction === 'string' ? params.tabAction : 'list'
|
|
1066
|
+
delete args.tabAction
|
|
1067
|
+
}
|
|
1068
|
+
if (action === 'network') {
|
|
1069
|
+
args.includeStatic = params.includeStatic === true
|
|
1070
|
+
if (typeof params.filename !== 'string') delete args.filename
|
|
1071
|
+
}
|
|
1072
|
+
if (action === 'select' && args.option !== undefined) {
|
|
1073
|
+
args.values = Array.isArray(args.option) ? args.option : [String(args.option)]
|
|
1074
|
+
delete args.option
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if ((action === 'screenshot' || action === 'snapshot') && args.url) {
|
|
1078
|
+
const navUrl = args.url
|
|
1079
|
+
delete args.url
|
|
1080
|
+
await callMcpTool('browser_navigate', { url: navUrl })
|
|
1081
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (action === 'screenshot' || action === 'snapshot') {
|
|
1085
|
+
try {
|
|
1086
|
+
await callMcpTool('browser_evaluate', {
|
|
1087
|
+
expression: `await new Promise(resolve => {
|
|
1088
|
+
if (document.readyState === 'complete') {
|
|
1089
|
+
setTimeout(resolve, 1200);
|
|
1090
|
+
} else {
|
|
1091
|
+
window.addEventListener('load', () => setTimeout(resolve, 1200), { once: true });
|
|
1092
|
+
setTimeout(resolve, 5000);
|
|
1093
|
+
}
|
|
1094
|
+
})`,
|
|
1095
|
+
})
|
|
1096
|
+
} catch {
|
|
1097
|
+
await new Promise((r) => setTimeout(r, 1200))
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
let result = await callMcpTool(mcpTool, args, { saveTo: typeof params.saveTo === 'string' ? params.saveTo : undefined })
|
|
1102
|
+
if (action === 'navigate' && result.includes('ERR_ABORTED')) {
|
|
1103
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
1104
|
+
result = await callMcpTool('browser_snapshot', {})
|
|
1105
|
+
}
|
|
1106
|
+
if (action === 'navigate') {
|
|
1107
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (['navigate', 'back', 'click', 'type', 'select', 'fill_form', 'submit_form', 'press_key', 'scroll_until', 'complete_web_task'].includes(action)) {
|
|
1111
|
+
try { await captureStructuredObservation() } catch { /* ignore */ }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (action === 'close') {
|
|
1115
|
+
cleanupSessionBrowser(sessionKey)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
316
1118
|
return result
|
|
317
|
-
} catch (err: unknown) {
|
|
1119
|
+
} catch (err: unknown) {
|
|
1120
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1121
|
+
}
|
|
318
1122
|
},
|
|
319
1123
|
{
|
|
320
1124
|
name: 'browser',
|
|
321
|
-
description: 'Control
|
|
1125
|
+
description: 'Control a persistent browser profile. Supports low-level actions plus higher-level workflows like read_page, extract_links, extract_form_fields, extract_table, fill_form, submit_form, scroll_until, download_file, complete_web_task, profile, and reset_profile.',
|
|
322
1126
|
schema: z.object({
|
|
323
|
-
action: z.enum([
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1127
|
+
action: z.enum([
|
|
1128
|
+
'navigate',
|
|
1129
|
+
'back',
|
|
1130
|
+
'close',
|
|
1131
|
+
'screenshot',
|
|
1132
|
+
'snapshot',
|
|
1133
|
+
'click',
|
|
1134
|
+
'hover',
|
|
1135
|
+
'type',
|
|
1136
|
+
'fill_form',
|
|
1137
|
+
'submit_form',
|
|
1138
|
+
'scroll_until',
|
|
1139
|
+
'press_key',
|
|
1140
|
+
'select',
|
|
1141
|
+
'dialog',
|
|
1142
|
+
'evaluate',
|
|
1143
|
+
'run_code',
|
|
1144
|
+
'pdf',
|
|
1145
|
+
'upload',
|
|
1146
|
+
'wait',
|
|
1147
|
+
'tabs',
|
|
1148
|
+
'network',
|
|
1149
|
+
'read_page',
|
|
1150
|
+
'extract_links',
|
|
1151
|
+
'extract_form_fields',
|
|
1152
|
+
'extract_table',
|
|
1153
|
+
'download_file',
|
|
1154
|
+
'complete_web_task',
|
|
1155
|
+
'verify_text',
|
|
1156
|
+
'verify_element',
|
|
1157
|
+
'verify_list',
|
|
1158
|
+
'verify_value',
|
|
1159
|
+
'profile',
|
|
1160
|
+
'reset_profile',
|
|
1161
|
+
]),
|
|
1162
|
+
}).passthrough(),
|
|
328
1163
|
},
|
|
329
1164
|
),
|
|
330
1165
|
)
|
|
@@ -332,7 +1167,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
332
1167
|
|
|
333
1168
|
// openclaw_browser CLI passthrough
|
|
334
1169
|
const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
|
|
335
|
-
if (openclawPath && (bctx.
|
|
1170
|
+
if (openclawPath && (bctx.hasPlugin('browser') || bctx.hasPlugin('openclaw_browser'))) {
|
|
336
1171
|
tools.push(
|
|
337
1172
|
tool(
|
|
338
1173
|
async (rawArgs) => {
|