crawlforge-mcp-server 6.7.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 +19 -2
- package/README.md +21 -3
- package/package.json +4 -2
- package/server.js +51 -27
- package/src/constants/config.js +22 -0
- package/src/core/AgentOrchestrator.js +123 -25
- package/src/core/AuthManager.js +10 -1
- package/src/core/ResearchOrchestrator.js +72 -2
- package/src/core/StealthBrowserManager.js +852 -250
- package/src/core/creatorMode.js +1 -1
- package/src/core/processing/BrowserProcessor.js +30 -4
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +35 -11
- package/src/skills/agent-skills/crawlforge-stealth-browsing/references/engine-selection.md +64 -19
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +12 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -3
- package/src/tools/advanced/ScrapeWithActionsTool.js +30 -2
- package/src/tools/agent/agent.js +29 -9
- package/src/tools/agent/escalation.js +40 -0
- package/src/tools/scrape/escalation.js +6 -1
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +86 -3
- package/src/utils/stealthVerdict.js +36 -2
package/CLAUDE.md
CHANGED
|
@@ -141,7 +141,7 @@ npm run docker:prod # Run production container
|
|
|
141
141
|
- **ActionExecutor**: Browser automation engine (Playwright-based)
|
|
142
142
|
- **ResearchOrchestrator**: Multi-stage research with query expansion and synthesis
|
|
143
143
|
- **AgentOrchestrator**: Powers the `agent` tool — NL prompt → autonomous PLAN→GATHER→ACT→DECIDE→SHAPE loop with three orchestrator-enforced hard stops (maxSteps≤10, maxUrls≤20, wall-clock) never delegated to the LLM; degraded no-LLM-key path (D2, v4.6.0)
|
|
144
|
-
- **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0
|
|
144
|
+
- **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0. Engine selection goes through the one resolver `resolveStealthEngine(requested)` → `{engine, fallbackWarning}`; `'auto'` (the default for `scrape.escalate_engine` and `stealth_mode.engine` since the 2026-09 stealth review Phase 2) prefers Camoufox and falls back to Chromium with a caller-visible warning when its binary is absent. `'playwright'` is the tool layer's public name for Chromium. Camoufox personas are pinned to the installed binary's Firefox major so the UA's `rv:` and `Firefox/` tokens agree — see `docs/STEALTH_REVIEW_2026-09.md`
|
|
145
145
|
- **LocalizationManager**: Multi-language content and localization
|
|
146
146
|
- **ChangeTracker**: Content change tracking over time
|
|
147
147
|
- **SnapshotManager**: Website snapshots and version history
|
|
@@ -221,8 +221,25 @@ MAX_PAGES_PER_CRAWL=100
|
|
|
221
221
|
RESPECT_ROBOTS_TXT=true
|
|
222
222
|
ROBOTS_CACHE_TTL_MS=3600000 # how long a parsed robots.txt stays good for
|
|
223
223
|
CRAWLFORGE_BLOCKED_HOSTS= # comma-separated; extends the permanent opt-out blocklist
|
|
224
|
+
CRAWLFORGE_STEALTH_ENGINE= # which engine `auto` resolves to on THIS deployment:
|
|
225
|
+
# chromium|playwright pins it to Chromium; unset or anything
|
|
226
|
+
# else keeps the default preference for Camoufox. A caller
|
|
227
|
+
# naming an engine always wins. There is no correct global
|
|
228
|
+
# value — which engine clears a wall depends on the exit IP
|
|
229
|
+
# (residential favours Camoufox, the hosted datacenter IP
|
|
230
|
+
# favours Chromium, measured twice).
|
|
231
|
+
CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the escalation stage,
|
|
232
|
+
# stealth_mode, browser_session, scrape_with_actions and the
|
|
233
|
+
# deep_research fallback when the caller passes none (a proxy
|
|
234
|
+
# passed on the call wins). CrawlForge supplies no proxies, and
|
|
235
|
+
# a datacenter one does not defeat these detectors. Distinct
|
|
236
|
+
# from the PROXY_ROTATION_* family, which belongs to
|
|
237
|
+
# localization. The agent's automatic stealth retry
|
|
238
|
+
# (stealth review Phase 3) reads it too.
|
|
224
239
|
```
|
|
225
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.
|
|
242
|
+
|
|
226
243
|
Web Bot Auth (optional, off by default — requests go out unsigned when unset):
|
|
227
244
|
|
|
228
245
|
```bash
|
|
@@ -260,7 +277,7 @@ Key mechanisms for security-conscious future sessions:
|
|
|
260
277
|
- **endpointGuard** (`src/core/endpointGuard.js`): Hard allow-list of `{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}` for the server's own backend calls; HTTPS required; fail-closed. Localhost only in creator mode (v3.0.18).
|
|
261
278
|
- **Action allowlist** (`src/core/ActionExecutor.js`): `scrape_with_actions` accepts only 7 action types: `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`. `executeJavaScript` throws unless `ALLOW_JAVASCRIPT_EXECUTION=true` is set at deploy time (off by default).
|
|
262
279
|
- **Elicitation** (`src/core/ElicitationHelper.js`): User confirmation requested for `deep_research` (>50 URLs), `batch_scrape` (sync, >25 URLs), `crawl_deep` (projected >500 pages), `extract_structured` (schema has >3 required fields, no LLM configured), and credit-low situations. Fail-open if client does not support elicitation.
|
|
263
|
-
- **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox`
|
|
280
|
+
- **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox` (deliberate fingerprint-spoofing trade-off); `--disable-web-security` was removed from it in Phase 1 of the 2026-09 stealth review — a page reads it in one line — but the non-stealth render browser (`BrowserProcessor`) still carries both flags. Camoufox (Firefox, v4.0.0) is the alternative — see `docs/stealth-engines.md`.
|
|
264
281
|
|
|
265
282
|
See `docs/sandboxing-and-approvals.md` for the full reference.
|
|
266
283
|
|
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.
|
|
@@ -267,6 +267,12 @@ export CRAWLFORGE_TOOL_GROUPS="basic,search,scrape" # unset = all tools; unkno
|
|
|
267
267
|
export RESEARCH_STEALTH_ENGINE="auto" # auto (default) | camoufox | chromium
|
|
268
268
|
export RESEARCH_STEALTH_FALLBACK="true" # set to "false" to disable entirely
|
|
269
269
|
export RESEARCH_MAX_STEALTH_RETRIES="8" # cap on stealth retries per research run
|
|
270
|
+
|
|
271
|
+
# Optional: your own proxies for the stealth paths that have no caller to ask —
|
|
272
|
+
# the scrape escalation stage, the deep_research retry, the agent, browser_session.
|
|
273
|
+
# Comma-separated; a proxy passed on a call always wins. CrawlForge supplies none.
|
|
274
|
+
export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
|
|
275
|
+
# Unrelated to the PROXY_ROTATION_* variables, which belong to the localization tool.
|
|
270
276
|
```
|
|
271
277
|
|
|
272
278
|
### MCP Spec Features
|
|
@@ -297,6 +303,18 @@ ollama pull llama3.2
|
|
|
297
303
|
# extract_with_llm({ url: "https://example.com", prompt: "…", model: "llama3.2" })
|
|
298
304
|
```
|
|
299
305
|
|
|
306
|
+
### Stealth engines and proxies
|
|
307
|
+
|
|
308
|
+
Every stealth entry point — `scrape` with `escalate: true`, `stealth_mode`, `browser_session`, `scrape_with_actions` and the `deep_research` retry — now defaults to **`auto`**: use Camoufox (Firefox anti-detect) when its binary is installed, fall back to Chromium when it is not, with the reason in the result's `warnings[]`. Every result names the engine that actually ran. Naming an engine explicitly behaves as it always has — `camoufox` fails rather than falling back, and `chromium` and `playwright` are the same engine (on `scrape`'s `escalate_engine`, spell it `playwright`).
|
|
309
|
+
|
|
310
|
+
On `browser_session` and `scrape_with_actions` the engine applies only with `stealth: true` — their ordinary path is the standard Chromium pool.
|
|
311
|
+
|
|
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
|
+
|
|
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
|
+
|
|
316
|
+
Full detail, measurements and sources: [docs/stealth-engines.md](docs/stealth-engines.md).
|
|
317
|
+
|
|
300
318
|
### Stealth extraction for `deep_research` (Camoufox)
|
|
301
319
|
|
|
302
320
|
`deep_research` automatically retries sources that block the normal fetch path (Reddit, Quora, forums, and Cloudflare/DataDome-protected pages return HTTP 403) through a **real fingerprinted browser**, then re-extracts from the rendered HTML. It's bounded (`RESEARCH_MAX_STEALTH_RETRIES`, default 8, plus a per-page timeout) and lazy — the browser stack only loads when a source is actually blocked.
|
|
@@ -307,7 +325,7 @@ Engine selection (`RESEARCH_STEALTH_ENGINE`):
|
|
|
307
325
|
- **`camoufox`** — force Camoufox.
|
|
308
326
|
- **`chromium`** — force the Chromium stealth engine.
|
|
309
327
|
|
|
310
|
-
|
|
328
|
+
Camoufox gets through walls that stop headless Chromium: in a benchmark run on 2026-09-21 from a residential IP it was the only engine to clear Cloudflare Turnstile (indeed.com) and Akamai (harrods.com), both of which blocked Chromium stealth. Neither engine cleared DataDome or an interactive Turnstile, so this is a better fetch path, not a bypass. To enable it, install the optional dependency and run its one-time binary fetch:
|
|
311
329
|
|
|
312
330
|
```bash
|
|
313
331
|
# Camoufox is declared as an optional dependency, so a normal install already pulls it.
|
|
@@ -320,7 +338,7 @@ npx camoufox fetch
|
|
|
320
338
|
|
|
321
339
|
Without the Camoufox binary, `deep_research` silently falls back to Chromium stealth and then to plain fetch — no errors, just lower recovery on heavily-protected sites. Disable the whole fallback with `RESEARCH_STEALTH_FALLBACK=false`.
|
|
322
340
|
|
|
323
|
-
> **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide
|
|
341
|
+
> **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide — point the server at your own with `CRAWLFORGE_STEALTH_PROXIES`. See [docs/stealth-engines.md](docs/stealth-engines.md) for details.
|
|
324
342
|
|
|
325
343
|
### Manual Configuration
|
|
326
344
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
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",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"docker:build": "docker build -t crawlforge .",
|
|
29
29
|
"docker:dev": "docker-compose up crawlforge-dev",
|
|
30
30
|
"docker:prod": "docker-compose up crawlforge-prod",
|
|
31
|
+
"bench:stealth": "node scripts/stealth-bench.mjs",
|
|
32
|
+
"bench:stealth:ci": "node scripts/stealth-bench.mjs --ci",
|
|
31
33
|
"eval:selection": "node scripts/tool-selection-eval.mjs",
|
|
32
34
|
"usage:report": "node scripts/tool-usage-report.mjs"
|
|
33
35
|
},
|
|
@@ -155,6 +157,6 @@
|
|
|
155
157
|
"@hono/node-server": "2.0.12",
|
|
156
158
|
"hono": "^4.12.4",
|
|
157
159
|
"dompurify": "^3.4.0",
|
|
158
|
-
"adm-zip": "^0.6.
|
|
160
|
+
"adm-zip": "^0.6.1"
|
|
159
161
|
}
|
|
160
162
|
}
|
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
|
29
29
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
30
30
|
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
31
31
|
import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
|
|
32
|
-
import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
|
|
32
|
+
import { StealthBrowserManager, resolveStealthEngine } from "./src/core/StealthBrowserManager.js";
|
|
33
33
|
import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
34
34
|
// Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
|
|
35
35
|
import * as cheerio from "cheerio";
|
|
@@ -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.
|
|
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,23 +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
|
-
|
|
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
|
-
// "playwright" → chromium engine mapping stays here with its sibling.
|
|
253
|
-
escalateScrape: async ({ url, engine, respectRobots }) => {
|
|
254
|
-
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
255
|
-
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
256
|
-
url,
|
|
257
|
-
engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
|
|
258
|
-
});
|
|
259
|
-
return { ...scraped, engine, warnings };
|
|
260
|
-
}
|
|
272
|
+
escalateScrape: stealthEscalation
|
|
261
273
|
});
|
|
262
|
-
const agentTool = new AgentTool(); // D4 D2
|
|
274
|
+
const agentTool = new AgentTool({ escalateFetch: stealthEscalation }); // D4 D2 + stealth review Phase 3
|
|
263
275
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
264
276
|
const localizationManager = new LocalizationManager();
|
|
265
277
|
|
|
@@ -913,7 +925,7 @@ registerToolIfEnabled("read_result", {
|
|
|
913
925
|
|
|
914
926
|
// Tool: scrape_with_actions
|
|
915
927
|
registerToolIfEnabled("scrape_with_actions", {
|
|
916
|
-
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser. robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
928
|
+
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
917
929
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
918
930
|
inputSchema: {
|
|
919
931
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -985,7 +997,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
985
997
|
viewportWidth: z.number().min(800).max(1920).default(1280),
|
|
986
998
|
viewportHeight: z.number().min(600).max(1080).default(720),
|
|
987
999
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
988
|
-
stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges.")
|
|
1000
|
+
stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
|
|
1001
|
+
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium.")
|
|
989
1002
|
}).optional().describe("Browser configuration options"),
|
|
990
1003
|
extractionOptions: z.object({
|
|
991
1004
|
selectors: z.record(z.string()).optional(),
|
|
@@ -1028,13 +1041,14 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1028
1041
|
|
|
1029
1042
|
// Tool: browser_session
|
|
1030
1043
|
registerToolIfEnabled("browser_session", {
|
|
1031
|
-
description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
|
|
1044
|
+
description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
|
|
1032
1045
|
annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1033
1046
|
inputSchema: {
|
|
1034
1047
|
operation: z.enum(["open", "snapshot", "act", "read", "screenshot", "close", "list"]).describe("open a session, observe it, act on it, read it, or close it"),
|
|
1035
1048
|
session_id: z.string().optional().describe("The id returned by operation:\"open\". Required by every operation except open and list"),
|
|
1036
1049
|
url: z.string().url().optional().describe("open: the URL to load the session on"),
|
|
1037
1050
|
stealth: z.boolean().default(false).describe("open: run the session in the stealth browser"),
|
|
1051
|
+
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("open: stealth engine for the session, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; every operation echoes the `engine` that actually ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
|
|
1038
1052
|
ttl: z.number().min(30).max(3600).optional().describe("open: seconds the session may live at most (default 600)"),
|
|
1039
1053
|
activity_ttl: z.number().min(10).max(3600).optional().describe("open: seconds the session may sit idle (default 300)"),
|
|
1040
1054
|
viewport: z.object({
|
|
@@ -1201,7 +1215,7 @@ registerToolIfEnabled("scrape", {
|
|
|
1201
1215
|
|
|
1202
1216
|
// Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract)
|
|
1203
1217
|
registerToolIfEnabled("agent", {
|
|
1204
|
-
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:
|
|
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})",
|
|
1205
1219
|
annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1206
1220
|
inputSchema: {
|
|
1207
1221
|
prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
|
|
@@ -1337,7 +1351,7 @@ function stealthScrapeFormats(formats, scraped) {
|
|
|
1337
1351
|
|
|
1338
1352
|
// Tool: stealth_mode
|
|
1339
1353
|
registerToolIfEnabled("stealth_mode", {
|
|
1340
|
-
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a
|
|
1354
|
+
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context -> create_page -> cleanup operations remain for multi-step work. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, enable, disable, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
|
|
1341
1355
|
annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1342
1356
|
inputSchema: {
|
|
1343
1357
|
operation: z.enum(['scrape', 'configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
|
|
@@ -1379,7 +1393,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1379
1393
|
hardwareSpoofing: z.boolean().default(true)
|
|
1380
1394
|
}).optional()
|
|
1381
1395
|
}).optional().describe("Stealth browser configuration with anti-detection settings"),
|
|
1382
|
-
engine: z.enum(["
|
|
1396
|
+
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).optional().default("auto").describe("Browser engine: \"auto\" (default — camoufox when it is installed, Chromium otherwise, and the result says which), \"camoufox\" (Firefox-based, higher anti-detect score; fails if not installed), or \"chromium\" (\"playwright\" is the same engine under its old name)"),
|
|
1383
1397
|
contextId: z.string().optional().describe("Browser context ID for page operations"),
|
|
1384
1398
|
urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
|
|
1385
1399
|
url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
|
|
@@ -1399,12 +1413,16 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1399
1413
|
// Gate first: a disallowed URL must never launch a browser.
|
|
1400
1414
|
const warnings = await stealthComplianceGate(url, respect_robots);
|
|
1401
1415
|
|
|
1416
|
+
// "auto" prefers camoufox and falls back to chromium when its binary
|
|
1417
|
+
// is missing; "playwright" is this tool's old public name for chromium.
|
|
1418
|
+
// The resolver owns both, and reports a fallback the caller can see.
|
|
1419
|
+
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1420
|
+
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1421
|
+
|
|
1402
1422
|
const wantsScreenshot = formats.includes('screenshot');
|
|
1403
1423
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
1404
1424
|
url,
|
|
1405
|
-
|
|
1406
|
-
// (the manager and the CLI both call it chromium).
|
|
1407
|
-
engine: engine === 'camoufox' ? 'camoufox' : 'chromium',
|
|
1425
|
+
engine: resolvedEngine.engine,
|
|
1408
1426
|
wait_for: wait_for || 0,
|
|
1409
1427
|
screenshot: wantsScreenshot,
|
|
1410
1428
|
stealthConfig
|
|
@@ -1421,6 +1439,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1421
1439
|
url: scraped.url,
|
|
1422
1440
|
title: scraped.title,
|
|
1423
1441
|
status: verdict.status,
|
|
1442
|
+
engine: resolvedEngine.engine,
|
|
1424
1443
|
content: stealthScrapeFormats(formats, scraped)
|
|
1425
1444
|
};
|
|
1426
1445
|
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
@@ -1457,10 +1476,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1457
1476
|
case 'create_context': {
|
|
1458
1477
|
// Forward the tool-level engine the same way operation:"scrape" does —
|
|
1459
1478
|
// without it, create_context always ran on chromium whatever the caller
|
|
1460
|
-
// asked for
|
|
1479
|
+
// asked for. Resolved, so "auto" reaches the manager as a real engine.
|
|
1480
|
+
const contextEngine = await resolveStealthEngine(engine);
|
|
1461
1481
|
const contextData = await stealthBrowserManager.createStealthContext({
|
|
1462
1482
|
...(stealthConfig || {}),
|
|
1463
|
-
engine: engine
|
|
1483
|
+
engine: contextEngine.engine
|
|
1464
1484
|
});
|
|
1465
1485
|
// The full fingerprint is ~4 KB of canvas noise arrays and WebGL
|
|
1466
1486
|
// extension lists no caller acts on. Summarise by default; verbose:true
|
|
@@ -1468,6 +1488,10 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1468
1488
|
result = {
|
|
1469
1489
|
contextId: contextData.contextId,
|
|
1470
1490
|
created: true,
|
|
1491
|
+
// Which engine the context is on, and why, when "auto" had to fall
|
|
1492
|
+
// back: every page made from it inherits that engine.
|
|
1493
|
+
engine: contextEngine.engine,
|
|
1494
|
+
...(contextEngine.fallbackWarning ? { warnings: [contextEngine.fallbackWarning] } : {}),
|
|
1471
1495
|
fingerprint: verbose
|
|
1472
1496
|
? contextData.fingerprint
|
|
1473
1497
|
: stealthBrowserManager.summarizeFingerprint(contextData.fingerprint)
|
package/src/constants/config.js
CHANGED
|
@@ -454,6 +454,28 @@ export function isStealthConfigured() {
|
|
|
454
454
|
);
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
// The server operator's own exit IPs for every stealth path, from
|
|
458
|
+
// CRAWLFORGE_STEALTH_PROXIES — a comma-separated list in proxyRotation's URL
|
|
459
|
+
// format (http/https/socks4/socks5, credentials percent-encoded in the URL).
|
|
460
|
+
//
|
|
461
|
+
// Deliberately a function reading process.env rather than a field on `config`
|
|
462
|
+
// above: a value frozen at import time cannot be changed for a single run, and
|
|
463
|
+
// every consumer (the escalation stage, the deep_research fallback, the agent,
|
|
464
|
+
// browser_session) resolves its proxy mid-process.
|
|
465
|
+
//
|
|
466
|
+
// Separate from the PROXY_ROTATION_* family under localization.proxy on
|
|
467
|
+
// purpose. Those answer "which country should this page be fetched from", which
|
|
468
|
+
// is the localization tool's whole job; this one answers "which address does
|
|
469
|
+
// this server go out from", which is about not being the same datacentre IP a
|
|
470
|
+
// wall has already seen. Overloading one on the other would make either
|
|
471
|
+
// setting silently change the other's behaviour.
|
|
472
|
+
export function serverStealthProxies() {
|
|
473
|
+
return (process.env.CRAWLFORGE_STEALTH_PROXIES || '')
|
|
474
|
+
.split(',')
|
|
475
|
+
.map((entry) => entry.trim())
|
|
476
|
+
.filter(Boolean);
|
|
477
|
+
}
|
|
478
|
+
|
|
457
479
|
// Get localization configuration
|
|
458
480
|
export function getLocalizationConfig() {
|
|
459
481
|
return config.localization;
|
|
@@ -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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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,
|