crawlforge-mcp-server 6.8.0 → 6.9.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/CLAUDE.md CHANGED
@@ -234,7 +234,8 @@ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the es
234
234
  # passed on the call wins). CrawlForge supplies no proxies, and
235
235
  # a datacenter one does not defeat these detectors. Distinct
236
236
  # from the PROXY_ROTATION_* family, which belongs to
237
- # localization. The agent tool does not browse yet (Phase 3).
237
+ # localization. The agent's automatic stealth retry
238
+ # (stealth review Phase 3) reads it too.
238
239
  ```
239
240
 
240
241
  Camoufox is an **optional** dependency and its transitive `language-tags@2.1.0` requires Node ≥22. npm silently drops an optional subtree that fails an engine check, so on Node 20 it is simply absent — `'auto'` then resolves to Chromium and says so in `fallbackWarning`. `engines.node` stays at `>=20.16.0` deliberately; bumping it is breaking and is a release decision.
package/README.md CHANGED
@@ -206,7 +206,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
206
206
  | `scrape_with_actions` | 5 | Browser automation chains |
207
207
  | `generate_llms_txt` | 5 | Generate AI interaction guidelines |
208
208
  | `stealth_mode` | 5 | Anti-detection browser management |
209
- | `agent` | 8 | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM) |
209
+ | `agent` | 8 (+5 per stealth retry that gets the page, max 2) | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM). A page that walls the plain fetch is retried in the stealth browser automatically — URLs you name first — and that evidence is marked `via: "stealth"`; each retry that runs adds 5 |
210
210
  | `deep_research` | 10 | Multi-stage research with source verification |
211
211
 
212
212
  Ten tools (`scrape`, `fetch_url`, `extract_content`, `crawl_deep`, `batch_scrape`, `stealth_mode`, `scrape_with_actions`, `process_document`, `deep_research`, `extract_embedded_state`) accept `max_inline_chars` (default 40,000; env `CRAWLFORGE_MAX_INLINE_CHARS`): a result over it comes back as a `preview` plus a `result_handle` for `read_result`, with the full result kept for 1 hour under `~/.crawlforge/results/` on your own machine — nothing is uploaded.
@@ -311,7 +311,7 @@ On `browser_session` and `scrape_with_actions` the engine applies only with `ste
311
311
 
312
312
  `auto` is not free. Measured once each on an Apple Silicon Mac on 2026-09-21 (launch plus a context and a blank page): Chromium 148 ms and 253 MB, Camoufox 946 ms and 667 MB — roughly **+0.8 s and +400 MB per stealth call**. Pin `engine: "playwright"` for high-volume work on sites that do not block.
313
313
 
314
- **CrawlForge supplies no proxies**, and a datacenter proxy does not fix a block: Cloudflare scores the IP's ASN and the TLS/HTTP2 handshake before any JavaScript runs, so no browser-side patch compensates for a datacenter address. Bring your own residential exit with `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs), which the escalation stage, `stealth_mode`, `browser_session`, `scrape_with_actions` and the `deep_research` retry use when the caller passes none; a proxy passed on the call always wins. (The `agent` tool does not browse yet, so it is not in that list — it joins when Phase 3 gives it an escalation path.) With a proxy, Camoufox derives its timezone, locale and geolocation from the exit IP.
314
+ **CrawlForge supplies no proxies**, and a datacenter proxy does not fix a block: Cloudflare scores the IP's ASN and the TLS/HTTP2 handshake before any JavaScript runs, so no browser-side patch compensates for a datacenter address. Bring your own residential exit with `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs), which the escalation stage, `stealth_mode`, `browser_session`, `scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic stealth retry use when the caller passes none; a proxy passed on the call always wins. With a proxy, Camoufox derives its timezone, locale and geolocation from the exit IP.
315
315
 
316
316
  Full detail, measurements and sources: [docs/stealth-engines.md](docs/stealth-engines.md).
317
317
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.8.0",
3
+ "version": "6.9.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -157,6 +157,6 @@
157
157
  "@hono/node-server": "2.0.12",
158
158
  "hono": "^4.12.4",
159
159
  "dompurify": "^3.4.0",
160
- "adm-zip": "^0.6.0"
160
+ "adm-zip": "^0.6.1"
161
161
  }
162
162
  }
package/server.js CHANGED
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
108
108
  // Create the server
