scorchcrawl-mcp 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/copilot-agent.js +619 -0
- package/dist/index.js +1132 -0
- package/dist/lib/fastmcp/index.js +6 -0
- package/dist/lib/scorch-client/index.js +9 -0
- package/dist/local-scraper.js +319 -0
- package/dist/rate-limiter.js +361 -0
- package/dist/response-utils.js +789 -0
- package/dist/scrape-cache.js +34 -0
- package/package.json +39 -9
- package/dist/cli.js +0 -121
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot SDK Agent Engine
|
|
3
|
+
*
|
|
4
|
+
* Replaces the proprietary cloud-only agent with a Copilot SDK-powered
|
|
5
|
+
* agent that uses scorchcrawl tools (scrape, search, map, extract) as custom tools.
|
|
6
|
+
*
|
|
7
|
+
* Model selection is controlled via COPILOT_AGENT_MODELS environment variable.
|
|
8
|
+
*/
|
|
9
|
+
import { CopilotClient } from '@github/copilot-sdk';
|
|
10
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
11
|
+
import { RateLimitGuard, buildRateLimitConfig, buildErrorHook, findStaleJobs, } from './rate-limiter.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Parse COPILOT_AGENT_MODELS env var into an array of model names */
|
|
16
|
+
export function parseAllowedModels() {
|
|
17
|
+
const envModels = process.env.COPILOT_AGENT_MODELS;
|
|
18
|
+
if (!envModels) {
|
|
19
|
+
// Defaults from user requirements
|
|
20
|
+
return ['gpt-4.1', 'gpt-4o', 'gpt-5-mini', 'grok-code-fast-1'];
|
|
21
|
+
}
|
|
22
|
+
return envModels
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((m) => m.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
/** Get default model (first in list or from env) */
|
|
28
|
+
export function getDefaultModel() {
|
|
29
|
+
const defaultFromEnv = process.env.COPILOT_AGENT_DEFAULT_MODEL;
|
|
30
|
+
if (defaultFromEnv)
|
|
31
|
+
return defaultFromEnv.trim();
|
|
32
|
+
const allowed = parseAllowedModels();
|
|
33
|
+
return allowed[0] || 'gpt-4.1';
|
|
34
|
+
}
|
|
35
|
+
/** Build the agent configuration from environment */
|
|
36
|
+
export function buildAgentConfig() {
|
|
37
|
+
const config = {
|
|
38
|
+
allowedModels: parseAllowedModels(),
|
|
39
|
+
defaultModel: getDefaultModel(),
|
|
40
|
+
};
|
|
41
|
+
// Optional BYOK provider
|
|
42
|
+
if (process.env.COPILOT_AGENT_PROVIDER_TYPE && process.env.COPILOT_AGENT_PROVIDER_BASE_URL) {
|
|
43
|
+
config.provider = {
|
|
44
|
+
type: process.env.COPILOT_AGENT_PROVIDER_TYPE,
|
|
45
|
+
baseUrl: process.env.COPILOT_AGENT_PROVIDER_BASE_URL,
|
|
46
|
+
apiKey: process.env.COPILOT_AGENT_PROVIDER_API_KEY,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// ScorchCrawl Tools for Copilot SDK Agent
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Build the custom tools that the Copilot SDK agent will have access to.
|
|
56
|
+
* These wrap the scraping engine client methods so the agent can scrape, search,
|
|
57
|
+
* map, crawl, and extract data from the web.
|
|
58
|
+
*/
|
|
59
|
+
function buildScrapingTools(client, origin) {
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
name: 'web_scrape',
|
|
63
|
+
description: 'Scrape content from a single URL. Returns markdown content by default. ' +
|
|
64
|
+
'Use formats parameter to request JSON extraction with a schema, or other formats like html, screenshot, links. ' +
|
|
65
|
+
'If the result is empty, blocked, or shows anti-bot content, use web_screenshot to visually inspect the page.',
|
|
66
|
+
parameters: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
url: { type: 'string', description: 'The URL to scrape' },
|
|
70
|
+
formats: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
description: 'Output formats: "markdown", "html", "links", or JSON extraction object',
|
|
73
|
+
items: { type: 'string' },
|
|
74
|
+
},
|
|
75
|
+
onlyMainContent: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: 'Extract only the main content, excluding nav/footer/etc',
|
|
78
|
+
},
|
|
79
|
+
waitFor: {
|
|
80
|
+
type: 'number',
|
|
81
|
+
description: 'Wait time in ms for JS-rendered pages (default 0)',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ['url'],
|
|
85
|
+
},
|
|
86
|
+
handler: async (args) => {
|
|
87
|
+
try {
|
|
88
|
+
const a = args;
|
|
89
|
+
const { url, ...opts } = a;
|
|
90
|
+
const res = await client.scrape(String(url), {
|
|
91
|
+
...opts,
|
|
92
|
+
origin,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
textResultForLlm: JSON.stringify(res, null, 2),
|
|
96
|
+
resultType: 'success',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
textResultForLlm: `Scrape failed: ${err.message || err}`,
|
|
102
|
+
resultType: 'failure',
|
|
103
|
+
error: String(err.message || err),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'web_screenshot',
|
|
110
|
+
description: 'Take a screenshot of a web page for visual inspection. ' +
|
|
111
|
+
'Use this when web_scrape returns empty, blocked, or anti-bot content. ' +
|
|
112
|
+
'The screenshot is passed directly to your vision capability so you can analyze what is on the page — ' +
|
|
113
|
+
'cookie popups, CAPTCHAs, anti-bot walls, or the actual rendered content.',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
url: { type: 'string', description: 'The URL to screenshot' },
|
|
118
|
+
fullPage: {
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
description: 'Capture the full scrollable page (default: false, viewport only)',
|
|
121
|
+
},
|
|
122
|
+
waitFor: {
|
|
123
|
+
type: 'number',
|
|
124
|
+
description: 'Wait time in ms for JS rendering before screenshot (default 3000)',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ['url'],
|
|
128
|
+
},
|
|
129
|
+
handler: async (args) => {
|
|
130
|
+
try {
|
|
131
|
+
const a = args;
|
|
132
|
+
const url = String(a.url);
|
|
133
|
+
const fullPage = a.fullPage === true;
|
|
134
|
+
const waitFor = typeof a.waitFor === 'number' ? a.waitFor : 3000;
|
|
135
|
+
const screenshotFormat = {
|
|
136
|
+
type: 'screenshot',
|
|
137
|
+
fullPage,
|
|
138
|
+
};
|
|
139
|
+
const res = await client.scrape(url, {
|
|
140
|
+
formats: [screenshotFormat],
|
|
141
|
+
waitFor,
|
|
142
|
+
origin,
|
|
143
|
+
});
|
|
144
|
+
const resAny = res;
|
|
145
|
+
const screenshot = resAny?.screenshot || resAny?.data?.screenshot;
|
|
146
|
+
const metadata = resAny?.metadata || resAny?.data?.metadata || {};
|
|
147
|
+
if (screenshot) {
|
|
148
|
+
const imgBytes = Buffer.from(screenshot, 'base64').length;
|
|
149
|
+
const description = [
|
|
150
|
+
`Screenshot of: ${url}`,
|
|
151
|
+
`Title: ${metadata.title || 'Unknown'}`,
|
|
152
|
+
`Status: ${metadata.statusCode || 'Unknown'}`,
|
|
153
|
+
`Size: ${Math.round(imgBytes / 1024)}KB`,
|
|
154
|
+
'',
|
|
155
|
+
'Analyze the attached image to determine:',
|
|
156
|
+
'- Is there a cookie consent popup blocking content?',
|
|
157
|
+
'- Is there a CAPTCHA or anti-bot challenge?',
|
|
158
|
+
'- Is the page content actually rendered?',
|
|
159
|
+
'- What text/data is visible?',
|
|
160
|
+
].join('\n');
|
|
161
|
+
return {
|
|
162
|
+
textResultForLlm: description,
|
|
163
|
+
binaryResultsForLlm: [{
|
|
164
|
+
data: screenshot,
|
|
165
|
+
mimeType: 'image/png',
|
|
166
|
+
type: 'image',
|
|
167
|
+
description: `Screenshot of ${url}`,
|
|
168
|
+
}],
|
|
169
|
+
resultType: 'success',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
textResultForLlm: `Screenshot request completed but no image was returned. Page metadata: ${JSON.stringify(metadata, null, 2)}`,
|
|
174
|
+
resultType: 'success',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return {
|
|
179
|
+
textResultForLlm: `Screenshot failed: ${err.message || err}. The page may be unreachable or the screenshot service unavailable.`,
|
|
180
|
+
resultType: 'failure',
|
|
181
|
+
error: String(err.message || err),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'web_search',
|
|
188
|
+
description: 'Search the web for information. Returns search results with titles, URLs, and snippets. ' +
|
|
189
|
+
'Supports search operators like site:, inurl:, intitle:, and quoted phrases.',
|
|
190
|
+
parameters: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
query: { type: 'string', description: 'The search query' },
|
|
194
|
+
limit: {
|
|
195
|
+
type: 'number',
|
|
196
|
+
description: 'Maximum number of results (default 5)',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
required: ['query'],
|
|
200
|
+
},
|
|
201
|
+
handler: async (args) => {
|
|
202
|
+
try {
|
|
203
|
+
const a = args;
|
|
204
|
+
const { query, ...opts } = a;
|
|
205
|
+
const res = await client.search(String(query), {
|
|
206
|
+
...opts,
|
|
207
|
+
origin,
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
textResultForLlm: JSON.stringify(res, null, 2),
|
|
211
|
+
resultType: 'success',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
return {
|
|
216
|
+
textResultForLlm: `Search failed: ${err.message || err}`,
|
|
217
|
+
resultType: 'failure',
|
|
218
|
+
error: String(err.message || err),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'web_map',
|
|
225
|
+
description: 'Map a website to discover all indexed URLs. Use the search parameter to find specific pages. ' +
|
|
226
|
+
'Useful when you need to find the right page before scraping.',
|
|
227
|
+
parameters: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
url: { type: 'string', description: 'The website URL to map' },
|
|
231
|
+
search: {
|
|
232
|
+
type: 'string',
|
|
233
|
+
description: 'Optional search query to filter URLs',
|
|
234
|
+
},
|
|
235
|
+
limit: {
|
|
236
|
+
type: 'number',
|
|
237
|
+
description: 'Maximum number of URLs to return',
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
required: ['url'],
|
|
241
|
+
},
|
|
242
|
+
handler: async (args) => {
|
|
243
|
+
try {
|
|
244
|
+
const a = args;
|
|
245
|
+
const { url, ...opts } = a;
|
|
246
|
+
const res = await client.map(String(url), {
|
|
247
|
+
...opts,
|
|
248
|
+
origin,
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
textResultForLlm: JSON.stringify(res, null, 2),
|
|
252
|
+
resultType: 'success',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return {
|
|
257
|
+
textResultForLlm: `Map failed: ${err.message || err}`,
|
|
258
|
+
resultType: 'failure',
|
|
259
|
+
error: String(err.message || err),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'web_extract',
|
|
266
|
+
description: 'Extract structured information from web pages using LLM capabilities. ' +
|
|
267
|
+
'Provide URLs, a prompt describing what to extract, and an optional JSON schema for the output format.',
|
|
268
|
+
parameters: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: {
|
|
271
|
+
urls: {
|
|
272
|
+
type: 'array',
|
|
273
|
+
items: { type: 'string' },
|
|
274
|
+
description: 'URLs to extract information from',
|
|
275
|
+
},
|
|
276
|
+
prompt: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
description: 'Description of what information to extract',
|
|
279
|
+
},
|
|
280
|
+
schema: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
description: 'JSON schema for structured output',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
required: ['urls'],
|
|
286
|
+
},
|
|
287
|
+
handler: async (args) => {
|
|
288
|
+
try {
|
|
289
|
+
const a = args;
|
|
290
|
+
const res = await client.extract({
|
|
291
|
+
urls: a.urls,
|
|
292
|
+
prompt: a.prompt,
|
|
293
|
+
schema: a.schema,
|
|
294
|
+
origin,
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
textResultForLlm: JSON.stringify(res, null, 2),
|
|
298
|
+
resultType: 'success',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return {
|
|
303
|
+
textResultForLlm: `Extract failed: ${err.message || err}`,
|
|
304
|
+
resultType: 'failure',
|
|
305
|
+
error: String(err.message || err),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Agent Engine
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
/** In-memory store for agent jobs */
|
|
316
|
+
const agentJobs = new Map();
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Rate Limiting & Concurrency Guard (singleton)
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
const rateLimitGuard = new RateLimitGuard(buildRateLimitConfig());
|
|
321
|
+
/** Periodic stale-job reaper */
|
|
322
|
+
setInterval(() => {
|
|
323
|
+
const staleIds = findStaleJobs(agentJobs.values(), rateLimitGuard.config.staleJobTimeoutMs);
|
|
324
|
+
for (const id of staleIds) {
|
|
325
|
+
const job = agentJobs.get(id);
|
|
326
|
+
if (job) {
|
|
327
|
+
job.status = 'failed';
|
|
328
|
+
job.error = `Job timed out after ${rateLimitGuard.config.staleJobTimeoutMs / 1000}s without completing.`;
|
|
329
|
+
job.completedAt = Date.now();
|
|
330
|
+
// Release concurrency slot (use empty-string key for unknown user)
|
|
331
|
+
rateLimitGuard.release(job._userKey || '');
|
|
332
|
+
console.warn(`[RateLimit] Reaped stale job ${id}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}, rateLimitGuard.config.gcIntervalMs);
|
|
336
|
+
/**
|
|
337
|
+
* Cache of CopilotClient instances keyed by GitHub token.
|
|
338
|
+
* The empty-string key holds the server-wide (env-based) client.
|
|
339
|
+
* Entries are evicted after MAX_CLIENT_AGE_MS of inactivity.
|
|
340
|
+
*/
|
|
341
|
+
const clientCache = new Map();
|
|
342
|
+
const MAX_CLIENT_AGE_MS = 30 * 60 * 1000; // 30 min
|
|
343
|
+
/** Periodically purge stale clients */
|
|
344
|
+
setInterval(() => {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
for (const [key, entry] of clientCache) {
|
|
347
|
+
if (now - entry.lastUsed > MAX_CLIENT_AGE_MS) {
|
|
348
|
+
try {
|
|
349
|
+
entry.client.stop();
|
|
350
|
+
}
|
|
351
|
+
catch { /* ignore */ }
|
|
352
|
+
clientCache.delete(key);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}, 5 * 60 * 1000); // every 5 min
|
|
356
|
+
/**
|
|
357
|
+
* Get or create a CopilotClient for the given token.
|
|
358
|
+
* If `userToken` is provided it takes priority; otherwise
|
|
359
|
+
* the server-wide GITHUB_TOKEN env var is used.
|
|
360
|
+
*
|
|
361
|
+
* Exported so the summarization layer in response-utils.ts can
|
|
362
|
+
* reuse the same cached Copilot client instances.
|
|
363
|
+
*/
|
|
364
|
+
export async function getCopilotClient(userToken) {
|
|
365
|
+
const cacheKey = userToken || '';
|
|
366
|
+
const existing = clientCache.get(cacheKey);
|
|
367
|
+
if (existing) {
|
|
368
|
+
existing.lastUsed = Date.now();
|
|
369
|
+
return existing.client;
|
|
370
|
+
}
|
|
371
|
+
const clientOpts = {};
|
|
372
|
+
// Allow configuring Copilot CLI path
|
|
373
|
+
if (process.env.COPILOT_CLI_PATH) {
|
|
374
|
+
clientOpts.cliPath = process.env.COPILOT_CLI_PATH;
|
|
375
|
+
}
|
|
376
|
+
// Allow connecting to external CLI server
|
|
377
|
+
if (process.env.COPILOT_CLI_URL) {
|
|
378
|
+
clientOpts.cliUrl = process.env.COPILOT_CLI_URL;
|
|
379
|
+
}
|
|
380
|
+
// Per-user token takes priority, then env var
|
|
381
|
+
const token = userToken || process.env.GITHUB_TOKEN;
|
|
382
|
+
if (token) {
|
|
383
|
+
clientOpts.githubToken = token;
|
|
384
|
+
}
|
|
385
|
+
const client = new CopilotClient(clientOpts);
|
|
386
|
+
clientCache.set(cacheKey, { client, lastUsed: Date.now() });
|
|
387
|
+
return client;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Start a new agent job and wait for it to complete.
|
|
391
|
+
* Returns the final result (or error) directly — no polling needed.
|
|
392
|
+
*
|
|
393
|
+
* Rate limiting is enforced before the job is accepted:
|
|
394
|
+
* 1. Per-user and global concurrency limits
|
|
395
|
+
* 2. Sliding-window request rate
|
|
396
|
+
* 3. Proactive Copilot quota check
|
|
397
|
+
*/
|
|
398
|
+
export async function startAgent(request, scrapingClient, origin, config, copilotToken) {
|
|
399
|
+
const jobId = uuidv4();
|
|
400
|
+
const userKey = copilotToken || '__server__';
|
|
401
|
+
// --- Rate limit gate ---
|
|
402
|
+
const gate = rateLimitGuard.check(userKey);
|
|
403
|
+
if (!gate.allowed) {
|
|
404
|
+
return {
|
|
405
|
+
id: jobId,
|
|
406
|
+
status: 'rate_limited',
|
|
407
|
+
rateLimited: true,
|
|
408
|
+
retryAfterSeconds: gate.retryAfterSeconds,
|
|
409
|
+
error: gate.reason,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// Validate model
|
|
413
|
+
const requestedModel = request.model || config.defaultModel;
|
|
414
|
+
if (!config.allowedModels.includes(requestedModel)) {
|
|
415
|
+
return {
|
|
416
|
+
id: jobId,
|
|
417
|
+
status: 'failed',
|
|
418
|
+
error: `Model "${requestedModel}" is not in the allowed list: ${config.allowedModels.join(', ')}`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// --- Acquire concurrency slot & record request ---
|
|
422
|
+
rateLimitGuard.acquire(userKey);
|
|
423
|
+
// Create the job entry
|
|
424
|
+
const job = {
|
|
425
|
+
id: jobId,
|
|
426
|
+
status: 'processing',
|
|
427
|
+
prompt: request.prompt,
|
|
428
|
+
createdAt: Date.now(),
|
|
429
|
+
_userKey: userKey,
|
|
430
|
+
};
|
|
431
|
+
agentJobs.set(jobId, job);
|
|
432
|
+
// Run the agent synchronously — wait for completion
|
|
433
|
+
try {
|
|
434
|
+
await runAgentJob(job, request, scrapingClient, origin, config, requestedModel, copilotToken);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
job.status = 'failed';
|
|
438
|
+
job.error = String(err.message || err);
|
|
439
|
+
job.completedAt = Date.now();
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
// Always release the concurrency slot when the job finishes
|
|
443
|
+
rateLimitGuard.release(userKey);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
id: jobId,
|
|
447
|
+
status: job.status,
|
|
448
|
+
data: job.result || undefined,
|
|
449
|
+
error: job.error || undefined,
|
|
450
|
+
duration: job.completedAt
|
|
451
|
+
? `${((job.completedAt - job.createdAt) / 1000).toFixed(1)}s`
|
|
452
|
+
: undefined,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Run an agent job using the Copilot SDK.
|
|
457
|
+
*/
|
|
458
|
+
async function runAgentJob(job, request, scrapingClient, origin, config, model, copilotToken) {
|
|
459
|
+
try {
|
|
460
|
+
job.progress = 'Initializing Copilot SDK agent...';
|
|
461
|
+
const client = await getCopilotClient(copilotToken);
|
|
462
|
+
const tools = buildScrapingTools(scrapingClient, origin);
|
|
463
|
+
// Build session config
|
|
464
|
+
const sessionConfig = {
|
|
465
|
+
model,
|
|
466
|
+
tools,
|
|
467
|
+
// Disable all built-in tools - we only want our scorchcrawl tools
|
|
468
|
+
availableTools: tools.map((t) => t.name),
|
|
469
|
+
systemMessage: {
|
|
470
|
+
mode: 'replace',
|
|
471
|
+
content: buildSystemPrompt(request),
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
// Add BYOK provider if configured
|
|
475
|
+
if (config.provider) {
|
|
476
|
+
sessionConfig.provider = {
|
|
477
|
+
type: config.provider.type,
|
|
478
|
+
baseUrl: config.provider.baseUrl,
|
|
479
|
+
...(config.provider.apiKey && { apiKey: config.provider.apiKey }),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
job.progress = `Creating agent session with model: ${model}`;
|
|
483
|
+
const session = await client.createSession(sessionConfig);
|
|
484
|
+
// Register error hook for intelligent retry/abort decisions
|
|
485
|
+
try {
|
|
486
|
+
const errorHook = buildErrorHook(job.id);
|
|
487
|
+
if (typeof session.registerHooks === 'function') {
|
|
488
|
+
session.registerHooks({ onErrorOccurred: errorHook });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// Hooks may not be supported in all SDK versions — non-fatal
|
|
493
|
+
}
|
|
494
|
+
// Listen for quota snapshots from assistant.usage events
|
|
495
|
+
try {
|
|
496
|
+
const userKey = copilotToken || '__server__';
|
|
497
|
+
session.on?.('assistant.usage', (event) => {
|
|
498
|
+
const snapshots = event?.data?.quotaSnapshots || event?.quotaSnapshots;
|
|
499
|
+
if (snapshots) {
|
|
500
|
+
// Take the first available quota category (usually 'chat')
|
|
501
|
+
const snap = snapshots.chat || Object.values(snapshots)[0];
|
|
502
|
+
if (snap) {
|
|
503
|
+
rateLimitGuard.quota.update(userKey, {
|
|
504
|
+
remainingPercent: snap.remainingPercentage ?? snap.percent_remaining ?? 100,
|
|
505
|
+
usedRequests: snap.usedRequests ?? snap.remaining ?? 0,
|
|
506
|
+
entitlementRequests: snap.entitlementRequests ?? snap.entitlement ?? -1,
|
|
507
|
+
isUnlimited: snap.isUnlimitedEntitlement ?? snap.unlimited ?? false,
|
|
508
|
+
resetDate: snap.resetDate ?? undefined,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Event subscription may not be supported — non-fatal
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
job.progress = 'Agent is researching...';
|
|
519
|
+
// Build the user prompt
|
|
520
|
+
let userPrompt = request.prompt;
|
|
521
|
+
if (request.urls && request.urls.length > 0) {
|
|
522
|
+
userPrompt += `\n\nFocus on these URLs:\n${request.urls.map((u) => `- ${u}`).join('\n')}`;
|
|
523
|
+
}
|
|
524
|
+
if (request.schema) {
|
|
525
|
+
userPrompt += `\n\nReturn the results in this JSON schema format:\n${JSON.stringify(request.schema, null, 2)}`;
|
|
526
|
+
}
|
|
527
|
+
// Determine timeout: request-level > env var > SDK default (60s)
|
|
528
|
+
const timeoutMs = request.timeout
|
|
529
|
+
|| (process.env.AGENT_TIMEOUT_MS ? parseInt(process.env.AGENT_TIMEOUT_MS, 10) : undefined)
|
|
530
|
+
|| undefined; // undefined lets SDK use its own 60s default
|
|
531
|
+
// Send the prompt and wait for completion
|
|
532
|
+
const response = await session.sendAndWait({ prompt: userPrompt }, timeoutMs);
|
|
533
|
+
job.status = 'completed';
|
|
534
|
+
job.completedAt = Date.now();
|
|
535
|
+
job.result = {
|
|
536
|
+
success: true,
|
|
537
|
+
data: response?.data?.content || 'No response generated',
|
|
538
|
+
model,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
// Clean up the session
|
|
543
|
+
try {
|
|
544
|
+
await session.destroy();
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// Ignore cleanup errors
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
job.status = 'failed';
|
|
553
|
+
job.error = `Agent error: ${err.message || err}`;
|
|
554
|
+
job.completedAt = Date.now();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Build the system prompt for the agent session.
|
|
559
|
+
*/
|
|
560
|
+
function buildSystemPrompt(request) {
|
|
561
|
+
return `You are an autonomous web research agent. Your job is to browse the internet, search for information, navigate through pages, and extract structured data based on the user's query.
|
|
562
|
+
|
|
563
|
+
You have access to the following web tools:
|
|
564
|
+
- **web_search**: Search the web for information. Start with this for broad queries.
|
|
565
|
+
- **web_scrape**: Scrape content from a specific URL. Use this to get page content.
|
|
566
|
+
- **web_screenshot**: Take a screenshot of a page. The image is sent directly to you for vision analysis. Use this when scrape returns empty/blocked content.
|
|
567
|
+
- **web_map**: Map a website to discover all its URLs. Use this to find specific pages on a site.
|
|
568
|
+
- **web_extract**: Extract structured data from URLs using LLM. Use this for structured extraction.
|
|
569
|
+
|
|
570
|
+
## Research Strategy
|
|
571
|
+
|
|
572
|
+
1. **Start with search** to find relevant URLs if no specific URLs are provided.
|
|
573
|
+
2. **Map websites** to discover relevant pages when you need to explore a site's structure.
|
|
574
|
+
3. **Scrape specific pages** to get their content.
|
|
575
|
+
4. **If scrape fails or returns empty/blocked content**, use **web_screenshot** to visually inspect the page and determine what's blocking (cookie popup, CAPTCHA, anti-bot, JS rendering issue).
|
|
576
|
+
5. **Extract structured data** when you need specific fields from pages.
|
|
577
|
+
|
|
578
|
+
## Visual Inspection Fallback
|
|
579
|
+
|
|
580
|
+
When web_scrape returns any of these, ALWAYS follow up with web_screenshot:
|
|
581
|
+
- Empty or very short content (< 100 characters)
|
|
582
|
+
- Anti-bot messages ("Checking your browser", "Access denied", "Just a moment")
|
|
583
|
+
- Cookie consent popup HTML instead of actual content
|
|
584
|
+
- CAPTCHA challenges
|
|
585
|
+
- Generic error pages
|
|
586
|
+
|
|
587
|
+
The screenshot helps you understand what the user would see in a browser, and report back what's actually on the page even when text extraction fails.
|
|
588
|
+
|
|
589
|
+
## Guidelines
|
|
590
|
+
|
|
591
|
+
- Be thorough: check multiple sources when possible.
|
|
592
|
+
- Be efficient: don't scrape pages that aren't relevant.
|
|
593
|
+
- If a page fails to load or returns empty content, try an alternative approach (screenshot, different URL, waitFor parameter).
|
|
594
|
+
- Always provide your final answer with all the information you gathered.
|
|
595
|
+
- If the user provided a JSON schema, format your final response to match that schema.
|
|
596
|
+
- Cite your sources with URLs when providing information.
|
|
597
|
+
- When reporting screenshot results, describe what you observed on the page.
|
|
598
|
+
|
|
599
|
+
## Important
|
|
600
|
+
|
|
601
|
+
- You MUST use the provided tools to gather information. Do not make up or hallucinate data.
|
|
602
|
+
- If you cannot find the requested information after reasonable effort, say so clearly.
|
|
603
|
+
- Provide complete, well-structured responses.`;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Gracefully shut down the Copilot client.
|
|
607
|
+
*/
|
|
608
|
+
export async function shutdownAgent() {
|
|
609
|
+
rateLimitGuard.shutdown();
|
|
610
|
+
for (const [, entry] of clientCache) {
|
|
611
|
+
try {
|
|
612
|
+
await entry.client.stop();
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// Ignore shutdown errors
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
clientCache.clear();
|
|
619
|
+
}
|