@swarmclawai/swarmclaw 0.7.2 → 0.7.4
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 +116 -50
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +43 -0
- package/src/app/api/agents/[id]/thread/route.ts +39 -8
- package/src/app/api/agents/route.ts +35 -2
- package/src/app/api/auth/route.ts +77 -8
- package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +30 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +23 -1
- 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/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- 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/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +12 -4
- 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 +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +55 -17
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +16 -6
- package/src/app/api/tasks/bulk/route.ts +3 -3
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +135 -17
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +38 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +21 -12
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +456 -23
- package/src/components/agents/inspector-panel.tsx +110 -49
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +70 -27
- package/src/components/chat/chat-card.tsx +6 -21
- package/src/components/chat/chat-header.tsx +263 -366
- package/src/components/chat/chat-list.tsx +62 -26
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +145 -19
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +422 -209
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +385 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +189 -1
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- 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 +88 -6
- package/src/components/shared/settings/section-orchestrator.tsx +6 -3
- 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 +248 -47
- package/src/components/tasks/approvals-panel.tsx +211 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- 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/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +264 -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 +44 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
- package/src/lib/server/chat-execution.ts +402 -125
- 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 +74 -2
- package/src/lib/server/chatroom-helpers.ts +144 -11
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- 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 +994 -130
- 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 +189 -10
- 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/daemon-state.ts +62 -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/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -43
- 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 +31 -964
- 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 +6 -5
- package/src/lib/server/openclaw-gateway.ts +123 -36
- 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 +18 -8
- package/src/lib/server/orchestrator.ts +5 -4
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +215 -0
- package/src/lib/server/plugins.ts +832 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +4 -21
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- 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 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +96 -34
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +40 -12
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +243 -24
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +87 -2
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +162 -12
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +95 -25
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +58 -4
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +195 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +947 -108
- package/src/lib/server/storage.ts +255 -10
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +185 -25
- 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 -11
- package/src/lib/server/tool-aliases.ts +80 -12
- package/src/lib/server/tool-capability-policy.ts +7 -1
- 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/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +62 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +43 -7
- package/src/stores/use-chat-store.ts +31 -2
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +470 -44
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- package/src/lib/server/session-run-manager.test.ts +0 -26
|
@@ -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 }
|
|
@@ -134,7 +222,7 @@ const WebPlugin: Plugin = {
|
|
|
134
222
|
name: 'Core Web',
|
|
135
223
|
description: 'Search the web and fetch content from URLs.',
|
|
136
224
|
hooks: {
|
|
137
|
-
getCapabilityDescription: () => 'I can
|
|
225
|
+
getCapabilityDescription: () => 'I can use the unified `web` tool with action `search` for research and action `fetch` for reading a URL.',
|
|
138
226
|
} as PluginHooks,
|
|
139
227
|
tools: [
|
|
140
228
|
{
|
|
@@ -180,35 +268,108 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
180
268
|
// Browser tool (kept as direct injection for now due to complexity)
|
|
181
269
|
if (bctx.hasPlugin('browser')) {
|
|
182
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)
|
|
183
276
|
let mcpClient: any = null
|
|
184
277
|
let mcpServer: any = null
|
|
185
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
|
+
})
|
|
186
288
|
|
|
187
289
|
const ensureMcp = (): Promise<void> => {
|
|
188
290
|
if (mcpClient) return Promise.resolve()
|
|
189
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
|
+
}
|
|
190
306
|
mcpInitializing = (async () => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
}
|
|
203
346
|
})()
|
|
204
347
|
return mcpInitializing
|
|
205
348
|
}
|
|
206
349
|
|
|
207
350
|
cleanupFns.push(async () => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
212
373
|
})
|
|
213
374
|
|
|
214
375
|
const cleanPlaywrightOutput = (text: string): string => {
|
|
@@ -222,148 +383,826 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
222
383
|
return text.replace(/\n{3,}/g, '\n').trim()
|
|
223
384
|
}
|
|
224
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
|
+
const callBrowserEvaluate = (fn: string) => callMcpTool('browser_evaluate', {
|
|
404
|
+
function: fn,
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const captureStructuredObservation = async () => {
|
|
408
|
+
const expression = `() => {
|
|
409
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
|
410
|
+
const visible = (el) => {
|
|
411
|
+
if (!el) return false;
|
|
412
|
+
const style = window.getComputedStyle(el);
|
|
413
|
+
return style && style.display !== 'none' && style.visibility !== 'hidden';
|
|
414
|
+
};
|
|
415
|
+
const links = Array.from(document.querySelectorAll('a[href]'))
|
|
416
|
+
.filter(visible)
|
|
417
|
+
.slice(0, 25)
|
|
418
|
+
.map((a) => ({
|
|
419
|
+
text: normalize(a.innerText || a.textContent || a.getAttribute('aria-label')),
|
|
420
|
+
href: a.href || a.getAttribute('href') || '',
|
|
421
|
+
}))
|
|
422
|
+
.filter((entry) => entry.href);
|
|
423
|
+
const forms = Array.from(document.forms).slice(0, 5).map((form, index) => ({
|
|
424
|
+
index,
|
|
425
|
+
action: form.getAttribute('action') || form.action || null,
|
|
426
|
+
method: normalize(form.getAttribute('method') || form.method || 'get') || 'get',
|
|
427
|
+
fields: Array.from(form.elements).slice(0, 20).map((el) => ({
|
|
428
|
+
name: el.getAttribute?.('name') || null,
|
|
429
|
+
label: normalize(el.labels?.[0]?.innerText || el.getAttribute?.('aria-label') || el.getAttribute?.('placeholder')) || null,
|
|
430
|
+
type: normalize(el.getAttribute?.('type') || el.tagName || 'field').toLowerCase(),
|
|
431
|
+
required: !!el.required,
|
|
432
|
+
})),
|
|
433
|
+
}));
|
|
434
|
+
const tables = Array.from(document.querySelectorAll('table')).slice(0, 3).map((table, index) => {
|
|
435
|
+
const headerCells = Array.from(table.querySelectorAll('thead th')).map((th) => normalize(th.innerText || th.textContent));
|
|
436
|
+
const bodyRows = Array.from(table.querySelectorAll('tbody tr')).slice(0, 5).map((tr) =>
|
|
437
|
+
Array.from(tr.querySelectorAll('th, td')).map((cell) => normalize(cell.innerText || cell.textContent))
|
|
438
|
+
);
|
|
439
|
+
return {
|
|
440
|
+
index,
|
|
441
|
+
headers: headerCells,
|
|
442
|
+
rowCount: table.querySelectorAll('tbody tr').length,
|
|
443
|
+
rows: bodyRows,
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
const errors = Array.from(document.querySelectorAll('[aria-invalid="true"], .error, .field-error, .invalid, [role="alert"]'))
|
|
447
|
+
.filter(visible)
|
|
448
|
+
.slice(0, 10)
|
|
449
|
+
.map((el) => normalize(el.innerText || el.textContent))
|
|
450
|
+
.filter(Boolean);
|
|
451
|
+
const textPreview = normalize(document.body?.innerText || document.body?.textContent || '').slice(0, 1200);
|
|
452
|
+
const lowerPreview = textPreview.toLowerCase();
|
|
453
|
+
const notices = [];
|
|
454
|
+
if (/ask the human|out-of-band|do not guess|verification code required/.test(lowerPreview)) {
|
|
455
|
+
notices.push({
|
|
456
|
+
type: 'human_input_required',
|
|
457
|
+
message: 'This page requires human-provided input. Ask the human instead of guessing or repeatedly submitting blank values.',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
url: window.location.href,
|
|
462
|
+
title: document.title || null,
|
|
463
|
+
textPreview,
|
|
464
|
+
links,
|
|
465
|
+
forms,
|
|
466
|
+
tables,
|
|
467
|
+
errors,
|
|
468
|
+
notices,
|
|
469
|
+
};
|
|
470
|
+
}`
|
|
471
|
+
const raw = await callBrowserEvaluate(expression)
|
|
472
|
+
const parsed = extractJsonPayload(raw)
|
|
473
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
474
|
+
const observation = {
|
|
475
|
+
capturedAt: Date.now(),
|
|
476
|
+
...parsed,
|
|
477
|
+
} as any
|
|
478
|
+
recordBrowserObservation(sessionKey, observation)
|
|
479
|
+
return observation
|
|
480
|
+
}
|
|
481
|
+
const fallback = {
|
|
482
|
+
capturedAt: Date.now(),
|
|
483
|
+
url: null,
|
|
484
|
+
title: null,
|
|
485
|
+
textPreview: cleanPlaywrightOutput(raw).slice(0, 1200),
|
|
486
|
+
}
|
|
487
|
+
recordBrowserObservation(sessionKey, fallback)
|
|
488
|
+
return fallback
|
|
489
|
+
}
|
|
490
|
+
|
|
225
491
|
const MCP_CALL_TIMEOUT_MS = 30000 // 30s timeout per browser action
|
|
226
492
|
const callMcpTool = async (toolName: string, args: Record<string, any>, options?: { saveTo?: string }): Promise<string> => {
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
493
|
+
const rawCall = async (): Promise<string> => {
|
|
494
|
+
try {
|
|
495
|
+
await ensureMcp()
|
|
496
|
+
const result = await Promise.race([
|
|
497
|
+
mcpClient.callTool({ name: toolName, arguments: args }),
|
|
498
|
+
new Promise<never>((_resolve, reject) =>
|
|
499
|
+
setTimeout(() => reject(new Error(`Browser action "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS / 1000}s`)), MCP_CALL_TIMEOUT_MS),
|
|
500
|
+
),
|
|
501
|
+
])
|
|
502
|
+
const isError = result?.isError === true
|
|
503
|
+
const content = result?.content
|
|
504
|
+
const savedPaths: string[] = []
|
|
505
|
+
const artifacts: Array<{ kind: 'snapshot' | 'screenshot' | 'download' | 'pdf'; path: string; url?: string | null; filename?: string | null; createdAt: number }> = []
|
|
506
|
+
const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
|
|
507
|
+
const rawSaveTo = options?.saveTo?.trim()
|
|
508
|
+
if (!rawSaveTo) return
|
|
509
|
+
let resolved = safePath(cwd, rawSaveTo)
|
|
510
|
+
if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
|
|
511
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
|
512
|
+
fs.writeFileSync(resolved, buffer)
|
|
513
|
+
savedPaths.push(resolved)
|
|
514
|
+
}
|
|
515
|
+
if (Array.isArray(content)) {
|
|
516
|
+
let parts: string[] = []
|
|
517
|
+
const isScreenshotTool = toolName === 'browser_take_screenshot'
|
|
518
|
+
const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
|
|
519
|
+
for (const c of content) {
|
|
520
|
+
if (c.type === 'image' && c.data) {
|
|
521
|
+
const imageBuffer = Buffer.from(c.data, 'base64')
|
|
522
|
+
const filename = `screenshot-${Date.now()}.png`
|
|
523
|
+
const filepath = path.join(UPLOAD_DIR, filename)
|
|
524
|
+
fs.writeFileSync(filepath, imageBuffer)
|
|
525
|
+
saveArtifact(imageBuffer, 'png')
|
|
526
|
+
artifacts.push({ kind: 'screenshot', path: filepath, url: `/api/uploads/${filename}`, filename, createdAt: Date.now() })
|
|
527
|
+
parts.push(`Screenshot saved to /api/uploads/${filename}`)
|
|
528
|
+
parts.push(``)
|
|
529
|
+
} else if (c.type === 'resource' && c.resource?.blob) {
|
|
530
|
+
const ext = c.resource.mimeType?.includes('pdf') ? 'pdf' : 'bin'
|
|
531
|
+
const resourceBuffer = Buffer.from(c.resource.blob, 'base64')
|
|
532
|
+
const filename = `browser-${Date.now()}.${ext}`
|
|
533
|
+
const filepath = path.join(UPLOAD_DIR, filename)
|
|
534
|
+
fs.writeFileSync(filepath, resourceBuffer)
|
|
535
|
+
saveArtifact(resourceBuffer, ext)
|
|
536
|
+
artifacts.push({
|
|
537
|
+
kind: ext === 'pdf' ? 'pdf' : 'download',
|
|
538
|
+
path: filepath,
|
|
539
|
+
url: `/api/uploads/${filename}`,
|
|
540
|
+
filename,
|
|
541
|
+
createdAt: Date.now(),
|
|
542
|
+
})
|
|
543
|
+
parts.push(`[Download ${filename}](/api/uploads/${filename})`)
|
|
544
|
+
} else {
|
|
545
|
+
const text = c.text || ''
|
|
546
|
+
const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
|
|
547
|
+
if (fileMatch) {
|
|
548
|
+
const rawPath = fileMatch[1]
|
|
549
|
+
const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
|
|
550
|
+
if (fs.existsSync(srcPath)) {
|
|
551
|
+
const ext = path.extname(srcPath).slice(1).toLowerCase()
|
|
552
|
+
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
|
|
553
|
+
if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) {
|
|
554
|
+
continue
|
|
555
|
+
} else {
|
|
556
|
+
const filename = `browser-${Date.now()}.${ext}`
|
|
557
|
+
const destPath = path.join(UPLOAD_DIR, filename)
|
|
558
|
+
fs.copyFileSync(srcPath, destPath)
|
|
559
|
+
if (options?.saveTo?.trim()) {
|
|
560
|
+
let targetPath = safePath(cwd, options.saveTo.trim())
|
|
561
|
+
if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
|
|
562
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
|
563
|
+
fs.copyFileSync(srcPath, targetPath)
|
|
564
|
+
savedPaths.push(targetPath)
|
|
565
|
+
}
|
|
566
|
+
artifacts.push({
|
|
567
|
+
kind: ext === 'pdf' ? 'pdf' : 'download',
|
|
568
|
+
path: destPath,
|
|
569
|
+
url: `/api/uploads/${filename}`,
|
|
570
|
+
filename,
|
|
571
|
+
createdAt: Date.now(),
|
|
572
|
+
})
|
|
573
|
+
parts.push(IMAGE_EXTS.includes(ext) ? `` : `[Download ${filename}](/api/uploads/${filename})`)
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
parts.push(isError ? text : cleanPlaywrightOutput(text))
|
|
272
577
|
}
|
|
273
|
-
|
|
578
|
+
} else {
|
|
579
|
+
parts.push(isError ? text : cleanPlaywrightOutput(text))
|
|
274
580
|
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
|
|
584
|
+
if (savedPaths.length > 0) {
|
|
585
|
+
const unique = Array.from(new Set(savedPaths))
|
|
586
|
+
parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
|
|
587
|
+
}
|
|
588
|
+
upsertBrowserSessionRecord({
|
|
589
|
+
sessionId: sessionKey,
|
|
590
|
+
profileId: profileInfo.profileId,
|
|
591
|
+
profileDir,
|
|
592
|
+
status: 'active',
|
|
593
|
+
lastAction: toolName,
|
|
594
|
+
lastError: isError ? parts.join('\n').slice(0, 1000) : null,
|
|
595
|
+
artifacts,
|
|
596
|
+
})
|
|
597
|
+
return parts.join('\n')
|
|
277
598
|
}
|
|
599
|
+
const fallback = JSON.stringify(result)
|
|
600
|
+
upsertBrowserSessionRecord({
|
|
601
|
+
sessionId: sessionKey,
|
|
602
|
+
profileId: profileInfo.profileId,
|
|
603
|
+
profileDir,
|
|
604
|
+
status: 'active',
|
|
605
|
+
lastAction: toolName,
|
|
606
|
+
lastError: isError ? fallback.slice(0, 1000) : null,
|
|
607
|
+
})
|
|
608
|
+
return fallback
|
|
609
|
+
} catch (err: unknown) {
|
|
610
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
611
|
+
upsertBrowserSessionRecord({
|
|
612
|
+
sessionId: sessionKey,
|
|
613
|
+
profileId: profileInfo.profileId,
|
|
614
|
+
profileDir,
|
|
615
|
+
status: 'error',
|
|
616
|
+
lastAction: toolName,
|
|
617
|
+
lastError: message,
|
|
618
|
+
})
|
|
619
|
+
return `Error: ${message}`
|
|
278
620
|
}
|
|
279
|
-
if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
|
|
280
|
-
if (savedPaths.length > 0) {
|
|
281
|
-
const unique = Array.from(new Set(savedPaths))
|
|
282
|
-
parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
|
|
283
|
-
}
|
|
284
|
-
return parts.join('\n')
|
|
285
621
|
}
|
|
286
|
-
|
|
622
|
+
|
|
623
|
+
return withRetry(rawCall, undefined, {
|
|
624
|
+
maxAttempts: 3,
|
|
625
|
+
backoffMs: 1000,
|
|
626
|
+
retryable: [
|
|
627
|
+
/timed out/i,
|
|
628
|
+
/ERR_ABORTED/i,
|
|
629
|
+
/Target closed/i,
|
|
630
|
+
/Execution context was destroyed/i,
|
|
631
|
+
/SharedContextFactory already exists/i,
|
|
632
|
+
/ECONNRESET/i,
|
|
633
|
+
/temporarily unavailable/i,
|
|
634
|
+
],
|
|
635
|
+
onRetry: async (_attempt, result) => {
|
|
636
|
+
if (/SharedContextFactory already exists/i.test(result)) {
|
|
637
|
+
cleanupSessionBrowser(sessionKey)
|
|
638
|
+
upsertBrowserSessionRecord({
|
|
639
|
+
sessionId: sessionKey,
|
|
640
|
+
profileId: profileInfo.profileId,
|
|
641
|
+
profileDir,
|
|
642
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
643
|
+
status: 'idle',
|
|
644
|
+
lastAction: 'browser_recover',
|
|
645
|
+
lastError: 'Recovered browser transport after Playwright shared-context startup conflict.',
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
})
|
|
287
650
|
}
|
|
288
651
|
|
|
289
652
|
const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
|
|
290
653
|
await new Promise((r) => setTimeout(r, 1500))
|
|
291
|
-
const js = `(
|
|
654
|
+
const js = `() => {
|
|
292
655
|
const sel = ['button[id*="reject" i]', 'button[class*="reject" i]', 'a[id*="reject" i]', 'a[class*="reject" i]', '#onetrust-reject-all-handler', '#CybotCookiebotDialogBodyButtonDecline', '#didomi-notice-disagree-button', '.qc-cmp2-summary-buttons button:first-child', 'button.sp_choice_type_12'];
|
|
293
656
|
for (const s of sel) { const el = document.querySelector(s); if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; } }
|
|
294
657
|
const btns = [...document.querySelectorAll('button, a[role="button"]')]; const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
|
|
295
658
|
for (const b of btns) { const txt = (b.textContent || '').trim(); if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; } }
|
|
296
659
|
return 'none';
|
|
297
|
-
}
|
|
298
|
-
await mcpCall('browser_evaluate', {
|
|
660
|
+
}`
|
|
661
|
+
await mcpCall('browser_evaluate', { function: js })
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const performFillForm = async (params: Record<string, unknown>) => {
|
|
665
|
+
const fields = Array.isArray(params.fields)
|
|
666
|
+
? params.fields
|
|
667
|
+
: (() => {
|
|
668
|
+
const form = params.form
|
|
669
|
+
if (!form || typeof form !== 'object' || Array.isArray(form)) return []
|
|
670
|
+
return Object.entries(form as Record<string, unknown>).map(([key, value]) => {
|
|
671
|
+
const escapedId = String(key).replace(/[^a-zA-Z0-9_-]/g, '')
|
|
672
|
+
const escapedAttr = String(key).replace(/["\\]/g, '\\$&')
|
|
673
|
+
const inferredType = typeof value === 'boolean'
|
|
674
|
+
? 'checkbox'
|
|
675
|
+
: /password/i.test(key)
|
|
676
|
+
? 'password'
|
|
677
|
+
: 'text'
|
|
678
|
+
return {
|
|
679
|
+
element: escapedId
|
|
680
|
+
? `#${escapedId}, [name="${escapedAttr}"]`
|
|
681
|
+
: `[name="${escapedAttr}"]`,
|
|
682
|
+
type: inferredType,
|
|
683
|
+
value,
|
|
684
|
+
}
|
|
685
|
+
})
|
|
686
|
+
})()
|
|
687
|
+
if (fields.length === 0) return { ok: false, error: 'fields is required for fill_form.' }
|
|
688
|
+
const filled: Array<Record<string, unknown>> = []
|
|
689
|
+
for (const field of fields) {
|
|
690
|
+
if (!field || typeof field !== 'object') continue
|
|
691
|
+
const entry = field as Record<string, unknown>
|
|
692
|
+
const ref = typeof entry.ref === 'string' ? entry.ref : undefined
|
|
693
|
+
const element = typeof entry.element === 'string' ? entry.element : undefined
|
|
694
|
+
const fieldType = String(entry.type || 'text').toLowerCase()
|
|
695
|
+
const value = entry.value
|
|
696
|
+
if (!ref && !element) continue
|
|
697
|
+
if (fieldType === 'select') {
|
|
698
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value ?? '')]
|
|
699
|
+
await callMcpTool('browser_select_option', { ref, element, values })
|
|
700
|
+
} else if (fieldType === 'checkbox' || fieldType === 'radio') {
|
|
701
|
+
if (value === true || value === 'true' || value === 'on' || value === 'checked') {
|
|
702
|
+
await callMcpTool('browser_click', { ref, element })
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
await callMcpTool('browser_type', {
|
|
706
|
+
ref,
|
|
707
|
+
element,
|
|
708
|
+
text: String(value ?? ''),
|
|
709
|
+
slowly: fieldType === 'password' ? false : params.slowly === true,
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
filled.push({
|
|
713
|
+
ref: ref || null,
|
|
714
|
+
element: element || null,
|
|
715
|
+
type: fieldType,
|
|
716
|
+
value: value ?? null,
|
|
717
|
+
})
|
|
718
|
+
}
|
|
719
|
+
return { ok: true, filled }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const submitForm = async (params: Record<string, unknown>) => {
|
|
723
|
+
if (typeof params.submitRef === 'string' || typeof params.submitElement === 'string') {
|
|
724
|
+
await callMcpTool('browser_click', {
|
|
725
|
+
ref: typeof params.submitRef === 'string' ? params.submitRef : undefined,
|
|
726
|
+
element: typeof params.submitElement === 'string' ? params.submitElement : undefined,
|
|
727
|
+
})
|
|
728
|
+
} else {
|
|
729
|
+
await callBrowserEvaluate(`() => {
|
|
730
|
+
const form = document.forms[0];
|
|
731
|
+
if (!form) return { submitted: false, reason: 'no-form' };
|
|
732
|
+
const submitButton = form.querySelector('button[type="submit"], input[type="submit"], button');
|
|
733
|
+
if (submitButton && typeof submitButton.click === 'function') {
|
|
734
|
+
submitButton.click();
|
|
735
|
+
return { submitted: true, method: 'click' };
|
|
736
|
+
}
|
|
737
|
+
if (typeof form.requestSubmit === 'function') {
|
|
738
|
+
form.requestSubmit();
|
|
739
|
+
return { submitted: true, method: 'requestSubmit' };
|
|
740
|
+
}
|
|
741
|
+
if (typeof form.submit === 'function') {
|
|
742
|
+
form.submit();
|
|
743
|
+
return { submitted: true, method: 'submit' };
|
|
744
|
+
}
|
|
745
|
+
return { submitted: false, reason: 'no-submit-method' };
|
|
746
|
+
}`)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const waitMs = typeof params.waitMs === 'number' ? Math.max(250, params.waitMs) : 1000
|
|
750
|
+
try {
|
|
751
|
+
await callBrowserEvaluate(`async () => { await new Promise(resolve => setTimeout(resolve, ${Math.min(waitMs, 5000)})); }`)
|
|
752
|
+
} catch {
|
|
753
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
ok: true,
|
|
758
|
+
submitted: true,
|
|
759
|
+
page: await captureStructuredObservation(),
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const scrollUntil = async (params: Record<string, unknown>) => {
|
|
764
|
+
const containsText = typeof params.containsText === 'string'
|
|
765
|
+
? params.containsText
|
|
766
|
+
: typeof params.text === 'string'
|
|
767
|
+
? params.text
|
|
768
|
+
: ''
|
|
769
|
+
const selector = typeof params.selector === 'string' ? params.selector : ''
|
|
770
|
+
if (!containsText && !selector) return { ok: false, error: 'containsText or selector is required for scroll_until.' }
|
|
771
|
+
|
|
772
|
+
const maxScrolls = typeof params.maxScrolls === 'number' ? Math.max(1, Math.min(20, params.maxScrolls)) : 8
|
|
773
|
+
let matchedAtStep = -1
|
|
774
|
+
for (let index = 0; index < maxScrolls; index += 1) {
|
|
775
|
+
const result = await callBrowserEvaluate(`() => {
|
|
776
|
+
const bodyText = String(document.body?.innerText || document.body?.textContent || '');
|
|
777
|
+
const selector = ${JSON.stringify(selector)};
|
|
778
|
+
const containsText = ${JSON.stringify(containsText)};
|
|
779
|
+
const match = (selector && !!document.querySelector(selector))
|
|
780
|
+
|| (containsText && bodyText.includes(containsText));
|
|
781
|
+
if (match) return { found: true, scrollY: window.scrollY, step: ${index} };
|
|
782
|
+
window.scrollBy({ top: Math.max(window.innerHeight * 0.85, 600), behavior: 'instant' });
|
|
783
|
+
return { found: false, scrollY: window.scrollY, step: ${index} };
|
|
784
|
+
}`)
|
|
785
|
+
const payload = extractJsonPayload(result)
|
|
786
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && (payload as Record<string, unknown>).found === true) {
|
|
787
|
+
matchedAtStep = index
|
|
788
|
+
break
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const page = await captureStructuredObservation()
|
|
793
|
+
return {
|
|
794
|
+
ok: matchedAtStep >= 0,
|
|
795
|
+
found: matchedAtStep >= 0,
|
|
796
|
+
matchedAtStep: matchedAtStep >= 0 ? matchedAtStep : null,
|
|
797
|
+
page,
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const resolveDownloadUrl = async (params: Record<string, unknown>) => {
|
|
802
|
+
if (typeof params.url === 'string' && params.url.trim()) return params.url.trim()
|
|
803
|
+
const linkText = typeof params.linkText === 'string' ? params.linkText.trim() : ''
|
|
804
|
+
const hrefContains = typeof params.hrefContains === 'string' ? params.hrefContains.trim() : ''
|
|
805
|
+
if (!linkText && !hrefContains) return null
|
|
806
|
+
const result = await callBrowserEvaluate(`() => {
|
|
807
|
+
const linkText = ${JSON.stringify(linkText)};
|
|
808
|
+
const hrefContains = ${JSON.stringify(hrefContains)};
|
|
809
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
810
|
+
const match = links.find((link) => {
|
|
811
|
+
const text = String(link.innerText || link.textContent || '').trim();
|
|
812
|
+
const href = String(link.href || link.getAttribute('href') || '').trim();
|
|
813
|
+
if (!href) return false;
|
|
814
|
+
if (linkText && text.toLowerCase().includes(linkText.toLowerCase())) return true;
|
|
815
|
+
if (hrefContains && href.toLowerCase().includes(hrefContains.toLowerCase())) return true;
|
|
816
|
+
return false;
|
|
817
|
+
});
|
|
818
|
+
return { href: match ? (match.href || match.getAttribute('href') || '') : null };
|
|
819
|
+
}`)
|
|
820
|
+
const payload = extractJsonPayload(result)
|
|
821
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
822
|
+
const href = (payload as Record<string, unknown>).href
|
|
823
|
+
return typeof href === 'string' && href.trim() ? href.trim() : null
|
|
824
|
+
}
|
|
825
|
+
return null
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const downloadFile = async (params: Record<string, unknown>) => {
|
|
829
|
+
const downloadUrl = await resolveDownloadUrl(params)
|
|
830
|
+
if (!downloadUrl) return { ok: false, error: 'url, linkText, or hrefContains is required for download_file.' }
|
|
831
|
+
|
|
832
|
+
const current = await captureStructuredObservation()
|
|
833
|
+
let resolvedUrl = downloadUrl
|
|
834
|
+
if (!/^https?:\/\//i.test(resolvedUrl)) {
|
|
835
|
+
const base = typeof current.url === 'string' && current.url ? current.url : undefined
|
|
836
|
+
if (!base) return { ok: false, error: 'Relative download URL requires an active page URL.' }
|
|
837
|
+
resolvedUrl = new URL(resolvedUrl, base).toString()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const res = await fetch(resolvedUrl, {
|
|
841
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
|
|
842
|
+
signal: AbortSignal.timeout(30_000),
|
|
843
|
+
})
|
|
844
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${res.statusText}`, url: resolvedUrl }
|
|
845
|
+
|
|
846
|
+
const arrayBuffer = await res.arrayBuffer()
|
|
847
|
+
const data = Buffer.from(arrayBuffer)
|
|
848
|
+
const inferredName = (() => {
|
|
849
|
+
try {
|
|
850
|
+
const pathname = new URL(resolvedUrl).pathname
|
|
851
|
+
const base = path.basename(pathname)
|
|
852
|
+
return base && base !== '/' ? base : `download-${Date.now()}`
|
|
853
|
+
} catch {
|
|
854
|
+
return `download-${Date.now()}`
|
|
855
|
+
}
|
|
856
|
+
})()
|
|
857
|
+
const targetPath = typeof params.saveTo === 'string' && params.saveTo.trim()
|
|
858
|
+
? safePath(cwd, params.saveTo.trim())
|
|
859
|
+
: path.join(UPLOAD_DIR, inferredName)
|
|
860
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
|
861
|
+
fs.writeFileSync(targetPath, data)
|
|
862
|
+
|
|
863
|
+
const artifactPath = targetPath.startsWith(UPLOAD_DIR)
|
|
864
|
+
? targetPath
|
|
865
|
+
: path.join(UPLOAD_DIR, `${Date.now()}-${path.basename(targetPath)}`)
|
|
866
|
+
if (artifactPath !== targetPath) fs.copyFileSync(targetPath, artifactPath)
|
|
867
|
+
const filename = path.basename(artifactPath)
|
|
868
|
+
upsertBrowserSessionRecord({
|
|
869
|
+
sessionId: sessionKey,
|
|
870
|
+
profileId: profileInfo.profileId,
|
|
871
|
+
profileDir,
|
|
872
|
+
status: 'active',
|
|
873
|
+
lastAction: 'download_file',
|
|
874
|
+
artifacts: [{
|
|
875
|
+
kind: 'download',
|
|
876
|
+
path: artifactPath,
|
|
877
|
+
url: `/api/uploads/${filename}`,
|
|
878
|
+
filename,
|
|
879
|
+
createdAt: Date.now(),
|
|
880
|
+
}],
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
ok: true,
|
|
885
|
+
url: resolvedUrl,
|
|
886
|
+
path: targetPath,
|
|
887
|
+
artifactUrl: `/api/uploads/${filename}`,
|
|
888
|
+
filename: path.basename(targetPath),
|
|
889
|
+
sizeBytes: data.byteLength,
|
|
890
|
+
contentType: res.headers.get('content-type') || null,
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const verifyOutcome = async (params: Record<string, unknown>) => {
|
|
895
|
+
const verification: Record<string, unknown> = {}
|
|
896
|
+
if (typeof params.expectText === 'string' && params.expectText.trim()) {
|
|
897
|
+
verification.expectText = await callMcpTool('browser_verify_text_visible', { text: params.expectText.trim() })
|
|
898
|
+
}
|
|
899
|
+
if (typeof params.expectElement === 'string' && params.expectElement.trim()) {
|
|
900
|
+
verification.expectElement = await callMcpTool('browser_verify_element_visible', { element: params.expectElement.trim() })
|
|
901
|
+
}
|
|
902
|
+
if (typeof params.expectValue === 'string' && params.expectValue.trim()) {
|
|
903
|
+
verification.expectValue = await callMcpTool('browser_verify_value', {
|
|
904
|
+
element: typeof params.expectValueElement === 'string' ? params.expectValueElement : undefined,
|
|
905
|
+
value: params.expectValue.trim(),
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
return verification
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const completeWebTask = async (params: Record<string, unknown>) => {
|
|
912
|
+
const steps: string[] = []
|
|
913
|
+
if (typeof params.url === 'string' && params.url.trim()) {
|
|
914
|
+
await callMcpTool('browser_navigate', { url: params.url.trim() })
|
|
915
|
+
steps.push(`navigate:${params.url.trim()}`)
|
|
916
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let initialPage = await captureStructuredObservation()
|
|
920
|
+
if (typeof params.scrollUntilText === 'string' || typeof params.scrollUntilSelector === 'string') {
|
|
921
|
+
const scroll = await scrollUntil({
|
|
922
|
+
containsText: typeof params.scrollUntilText === 'string' ? params.scrollUntilText : undefined,
|
|
923
|
+
selector: typeof params.scrollUntilSelector === 'string' ? params.scrollUntilSelector : undefined,
|
|
924
|
+
maxScrolls: typeof params.maxScrolls === 'number' ? params.maxScrolls : undefined,
|
|
925
|
+
})
|
|
926
|
+
steps.push('scroll_until')
|
|
927
|
+
if (scroll.ok) initialPage = scroll.page
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (Array.isArray(params.fields) && params.fields.length > 0) {
|
|
931
|
+
const filled = await performFillForm(params)
|
|
932
|
+
if (!filled.ok) return filled
|
|
933
|
+
steps.push('fill_form')
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (params.submit === true) {
|
|
937
|
+
await submitForm(params)
|
|
938
|
+
steps.push('submit_form')
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let download: Record<string, unknown> | null = null
|
|
942
|
+
if (params.download === true || typeof params.downloadUrl === 'string' || typeof params.linkText === 'string' || typeof params.hrefContains === 'string') {
|
|
943
|
+
download = await downloadFile({
|
|
944
|
+
url: typeof params.downloadUrl === 'string' ? params.downloadUrl : params.url,
|
|
945
|
+
linkText: params.linkText,
|
|
946
|
+
hrefContains: params.hrefContains,
|
|
947
|
+
saveTo: params.saveTo,
|
|
948
|
+
})
|
|
949
|
+
steps.push('download_file')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const verification = await verifyOutcome(params)
|
|
953
|
+
const page = await captureStructuredObservation()
|
|
954
|
+
return {
|
|
955
|
+
ok: true,
|
|
956
|
+
goal: typeof params.goal === 'string' ? params.goal : null,
|
|
957
|
+
steps,
|
|
958
|
+
verification,
|
|
959
|
+
initialPage,
|
|
960
|
+
page,
|
|
961
|
+
download,
|
|
962
|
+
}
|
|
299
963
|
}
|
|
300
964
|
|
|
301
965
|
const MCP_TOOL_MAP: Record<string, string> = {
|
|
302
|
-
navigate: 'browser_navigate',
|
|
303
|
-
|
|
304
|
-
|
|
966
|
+
navigate: 'browser_navigate',
|
|
967
|
+
back: 'browser_navigate_back',
|
|
968
|
+
close: 'browser_close',
|
|
969
|
+
screenshot: 'browser_take_screenshot',
|
|
970
|
+
snapshot: 'browser_snapshot',
|
|
971
|
+
click: 'browser_click',
|
|
972
|
+
hover: 'browser_hover',
|
|
973
|
+
type: 'browser_type',
|
|
974
|
+
press_key: 'browser_press_key',
|
|
975
|
+
select: 'browser_select_option',
|
|
976
|
+
fill_form: 'browser_fill_form',
|
|
977
|
+
dialog: 'browser_handle_dialog',
|
|
978
|
+
evaluate: 'browser_evaluate',
|
|
979
|
+
run_code: 'browser_run_code',
|
|
980
|
+
pdf: 'browser_pdf_save',
|
|
981
|
+
upload: 'browser_file_upload',
|
|
982
|
+
wait: 'browser_wait_for',
|
|
983
|
+
tabs: 'browser_tabs',
|
|
984
|
+
network: 'browser_network_requests',
|
|
985
|
+
verify_text: 'browser_verify_text_visible',
|
|
986
|
+
verify_element: 'browser_verify_element_visible',
|
|
987
|
+
verify_list: 'browser_verify_list_visible',
|
|
988
|
+
verify_value: 'browser_verify_value',
|
|
305
989
|
}
|
|
306
990
|
|
|
307
991
|
tools.push(
|
|
308
992
|
tool(
|
|
309
|
-
async (
|
|
993
|
+
async (rawParams) => {
|
|
994
|
+
const params = normalizeToolInputArgs((rawParams ?? {}) as Record<string, unknown>)
|
|
310
995
|
try {
|
|
311
|
-
const
|
|
996
|
+
const action = String(params.action || '').trim()
|
|
997
|
+
|
|
998
|
+
if (action === 'profile') {
|
|
999
|
+
const state = upsertBrowserSessionRecord({
|
|
1000
|
+
sessionId: sessionKey,
|
|
1001
|
+
profileId: profileInfo.profileId,
|
|
1002
|
+
profileDir,
|
|
1003
|
+
inheritedFromSessionId: profileInfo.inheritedFromSessionId,
|
|
1004
|
+
status: activeBrowsers.has(sessionKey) ? 'active' : 'idle',
|
|
1005
|
+
})
|
|
1006
|
+
return stringifyStructured({
|
|
1007
|
+
sessionId: sessionKey,
|
|
1008
|
+
active: activeBrowsers.has(sessionKey),
|
|
1009
|
+
profileId: state.profileId,
|
|
1010
|
+
profileDir: state.profileDir,
|
|
1011
|
+
inheritedFromSessionId: state.inheritedFromSessionId,
|
|
1012
|
+
currentUrl: state.currentUrl,
|
|
1013
|
+
pageTitle: state.pageTitle,
|
|
1014
|
+
lastObservation: state.lastObservation,
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (action === 'reset_profile') {
|
|
1019
|
+
cleanupSessionBrowser(sessionKey)
|
|
1020
|
+
fs.rmSync(profileDir, { recursive: true, force: true })
|
|
1021
|
+
removeBrowserSessionRecord(sessionKey)
|
|
1022
|
+
return stringifyStructured({
|
|
1023
|
+
ok: true,
|
|
1024
|
+
sessionId: sessionKey,
|
|
1025
|
+
profileId: profileInfo.profileId,
|
|
1026
|
+
profileDir,
|
|
1027
|
+
reset: true,
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (action === 'read_page') {
|
|
1032
|
+
const url = typeof params.url === 'string' ? params.url : ''
|
|
1033
|
+
if (url) {
|
|
1034
|
+
await callMcpTool('browser_navigate', { url })
|
|
1035
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
1036
|
+
}
|
|
1037
|
+
return stringifyStructured(await captureStructuredObservation())
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (action === 'extract_links') {
|
|
1041
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
1042
|
+
return stringifyStructured({
|
|
1043
|
+
url: observation.url || null,
|
|
1044
|
+
title: observation.title || null,
|
|
1045
|
+
links: Array.isArray(observation.links) ? observation.links : [],
|
|
1046
|
+
})
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (action === 'extract_form_fields') {
|
|
1050
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
1051
|
+
return stringifyStructured({
|
|
1052
|
+
url: observation.url || null,
|
|
1053
|
+
title: observation.title || null,
|
|
1054
|
+
forms: Array.isArray(observation.forms) ? observation.forms : [],
|
|
1055
|
+
})
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (action === 'extract_table') {
|
|
1059
|
+
const observation = await captureStructuredObservation() as Record<string, unknown>
|
|
1060
|
+
const tables = Array.isArray(observation.tables) ? observation.tables : []
|
|
1061
|
+
const tableIndex = typeof params.tableIndex === 'number' ? params.tableIndex : 0
|
|
1062
|
+
return stringifyStructured({
|
|
1063
|
+
url: observation.url || null,
|
|
1064
|
+
title: observation.title || null,
|
|
1065
|
+
table: tables[tableIndex] || null,
|
|
1066
|
+
tables,
|
|
1067
|
+
})
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (action === 'fill_form') {
|
|
1071
|
+
const filled = await performFillForm(params)
|
|
1072
|
+
if (!filled.ok) return `Error: ${filled.error}`
|
|
1073
|
+
if (params.submit === true) {
|
|
1074
|
+
await submitForm(params)
|
|
1075
|
+
}
|
|
1076
|
+
return stringifyStructured({
|
|
1077
|
+
ok: true,
|
|
1078
|
+
filled: filled.filled,
|
|
1079
|
+
submitted: params.submit === true,
|
|
1080
|
+
page: await captureStructuredObservation(),
|
|
1081
|
+
})
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (action === 'submit_form') {
|
|
1085
|
+
return stringifyStructured(await submitForm(params))
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (action === 'scroll_until') {
|
|
1089
|
+
return stringifyStructured(await scrollUntil(params))
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (action === 'download_file') {
|
|
1093
|
+
return stringifyStructured(await downloadFile(params))
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (action === 'complete_web_task') {
|
|
1097
|
+
return stringifyStructured(await completeWebTask(params))
|
|
1098
|
+
}
|
|
1099
|
+
|
|
312
1100
|
const mcpTool = MCP_TOOL_MAP[action]
|
|
313
1101
|
if (!mcpTool) return `Unknown browser action: "${action}"`
|
|
314
|
-
|
|
1102
|
+
const rest = { ...params }
|
|
1103
|
+
delete rest.action
|
|
315
1104
|
const args: Record<string, any> = {}
|
|
316
|
-
for (const [k, v] of Object.entries(rest)) {
|
|
1105
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1106
|
+
if (v !== undefined && v !== null && v !== '') args[k] = v
|
|
1107
|
+
}
|
|
317
1108
|
|
|
318
|
-
|
|
319
|
-
|
|
1109
|
+
if (action === 'tabs') {
|
|
1110
|
+
args.action = typeof params.tabAction === 'string' ? params.tabAction : 'list'
|
|
1111
|
+
delete args.tabAction
|
|
1112
|
+
}
|
|
1113
|
+
if (action === 'network') {
|
|
1114
|
+
args.includeStatic = params.includeStatic === true
|
|
1115
|
+
if (typeof params.filename !== 'string') delete args.filename
|
|
1116
|
+
}
|
|
1117
|
+
if (action === 'select' && args.option !== undefined) {
|
|
1118
|
+
args.values = Array.isArray(args.option) ? args.option : [String(args.option)]
|
|
1119
|
+
delete args.option
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if ((action === 'screenshot' || action === 'snapshot') && args.url) {
|
|
320
1123
|
const navUrl = args.url
|
|
321
1124
|
delete args.url
|
|
322
1125
|
await callMcpTool('browser_navigate', { url: navUrl })
|
|
323
1126
|
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
324
1127
|
}
|
|
325
1128
|
|
|
326
|
-
|
|
327
|
-
if (action === 'screenshot') {
|
|
1129
|
+
if (action === 'screenshot' || action === 'snapshot') {
|
|
328
1130
|
try {
|
|
329
|
-
await
|
|
330
|
-
expression: `await new Promise(resolve => {
|
|
1131
|
+
await callBrowserEvaluate(`async () => { await new Promise(resolve => {
|
|
331
1132
|
if (document.readyState === 'complete') {
|
|
332
|
-
setTimeout(resolve,
|
|
1133
|
+
setTimeout(resolve, 1200);
|
|
333
1134
|
} else {
|
|
334
|
-
window.addEventListener('load', () => setTimeout(resolve,
|
|
1135
|
+
window.addEventListener('load', () => setTimeout(resolve, 1200), { once: true });
|
|
335
1136
|
setTimeout(resolve, 5000);
|
|
336
1137
|
}
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
await new Promise((r) => setTimeout(r, 2000))
|
|
1138
|
+
}); }`)
|
|
1139
|
+
} catch {
|
|
1140
|
+
await new Promise((r) => setTimeout(r, 1200))
|
|
341
1141
|
}
|
|
342
1142
|
}
|
|
343
1143
|
|
|
344
|
-
let result = await callMcpTool(mcpTool, args, { saveTo: params.saveTo })
|
|
345
|
-
|
|
346
|
-
// Playwright throws ERR_ABORTED on server-side redirects (e.g. Wikipedia Special:Random).
|
|
347
|
-
// The browser follows the redirect fine — the original navigation just gets "aborted".
|
|
348
|
-
// Recover by taking a snapshot of the page the browser actually landed on.
|
|
1144
|
+
let result = await callMcpTool(mcpTool, args, { saveTo: typeof params.saveTo === 'string' ? params.saveTo : undefined })
|
|
349
1145
|
if (action === 'navigate' && result.includes('ERR_ABORTED')) {
|
|
350
1146
|
await new Promise((r) => setTimeout(r, 1000))
|
|
351
1147
|
result = await callMcpTool('browser_snapshot', {})
|
|
352
1148
|
}
|
|
1149
|
+
if (action === 'navigate') {
|
|
1150
|
+
try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (['navigate', 'back', 'click', 'type', 'select', 'fill_form', 'submit_form', 'press_key', 'scroll_until', 'complete_web_task'].includes(action)) {
|
|
1154
|
+
try { await captureStructuredObservation() } catch { /* ignore */ }
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (action === 'close') {
|
|
1158
|
+
cleanupSessionBrowser(sessionKey)
|
|
1159
|
+
}
|
|
353
1160
|
|
|
354
|
-
if (action === 'navigate') { try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ } }
|
|
355
1161
|
return result
|
|
356
|
-
} catch (err: unknown) {
|
|
1162
|
+
} catch (err: unknown) {
|
|
1163
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1164
|
+
}
|
|
357
1165
|
},
|
|
358
1166
|
{
|
|
359
1167
|
name: 'browser',
|
|
360
|
-
description: 'Control
|
|
1168
|
+
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.',
|
|
361
1169
|
schema: z.object({
|
|
362
|
-
action: z.enum([
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1170
|
+
action: z.enum([
|
|
1171
|
+
'navigate',
|
|
1172
|
+
'back',
|
|
1173
|
+
'close',
|
|
1174
|
+
'screenshot',
|
|
1175
|
+
'snapshot',
|
|
1176
|
+
'click',
|
|
1177
|
+
'hover',
|
|
1178
|
+
'type',
|
|
1179
|
+
'fill_form',
|
|
1180
|
+
'submit_form',
|
|
1181
|
+
'scroll_until',
|
|
1182
|
+
'press_key',
|
|
1183
|
+
'select',
|
|
1184
|
+
'dialog',
|
|
1185
|
+
'evaluate',
|
|
1186
|
+
'run_code',
|
|
1187
|
+
'pdf',
|
|
1188
|
+
'upload',
|
|
1189
|
+
'wait',
|
|
1190
|
+
'tabs',
|
|
1191
|
+
'network',
|
|
1192
|
+
'read_page',
|
|
1193
|
+
'extract_links',
|
|
1194
|
+
'extract_form_fields',
|
|
1195
|
+
'extract_table',
|
|
1196
|
+
'download_file',
|
|
1197
|
+
'complete_web_task',
|
|
1198
|
+
'verify_text',
|
|
1199
|
+
'verify_element',
|
|
1200
|
+
'verify_list',
|
|
1201
|
+
'verify_value',
|
|
1202
|
+
'profile',
|
|
1203
|
+
'reset_profile',
|
|
1204
|
+
]),
|
|
1205
|
+
}).passthrough(),
|
|
367
1206
|
},
|
|
368
1207
|
),
|
|
369
1208
|
)
|