109
109
  const server = new McpServer({
110
110
  name: "crawlforge",
111
- version: "6.8.0",
111
+ version: "6.9.0",
112
112
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
113
113
  homepage: "https://www.crawlforge.dev",
114
114
  icon: "https://www.crawlforge.dev/icon.png",
@@ -243,32 +243,35 @@ const deepResearchTool = new DeepResearchTool();
243
243
  const trackChangesTool = new TrackChangesTool();
244
244
  const generateLLMsTxtTool = new GenerateLLMsTxtTool();
245
245
  const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
246
+ // Phase 3: the escalation stage for `scrape`'s escalate:true and the agent's
247
+ // automatic stealth retry (stealth review Phase 3) — one function, so both
248
+ // go through the same gate, engine resolver and server-level proxy list.
249
+ // Injected so the tool modules never import StealthBrowserManager (that would pull a
250
+ // browser dependency into every unit test that loads `scrape`). Same gate
251
+ // and same browser the stealth_mode tool drives — no new evasion, and the
252
+ // engine name is resolved here, beside its sibling, by the one resolver
253
+ // every stealth entry point shares.
254
+ const stealthEscalation = async ({ url, engine, respectRobots }) => {
255
+ const warnings = await stealthComplianceGate(url, respectRobots);
256
+ // "auto" prefers camoufox and falls back to chromium when its binary is
257
+ // missing. Mapping here instead ("camoufox" or else chromium) collapsed
258
+ // auto to chromium and hid the fallback; the resolver says which engine it
259
+ // landed on and why, and the caller is told.
260
+ const resolved = await resolveStealthEngine(engine);
261
+ if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
262
+ const scraped = await stealthBrowserManager.scrapeWithStealth({
263
+ url,
264
+ engine: resolved.engine
265
+ });
266
+ // The RESOLVED engine: `scrape` reports it as stealth.engine, where the
267
+ // requested "auto" would tell the caller nothing about what ran.
268
+ return { ...scraped, engine: resolved.engine, warnings };
269
+ };
246
270
  const unifiedScrapeTool = new UnifiedScrapeTool({
247
271
  actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
248
- // Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
249
- // the tool module never imports StealthBrowserManager (that would pull a
250
- // browser dependency into every unit test that loads `scrape`). Same gate
251
- // and same browser the stealth_mode tool drives — no new evasion, and the
252
- // engine name is resolved here, beside its sibling, by the one resolver
253
- // every stealth entry point shares.
254
- escalateScrape: async ({ url, engine, respectRobots }) => {
255
- const warnings = await stealthComplianceGate(url, respectRobots);
256
- // "auto" prefers camoufox and falls back to chromium when its binary is
257
- // missing. Mapping here instead ("camoufox" or else chromium) collapsed
258
- // auto to chromium and hid the fallback; the resolver says which engine it
259
- // landed on and why, and the caller is told.
260
- const resolved = await resolveStealthEngine(engine);
261
- if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
262
- const scraped = await stealthBrowserManager.scrapeWithStealth({
263
- url,
264
- engine: resolved.engine
265
- });
266
- // The RESOLVED engine: `scrape` reports it as stealth.engine, where the
267
- // requested "auto" would tell the caller nothing about what ran.
268
- return { ...scraped, engine: resolved.engine, warnings };
269
- }
272
+ escalateScrape: stealthEscalation
270
273
  });
271
- const agentTool = new AgentTool(); // D4 D2
274
+ const agentTool = new AgentTool({ escalateFetch: stealthEscalation }); // D4 D2 + stealth review Phase 3
272
275
  const stealthBrowserManager = new StealthBrowserManager();
273
276
  const localizationManager = new LocalizationManager();
274
277
 
@@ -1212,7 +1215,7 @@ registerToolIfEnabled("scrape", {
1212
1215
 
1213
1216
  // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract)
1214
1217
  registerToolIfEnabled("agent", {
1215
- description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Cost: 8 credits, scales with maxUrls. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1218
+ description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Pages that block a plain fetch are retried in the stealth browser automatically (at most 2 a run, URLs you name first; evidence marked via:\"stealth\"). Cost: 18 credits at most - 8, plus 5 per stealth retry that gets the page; a retry that is blocked again is free. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1216
1219
  annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1217
1220
  inputSchema: {
1218
1221
  prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
@@ -4,7 +4,8 @@
4
4
  * Design: hardcoded 3-action state machine.
5
5
  * PLAN — one SamplingClient call to decompose prompt into search queries
6
6
  * GATHER — search_web (≤maxUrls results total)
7
- * ACT — fetchAndParse + relevance gate per URL
7
+ * ACT — fetchAndParse + relevance gate per URL; a walled page is retried
8
+ * once in the stealth browser (capped, see tools/agent/escalation.js)
8
9
  * DECIDE — loop or answer (step/URL/time hard stops; never LLM-trusted)
9
10
  * SHAPE — schema→ExtractWithLlm prose→synthesis via SamplingClient
10
11
  *
@@ -20,10 +21,21 @@
20
21
  import { fetchAndParse } from '../tools/extract/_fetchAndParse.js';
21
22
  import { SamplingClient } from './SamplingClient.js';
22
23
  import { fenceUntrusted } from '../utils/untrustedContent.js';
24
+ import { stealthDocumentVerdict as documentVerdict } from '../utils/stealthVerdict.js';
25
+ import { aBrowserMightPass } from '../tools/scrape/escalation.js';
26
+ import { AGENT_MAX_ESCALATIONS } from '../tools/agent/escalation.js';
23
27
 
24
28
  const DEFAULT_WALL_CLOCK_MS = 120_000;
25
29
  const DEFAULT_MAX_STEPS = 5;
26
30
  const DEFAULT_MAX_URLS = 10;
31
+ // A stealth retry launches a browser and waits out a challenge; one started
32
+ // with less than this left on the wall clock would overrun the hard stop.
33
+ const ESCALATION_MIN_REMAINING_MS = 20_000;
34
+
35
+ /** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
36
+ function isRefusal(err) {
37
+ return err?.code === 'ROBOTS_DISALLOWED' || err?.code === 'HOST_BLOCKED';
38
+ }
27
39
 
28
40
  // ── Helpers ───────────────────────────────────────────────────────────────────
29
41
 
@@ -168,6 +180,11 @@ export class AgentOrchestrator {
168
180
  * @param {object|null} options.mcpServer - McpServer instance (for SamplingClient)
169
181
  * @param {object} options.searchConfig - passed to SearchWebTool constructor
170
182
  * @param {object} options.llmConfig - passed to ExtractWithLlm constructor
183
+ * @param {Function} [options.escalateFetch] - the stealth stage, injected from
184
+ * server.js (same function `scrape`'s escalate:true uses):
185
+ * ({ url, engine, respectRobots }) => { html, url, title, text, status, engine, warnings? }
186
+ * Absent in unit tests and builds without a browser: ACT then keeps the
187
+ * plain-fetch-or-snippet behaviour.
171
188
  */
172
189
  constructor(options = {}) {
173
190
  this._mcpServer = options.mcpServer || null;
@@ -177,6 +194,7 @@ export class AgentOrchestrator {
177
194
  this._searchTool = null;
178
195
  this._extractWithLlm = null;
179
196
  this._researchOrchestrator = null;
197
+ this._escalateFetch = options.escalateFetch || null;
180
198
  }
181
199
 
182
200
  /** Set MCP server (called by agent.js after construction). */
@@ -237,6 +255,9 @@ export class AgentOrchestrator {
237
255
  * @param {number} [params.maxSteps] - Max ACT iterations (≤10)
238
256
  * @param {number} [params.maxUrls] - Max URLs to fetch (≤20)
239
257
  * @param {number} [params.wallClockMs] - Wall-clock budget in ms
258
+ * @param {object} [params.usage] - out-param: `usage.escalations` counts
259
+ * stealth retries that ran (the cap), `usage.charged` those that got the
260
+ * page (the bill), so the tool can bill them even if run() throws
240
261
  * @returns {Promise<object>}
241
262
  */
242
263
  async run(params) {
@@ -247,8 +268,11 @@ export class AgentOrchestrator {
247
268
  model = 'default',
248
269
  maxSteps = DEFAULT_MAX_STEPS,
249
270
  maxUrls = DEFAULT_MAX_URLS,
250
- wallClockMs = DEFAULT_WALL_CLOCK_MS
271
+ wallClockMs = DEFAULT_WALL_CLOCK_MS,
272
+ usage = {}
251
273
  } = params;
274
+ usage.escalations = 0;
275
+ usage.charged = 0;
252
276
 
253
277
  const startTime = Date.now();
254
278
  const deadline = () => (Date.now() - startTime) >= wallClockMs;
@@ -365,6 +389,10 @@ export class AgentOrchestrator {
365
389
  if (!urlQueue.includes(url)) urlQueue.push(url);
366
390
  if (!priorityUrls.includes(url)) priorityUrls.push(url);
367
391
  }
392
+ // The URLs the caller named — seeds and sites written into the prompt.
393
+ // Snapshotted before GATHER adds a voted live root to priorityUrls: that
394
+ // root is the agent's own discovery, not the caller's.
395
+ const namedUrls = new Set(priorityUrls);
368
396
  const searchResults = [];
369
397
  /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
370
398
  const excerpts = new Map();
@@ -441,32 +469,93 @@ export class AgentOrchestrator {
441
469
  const evidence = [];
442
470
  let urlsFetched = 0;
443
471
  let step = 0;
472
+ const warnings = [];
444
473
 
445
474
  for (const url of urlQueue) {
446
475
  if (urlsFetched >= capUrls || step >= capSteps || deadline()) break;
447
476
  urlsFetched++;
448
477
 
478
+ // The plain fetch reads an error document instead of throwing on it, so
479
+ // a wall served as a 403 can be told apart from a 404 (the same
480
+ // arrangement `scrape` has).
481
+ let plain = null;
482
+ let plainError = null;
483
+ let verdict = null;
449
484
  try {
450
- const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
451
- if (!isRelevant(textContent, prompt)) continue;
452
- step++;
453
- const sr = searchResults.find(s => s.url === url);
454
- evidence.push({
455
- url: finalUrl,
456
- title: sr ? sr.title : '',
457
- text: truncate(textContent),
458
- step
459
- });
460
- } catch {
461
- // The page is out of reach (challenge page, 403, timeout) but the
462
- // search engine's excerpt of it is not: npmjs.com's "Latest version:
463
- // 15.0.0" sat in the snippet while every fetch of the package page was
464
- // challenged, and the run ended with no evidence at all (R17,
465
- // 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
466
- const excerpt = excerpts.get(url);
467
- if (excerpt && isRelevant(excerpt.text, prompt)) {
468
- evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
485
+ plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
486
+ verdict = documentVerdict(
487
+ { url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
488
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
489
+ );
490
+ } catch (err) {
491
+ plainError = err;
492
+ }
493
+
494
+ const ok2xx = plain && plain.status >= 200 && plain.status < 300;
495
+ if (ok2xx && !verdict.blocked) {
496
+ if (isRelevant(plain.textContent, prompt)) {
497
+ step++;
498
+ const sr = searchResults.find(s => s.url === url);
499
+ evidence.push({
500
+ url: plain.finalUrl,
501
+ title: sr ? sr.title : '',
502
+ text: truncate(plain.textContent),
503
+ step
504
+ });
505
+ continue;
469
506
  }
507
+ // A real page that is simply off-topic: nothing to retry.
508
+ if (verdict.success) continue;
509
+ }
510
+
511
+ // The page is out of reach: a wall, a 403/429, an empty shell, a
512
+ // timeout. A search excerpt may still carry the fact (npmjs.com's
513
+ // "Latest version: 15.0.0" sat in the snippet while every fetch of the
514
+ // package page was challenged, R17 2026-09-04).
515
+ const excerpt = excerpts.get(url);
516
+ const relevantExcerpt = excerpt && isRelevant(excerpt.text, prompt) ? excerpt : null;
517
+
518
+ // Stealth retry (review Phase 3). A URL the caller named always gets it
519
+ // first; a discovered one only when there is no relevant excerpt to fall
520
+ // back on. Never for a refusal, a 404 or a 5xx; bounded by its own cap
521
+ // and by the time left before the wall-clock stop.
522
+ const mightPass = plainError ? !isRefusal(plainError) : aBrowserMightPass(verdict, plain.status);
523
+ const canEscalate = this._escalateFetch &&
524
+ usage.escalations < Math.min(AGENT_MAX_ESCALATIONS, capUrls) &&
525
+ (wallClockMs - (Date.now() - startTime)) >= ESCALATION_MIN_REMAINING_MS;
526
+ if (mightPass && canEscalate && (namedUrls.has(url) || !relevantExcerpt)) {
527
+ usage.escalations++;
528
+ try {
529
+ const stealth = await this._escalateFetch({ url: plain?.finalUrl || url, engine: 'auto' });
530
+ if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
531
+ const text = (stealth.text || '').trim();
532
+ const rendered = documentVerdict(
533
+ { url: stealth.url || url, title: stealth.title || '', text, html: stealth.html || '', status: stealth.status ?? null },
534
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
535
+ );
536
+ // Billed only when the browser got past the wall: a retry that met
537
+ // the wall again, or threw, costs nothing (owner decision).
538
+ if (rendered.success) usage.charged++;
539
+ if (rendered.success && isRelevant(text, prompt)) {
540
+ step++;
541
+ const sr = searchResults.find(s => s.url === url);
542
+ evidence.push({
543
+ url: stealth.url || url,
544
+ title: stealth.title || (sr ? sr.title : ''),
545
+ text: truncate(text),
546
+ via: 'stealth',
547
+ step
548
+ });
549
+ continue;
550
+ }
551
+ warnings.push(`stealth retry of ${url} ${rendered.success ? 'returned a page unrelated to the task' : `did not get the page either${rendered.blocked?.vendor ? ` (${rendered.blocked.vendor})` : ''}`}`);
552
+ } catch (err) {
553
+ warnings.push(`stealth retry of ${url} failed: ${err.message}`);
554
+ }
555
+ }
556
+
557
+ if (relevantExcerpt) {
558
+ evidence.push({ url, title: relevantExcerpt.title, text: relevantExcerpt.text, snippet: true, step });
470
559
  }
471
560
  }
472
561
 
@@ -501,7 +590,10 @@ export class AgentOrchestrator {
501
590
  evidence: [],
502
591
  answer: null,
503
592
  steps: step,
504
- urls_fetched: urlsFetched
593
+ urls_fetched: urlsFetched,
594
+ stealth_retries: usage.escalations,
595
+ stealth_retries_charged: usage.charged,
596
+ ...(warnings.length ? { warnings } : {})
505
597
  };
506
598
  }
507
599
 
@@ -520,11 +612,14 @@ export class AgentOrchestrator {
520
612
  answer: result.success ? result.data : null,
521
613
  structured: true,
522
614
  search_results: searchResults,
523
- evidence: evidence.map(e => ({ url: e.url })),
615
+ evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
524
616
  degraded: !result.success,
525
617
  reason: result.success ? undefined : result.error,
526
618
  steps: step,
527
- urls_fetched: urlsFetched
619
+ urls_fetched: urlsFetched,
620
+ stealth_retries: usage.escalations,
621
+ stealth_retries_charged: usage.charged,
622
+ ...(warnings.length ? { warnings } : {})
528
623
  };
529
624
  } catch (err) {
530
625
  // Fall through to prose synthesis
@@ -648,11 +743,14 @@ export class AgentOrchestrator {
648
743
  success: true,
649
744
  answer,
650
745
  search_results: searchResults,
651
- evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
746
+ evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : e.via ? { url: e.url, via: e.via } : { url: e.url })),
652
747
  degraded,
653
748
  reason: degradedReason,
654
749
  steps: step,
655
750
  urls_fetched: urlsFetched,
751
+ stealth_retries: usage.escalations,
752
+ stealth_retries_charged: usage.charged,
753
+ ...(warnings.length ? { warnings } : {}),
656
754
  provenance: {
657
755
  checked: !synthesisFailed,
658
756
  unverified,
@@ -14,6 +14,7 @@ import { logger } from '../utils/Logger.js';
14
14
  import { maskSecrets } from '../utils/secretMask.js';
15
15
  import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
16
16
  import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
17
+ import { agentEscalationSurcharge } from '../tools/agent/escalation.js';
17
18
  import { searchQueryCount } from '../tools/search/batchSearch.js';
18
19
  import { redactionSurcharge, REDACT_PII_MODEL_CREDITS } from '../server/redaction.js';
19
20
  // Stamped on every usage report so support can tell which client version
@@ -686,6 +687,14 @@ class AuthManager {
686
687
  return costs.search_web * searchQueryCount(params?.queries) + redaction;
687
688
  }
688
689
 
690
+ // agent retries a walled page in the stealth browser automatically, at
691
+ // most AGENT_MAX_ESCALATIONS times a run, 5 each. The projection is that
692
+ // ceiling; the tool reports what actually ran (setActualCost), so a run
693
+ // with no retry still costs 8. The rule lives in tools/agent/escalation.js.
694
+ if (tool === 'agent') {
695
+ return costs.agent + agentEscalationSurcharge(params) + redaction;
696
+ }
697
+
689
698
  return (costs[tool] ?? 1) + redaction;
690
699
  }
691
700
 
@@ -761,7 +770,7 @@ class AuthManager {
761
770
  const agentUrls = params?.maxUrls || 10;
762
771
  const isPro = params?.model === 'pro';
763
772
  projected = Math.max(base, base + Math.ceil(agentUrls / 5) + (isPro ? 5 : 0));
764
- note = `Lower-bound estimate. Scales with maxUrls (${agentUrls}).${isPro ? ' pro model adds deep-research cost.' : ''} External LLM billed separately.`;
773
+ note = `Lower-bound estimate. Scales with maxUrls (${agentUrls}).${isPro ? ' pro model adds deep-research cost.' : ' Includes the ceiling for automatic stealth retries of walled pages (5 each, at most 2 a run); the actual charge drops by 5 for each retry that did not run or did not get the page.'} External LLM billed separately.`;
765
774
  break;
766
775
  }
767
776
  default:
@@ -31,7 +31,7 @@ skill (`scrape` / `extract_content`) instead.
31
31
  |------|------|------|
32
32
  | A ranked list of result URLs + snippets | `search_web` | 5 |
33
33
  | Reddit posts, comments, or a full thread | `reddit_search` | 5 |
34
- | A direct answer, agent decides what to read | `agent` | 8 (scales) |
34
+ | A direct answer, agent decides what to read | `agent` | 8 (+5 per automatic stealth retry, max 2) |
35
35
  | A synthesized, multi-source, cited report | `deep_research` | 10+ (scales) |
36
36
 
37
37
  A common pipeline: `search_web` to find sources → `batch_scrape` (see
@@ -93,8 +93,8 @@ scored. Route through your own residential proxy — CrawlForge supplies none.
93
93
 
94
94
  Server-wide, set `CRAWLFORGE_STEALTH_PROXIES` (comma-separated proxy URLs): the
95
95
  `scrape` escalation stage, `stealth_mode`, `browser_session`,
96
- `scrape_with_actions` and the `deep_research` retry use it when no proxy is
97
- passed on the call. (`agent` does not browse yet, so it is not a reader.) Per call, pass
96
+ `scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic
97
+ stealth retry use it when no proxy is passed on the call. Per call, pass
98
98
  `stealthConfig.proxyRotation`, which always wins:
99
99
 
100
100
  ```json
@@ -69,8 +69,8 @@ them default to `auto`.
69
69
  - `proxyRotation { enabled, proxies[], rotationInterval }` — your own proxies,
70
70
  per call. Server-wide, `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs of
71
71
  the same form) covers the paths with no caller to ask: the `scrape` escalation
72
- stage, `stealth_mode`, `browser_session`, `scrape_with_actions` and the
73
- `deep_research` retry — not `agent`, which has no browsing path yet. A call's
72
+ stage, `stealth_mode`, `browser_session`, `scrape_with_actions`, the
73
+ `deep_research` retry and the `agent` tool's automatic stealth retry. A call's
74
74
  own `proxyRotation` always wins. CrawlForge supplies no proxies, and a datacenter
75
75
  proxy does not help — Cloudflare scores the ASN before it serves a challenge.
76
76
  Unrelated to the `PROXY_ROTATION_*` variables, which belong to `localization`.
@@ -9,6 +9,11 @@ import { z } from 'zod';
9
9
  import { AgentOrchestrator } from '../../core/AgentOrchestrator.js';
10
10
  import { ElicitationHelper } from '../../core/ElicitationHelper.js';
11
11
  import { getToolConfig } from '../../constants/config.js';
12
+ import { setActualCost } from '../../server/requestContext.js';
13
+ import { AGENT_ESCALATION_CREDITS } from './escalation.js';
14
+
15
+ /** The agent's published price before any stealth retry. */
16
+ export const AGENT_BASE_CREDITS = 8;
12
17
 
13
18
  export const AgentInputSchema = z.object({
14
19
  prompt: z.string().min(1).max(2000).describe('Natural-language task or question'),
@@ -24,7 +29,10 @@ export class AgentTool {
24
29
  this._orchestrator = new AgentOrchestrator({
25
30
  mcpServer: null,
26
31
  searchConfig: getToolConfig('search_web') || {},
27
- llmConfig: options.llmConfig || {}
32
+ llmConfig: options.llmConfig || {},
33
+ // The stealth stage, injected from server.js so this module never
34
+ // imports a browser (see AgentOrchestrator's constructor).
35
+ escalateFetch: options.escalateFetch || null
28
36
  });
29
37
  this._elicitation = new ElicitationHelper({});
30
38
  }
@@ -58,14 +66,26 @@ export class AgentTool {
58
66
  }
59
67
  }
60
68
 
61
- return this._orchestrator.run({
62
- prompt: validated.prompt,
63
- urls: validated.urls,
64
- schema: validated.schema,
65
- model: validated.model,
66
- maxSteps: validated.maxSteps,
67
- maxUrls: validated.maxUrls
68
- });
69
+ // Priced like `scrape`'s escalation: getToolCost projects the ceiling
70
+ // (base + every retry the cap allows), and the charge is lowered to the
71
+ // retries that got the page — including when run() throws. A retry that
72
+ // met the wall again is free.
73
+ const usage = { escalations: 0, charged: 0 };
74
+ try {
75
+ return await this._orchestrator.run({
76
+ prompt: validated.prompt,
77
+ urls: validated.urls,
78
+ schema: validated.schema,
79
+ model: validated.model,
80
+ maxSteps: validated.maxSteps,
81
+ maxUrls: validated.maxUrls,
82
+ usage
83
+ });
84
+ } finally {
85
+ if (validated.model !== 'pro') {
86
+ setActualCost(AGENT_BASE_CREDITS + AGENT_ESCALATION_CREDITS * usage.charged);
87
+ }
88
+ }
69
89
  }
70
90
 
71
91
  async destroy() {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * escalation.js — the one declaration of the `agent` tool's automatic stealth
3
+ * retry (stealth review Phase 3, decision 4: automatic, capped).
4
+ *
5
+ * When ACT's plain fetch comes back walled (challenge page, 403/429, empty
6
+ * shell, timeout), the agent retries the URL once through the same stealth
7
+ * stage `scrape`'s escalate:true uses. It is not opt-in, so it is capped per
8
+ * run and priced per retry that gets the page; one that meets the wall again
9
+ * is free.
10
+ *
11
+ * The cap and the price live here so the orchestrator, the tool wrapper and
12
+ * `AuthManager.getToolCost` read one declaration — the arrangement
13
+ * `src/tools/scrape/escalation.js` has for `scrape`. Imports nothing, so
14
+ * AuthManager can read it without pulling in the orchestrator.
15
+ */
16
+
17
+ /** What one stealth retry adds to the agent's price: the stealth browser's own. */
18
+ export const AGENT_ESCALATION_CREDITS = 5;
19
+
20
+ /**
21
+ * Most stealth retries one run may make. A second, independent bound on top
22
+ * of maxSteps/maxUrls/wall-clock: each retry is a browser launch (seconds,
23
+ * and hundreds of MB) and 5 credits, and the projection a caller is checked
24
+ * against has to be a finite ceiling.
25
+ */
26
+ export const AGENT_MAX_ESCALATIONS = 2;
27
+
28
+ /**
29
+ * The most escalation can add to one call — the ceiling getToolCost projects.
30
+ * Reads the RAW params (getToolCost runs before validation). The pro model
31
+ * delegates to ResearchOrchestrator and never runs this stage, so it adds 0.
32
+ *
33
+ * @param {object|undefined} params
34
+ * @returns {number}
35
+ */
36
+ export function agentEscalationSurcharge(params) {
37
+ if (params?.model === 'pro') return 0;
38
+ const maxUrls = Number.isFinite(params?.maxUrls) && params.maxUrls >= 1 ? Math.floor(params.maxUrls) : AGENT_MAX_ESCALATIONS;
39
+ return AGENT_ESCALATION_CREDITS * Math.min(AGENT_MAX_ESCALATIONS, maxUrls);
40
+ }