crawlforge-mcp-server 6.21.1 → 6.22.1
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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +228 -21
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +76 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +22 -14
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.22.1",
|
|
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",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.18.1",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"pdf-parse": "2.4.5",
|
|
133
133
|
"playwright": "^1.54.2",
|
|
134
134
|
"robots-parser": "^3.0.1",
|
|
135
|
+
"sentiment": "5.0.2",
|
|
135
136
|
"turndown": "^7.2.4",
|
|
136
137
|
"turndown-plugin-gfm": "^1.0.2",
|
|
137
138
|
"undici": "^7.24.0",
|
package/server.js
CHANGED
|
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
113
|
// Create the server
|
|
114
114
|
const server = new McpServer({
|
|
115
115
|
name: "crawlforge",
|
|
116
|
-
version: "6.
|
|
116
|
+
version: "6.22.1",
|
|
117
117
|
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.",
|
|
118
118
|
homepage: "https://www.crawlforge.dev",
|
|
119
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -572,10 +572,10 @@ registerToolIfEnabled("search_web", {
|
|
|
572
572
|
try {
|
|
573
573
|
if (!query && !queries) {
|
|
574
574
|
// `query` stopped being a required field when `queries` arrived, so the
|
|
575
|
-
// SDK no longer rejects this call for us — it reaches the handler,
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
575
|
+
// SDK no longer rejects this call for us — it reaches the handler, which
|
|
576
|
+
// refuses it before any search runs. Same reasoning as
|
|
577
|
+
// ScrapeTemplateTool's badRequest: we fetched nothing, so it costs
|
|
578
|
+
// nothing (G4).
|
|
579
579
|
markPreflightRefusal('BAD_REQUEST');
|
|
580
580
|
return { content: [{ type: "text", text: EXACTLY_ONE_QUERY_MESSAGE }], isError: true };
|
|
581
581
|
}
|
|
@@ -1258,8 +1258,8 @@ registerToolIfEnabled("scrape", {
|
|
|
1258
1258
|
return shot;
|
|
1259
1259
|
});
|
|
1260
1260
|
}
|
|
1261
|
-
// A blocked verdict (0.1) is an error result: withAuth
|
|
1262
|
-
//
|
|
1261
|
+
// A blocked verdict (0.1) is an error result: withAuth charges it nothing
|
|
1262
|
+
// and appends the hint naming stealth_mode, and the SDK skips
|
|
1263
1263
|
// outputSchema validation on isError. The JSON body still carries the
|
|
1264
1264
|
// verdict (success:false, error, blocked) the way stealth_mode's does.
|
|
1265
1265
|
if (result.success === false) return { ...dualOutput(result), isError: true };
|
|
@@ -1274,7 +1274,7 @@ registerToolIfEnabled("agent", {
|
|
|
1274
1274
|
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})",
|
|
1275
1275
|
annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1276
1276
|
inputSchema: {
|
|
1277
|
-
prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
|
|
1277
|
+
prompt: z.string().trim().min(1).max(2000).describe("Natural-language task or question"),
|
|
1278
1278
|
urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
|
|
1279
1279
|
schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
|
|
1280
1280
|
model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
|
|
@@ -1335,7 +1335,7 @@ registerToolIfEnabled("generate_llms_txt", {
|
|
|
1335
1335
|
customRestrictions: z.array(z.string()).optional(),
|
|
1336
1336
|
robotsStyle: z.boolean().optional().default(false)
|
|
1337
1337
|
}).optional().describe("Output customization and organization details"),
|
|
1338
|
-
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("
|
|
1338
|
+
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("Applies only with outputOptions.robotsStyle:true, where it sets how many paths the llms.txt Disallow list covers (basic < standard < strict). The default llmstxt.org markdown is the same at every level; llms-full.txt only records the level as a label."),
|
|
1339
1339
|
format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe("Output format: llms.txt, llms-full.txt, or both"),
|
|
1340
1340
|
...MAX_INLINE_CHARS_PARAM
|
|
1341
1341
|
}
|
|
@@ -1423,10 +1423,10 @@ function stealthScrapeFormats(formats, scraped) {
|
|
|
1423
1423
|
|
|
1424
1424
|
// Tool: stealth_mode
|
|
1425
1425
|
registerToolIfEnabled("stealth_mode", {
|
|
1426
|
-
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.
|
|
1426
|
+
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. create_context then create_page is a navigation and challenge check: create_page loads urlToTest, reports its status, title and any challenge page, and closes the page before the call returns, so nothing on it carries over to a later call. For work over several calls on one page (log in, then read; click, then read), use browser_session with stealth:true. operation:\"configure\" only validates a stealthConfig and returns it with the defaults filled in - it stores nothing, so pass stealthConfig on each scrape or create_context call that should use it. 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, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
|
|
1427
1427
|
annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1428
1428
|
inputSchema: {
|
|
1429
|
-
operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context
|
|
1429
|
+
operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context: a stealth context to run create_page in. create_page: navigate urlToTest in that context and report status, title and any challenge page; the page is closed before the call returns. get_stats: this server process's open contexts, browser and proxy state. cleanup: close the browser and every context."),
|
|
1430
1430
|
stealthConfig: z.object({
|
|
1431
1431
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
1432
1432
|
randomizeFingerprint: z.boolean().default(true),
|
|
@@ -1467,7 +1467,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1467
1467
|
}).optional().describe("Stealth browser configuration with anti-detection settings. Applies to the call it is passed on and is not remembered. Chromium applies customUserAgent, customViewport, locale and timezone per call. On camoufox customUserAgent and customViewport are not applied, locale is fixed when the browser launches (run operation:\"cleanup\" first to change it), and behind a proxy locale and timezone come from the exit IP; each of these is reported in `warnings`."),
|
|
1468
1468
|
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)"),
|
|
1469
1469
|
contextId: z.string().optional().describe("Browser context ID for page operations"),
|
|
1470
|
-
urlToTest: z.string().url().optional().describe("URL to navigate to
|
|
1470
|
+
urlToTest: z.string().url().optional().describe("create_page: the URL to navigate to and check"),
|
|
1471
1471
|
url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
|
|
1472
1472
|
formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
|
|
1473
1473
|
wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
|
|
@@ -51,6 +51,17 @@ const SELECTOR_WAIT_STATES = new Set(['attached', 'detached', 'visible', 'hidden
|
|
|
51
51
|
|
|
52
52
|
const withoutHash = (url) => url.split('#')[0];
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A failed action's message, naming its selector. Playwright names it only in
|
|
56
|
+
* the "Call log:", which is stripped before the model reads the error (R25 3.2).
|
|
57
|
+
*/
|
|
58
|
+
function actionErrorMessage(action, error) {
|
|
59
|
+
const firstLine = String(error.message).split('\n')[0];
|
|
60
|
+
return action.selector && !firstLine.includes(action.selector)
|
|
61
|
+
? `${action.type} on selector "${action.selector}": ${error.message}`
|
|
62
|
+
: error.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
// Action schemas
|
|
55
66
|
const BaseActionSchema = z.object({
|
|
56
67
|
type: z.string(),
|
|
@@ -854,6 +865,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
854
865
|
action.timeout) {
|
|
855
866
|
timeout = Math.max(this.defaultTimeout, action.timeout + 5000);
|
|
856
867
|
}
|
|
868
|
+
// A pause before a selector/text wait (executeWaitAction) runs on top of
|
|
869
|
+
// that wait's deadline.
|
|
870
|
+
if (action.type === 'wait' && (action.selector || action.text)) {
|
|
871
|
+
timeout += action.duration || action.milliseconds || 0;
|
|
872
|
+
}
|
|
857
873
|
|
|
858
874
|
// Execute based on action type. Playwright owns the real deadline (every
|
|
859
875
|
// call below is given `timeout`), so this race is only a backstop for a
|
|
@@ -902,7 +918,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
902
918
|
id: actionId,
|
|
903
919
|
type: action.type,
|
|
904
920
|
success: false,
|
|
905
|
-
error: error
|
|
921
|
+
error: actionErrorMessage(action, error),
|
|
906
922
|
// Only when the gate refused (a `navigate` to a URL it would not
|
|
907
923
|
// load), so the chain can tell an answer from a fault.
|
|
908
924
|
...(refusalCode ? { errorCode: refusalCode } : {}),
|
|
@@ -1094,14 +1110,17 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1094
1110
|
(!action.selector && !action.text ? action.timeout : undefined);
|
|
1095
1111
|
if (waitTime) {
|
|
1096
1112
|
await this.delay(waitTime);
|
|
1097
|
-
|
|
1113
|
+
// A pause given with a selector or text waits for that too, after the
|
|
1114
|
+
// pause; the selector and condition used to be dropped (R25 3.3).
|
|
1115
|
+
if (!action.selector && !action.text) return { waited: waitTime };
|
|
1098
1116
|
}
|
|
1117
|
+
const waited = waitTime ? { waited: waitTime } : {};
|
|
1099
1118
|
|
|
1100
1119
|
const timeout = this.actionTimeout(action);
|
|
1101
1120
|
|
|
1102
1121
|
if (action.selector) {
|
|
1103
1122
|
await this.waitForCondition(page, action.selector, action.condition, timeout);
|
|
1104
|
-
return { selector: action.selector, condition: action.condition };
|
|
1123
|
+
return { ...waited, selector: action.selector, condition: action.condition };
|
|
1105
1124
|
}
|
|
1106
1125
|
|
|
1107
1126
|
if (action.text) {
|
|
@@ -1110,7 +1129,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1110
1129
|
action.text,
|
|
1111
1130
|
{ timeout }
|
|
1112
1131
|
);
|
|
1113
|
-
return { text: action.text };
|
|
1132
|
+
return { ...waited, text: action.text };
|
|
1114
1133
|
}
|
|
1115
1134
|
|
|
1116
1135
|
throw new Error('Wait action requires duration/milliseconds/timeout, selector, or text');
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
* Hard stops (enforced here, not by the LLM):
|
|
13
13
|
* 1. maxSteps iterations of the ACT loop
|
|
14
14
|
* 2. maxUrls total URLs fetched
|
|
15
|
-
* 3. wallClockMs wall-clock milliseconds (default 120 000)
|
|
15
|
+
* 3. wallClockMs wall-clock milliseconds (default 120 000); every LLM call
|
|
16
|
+
* gets only the time left, and a run that reaches it returns degraded
|
|
17
|
+
* with the evidence gathered so far
|
|
16
18
|
*
|
|
17
19
|
* No-LLM-key path: if all LLM calls fail, return collected evidence + {degraded:true}.
|
|
18
20
|
* pro model: delegates to ResearchOrchestrator.conductResearch() for richer synthesis.
|
|
19
21
|
*/
|
|
20
22
|
|
|
23
|
+
import { load } from 'cheerio';
|
|
21
24
|
import { fetchAndParse } from '../tools/extract/_fetchAndParse.js';
|
|
22
25
|
import { SamplingClient } from './SamplingClient.js';
|
|
23
26
|
import { fenceUntrusted } from '../utils/untrustedContent.js';
|
|
@@ -31,6 +34,12 @@ const DEFAULT_MAX_URLS = 10;
|
|
|
31
34
|
// A stealth retry launches a browser and waits out a challenge; one started
|
|
32
35
|
// with less than this left on the wall clock would overrun the hard stop.
|
|
33
36
|
const ESCALATION_MIN_REMAINING_MS = 20_000;
|
|
37
|
+
// PLAN is optional (the raw prompt is the fallback query), so a slow model
|
|
38
|
+
// may spend at most this share of the wall clock on it, never all of it.
|
|
39
|
+
const PLAN_MAX_SHARE = 0.25;
|
|
40
|
+
// A corrective rewrite is a whole synthesis call; with less than this left it
|
|
41
|
+
// would run into the hard stop, so the first answer is kept and flagged.
|
|
42
|
+
const REWRITE_MIN_REMAINING_MS = 15_000;
|
|
34
43
|
|
|
35
44
|
/** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
|
|
36
45
|
function isRefusal(err) {
|
|
@@ -67,7 +76,7 @@ function truncate(text, maxChars = 8000) {
|
|
|
67
76
|
* strings that do not (deduplicated, in answer order). Comparison is
|
|
68
77
|
* case-insensitive with whitespace and thousands separators removed, so
|
|
69
78
|
* "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
|
|
70
|
-
*
|
|
79
|
+
* skipped here: they are citations, checked by unverifiedUrls.
|
|
71
80
|
*/
|
|
72
81
|
const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
|
|
73
82
|
const VALUE_PATTERNS = [
|
|
@@ -101,16 +110,81 @@ export function unverifiedValues(answer, sourceText) {
|
|
|
101
110
|
return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The http(s) URLs in a text, trailing punctuation removed. A closing
|
|
115
|
+
* parenthesis stays only when it closes one opened inside the URL, so
|
|
116
|
+
* "[HN](https://x/item?id=1)" yields the link and "/wiki/Foo_(bar)" survives.
|
|
117
|
+
*/
|
|
118
|
+
export function urlsIn(text) {
|
|
119
|
+
return (String(text || '').match(/https?:\/\/[^\s<>"'`\]]+/g) || []).map(url => {
|
|
120
|
+
for (;;) {
|
|
121
|
+
if (/[.,;:!?*'"]$/.test(url)) url = url.slice(0, -1);
|
|
122
|
+
else if (url.endsWith(')') && url.split('(').length < url.split(')').length) url = url.slice(0, -1);
|
|
123
|
+
else return url;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A URL as a citation is compared: scheme, "www.", fragment and trailing
|
|
130
|
+
* slash dropped, host lowercased, "&" decoded. Null when it does not parse.
|
|
131
|
+
*/
|
|
132
|
+
function citationKey(url) {
|
|
133
|
+
try {
|
|
134
|
+
const u = new URL(String(url).replace(/&/g, '&'));
|
|
135
|
+
return `${u.host.toLowerCase().replace(/^www\./, '')}${u.pathname.replace(/\/+$/, '')}${u.search}`;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* URLs the answer cites that no fetched page supports: not a fetched URL
|
|
143
|
+
* (before or after redirects), not a link on a fetched page, and not written
|
|
144
|
+
* in the source text. The R25 re-check (2026-10-19) answered the current HN
|
|
145
|
+
* top story with the right title and a link to a 2021 story that none of the
|
|
146
|
+
* sources contained, and the value-only check above skipped it as a citation.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} answer
|
|
149
|
+
* @param {Iterable<string>} citable fetched URLs, their final URLs and the links on them
|
|
150
|
+
* @param {string} [sourceText]
|
|
151
|
+
* @returns {string[]} the cited URLs as written, deduplicated
|
|
152
|
+
*/
|
|
153
|
+
export function unverifiedUrls(answer, citable, sourceText = '') {
|
|
154
|
+
const known = new Set([...citable, ...urlsIn(sourceText)].map(citationKey).filter(Boolean));
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
return urlsIn(answer).filter(url => {
|
|
157
|
+
const key = citationKey(url);
|
|
158
|
+
if (!key || seen.has(key)) return false;
|
|
159
|
+
seen.add(key);
|
|
160
|
+
return !known.has(key);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Absolute http(s) hrefs of the links on a parsed page. */
|
|
165
|
+
function pageLinks($, baseUrl) {
|
|
166
|
+
const links = [];
|
|
167
|
+
$('a[href]').each((_, el) => {
|
|
168
|
+
try {
|
|
169
|
+
const url = new URL($(el).attr('href'), baseUrl);
|
|
170
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') links.push(url.href);
|
|
171
|
+
} catch {
|
|
172
|
+
// An unparsable href cites nothing.
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return links;
|
|
176
|
+
}
|
|
177
|
+
|
|
104
178
|
/**
|
|
105
179
|
* Current-state task gate: prompts about the live "now" ("right now",
|
|
106
|
-
* "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
180
|
+
* "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
107
181
|
* dated articles rank for the task's words while the live page does not
|
|
108
182
|
* (live repro 2026-08-26: "#1 story on Hacker News right now" answered from
|
|
109
183
|
* a January thread; news.ycombinator.com was never fetched). Detected
|
|
110
184
|
* deterministically here (never LLM-trusted) so PLAN steers search toward
|
|
111
185
|
* the live entity page and SHAPE answers from it, not from dated results.
|
|
112
186
|
*/
|
|
113
|
-
const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
187
|
+
const CURRENT_STATE_RE = /\b(right now|current|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
114
188
|
|
|
115
189
|
/** Version numbers as they appear in an answer, e.g. "2.11.4". */
|
|
116
190
|
const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
|
|
@@ -172,6 +246,66 @@ export function isCurrentStateTask(prompt) {
|
|
|
172
246
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
173
247
|
}
|
|
174
248
|
|
|
249
|
+
/** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
|
|
250
|
+
function siteLabel(hostname) {
|
|
251
|
+
const labels = hostname.toLowerCase().split('.');
|
|
252
|
+
const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
|
|
253
|
+
return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* True when a search result is the entity's own site: its domain is the
|
|
258
|
+
* entity's name (python.org for "Python"), or it is a site root whose title
|
|
259
|
+
* leads with exactly that name (news.ycombinator.com, titled "Hacker News").
|
|
260
|
+
*
|
|
261
|
+
* The domain vote alone picked the wrong site in both R25 repros (2026-10-19).
|
|
262
|
+
* Brave returns each origin about once, so for "Python" every origin tied and
|
|
263
|
+
* the top-ranked one, en.wikipedia.org, was promoted — its root is the
|
|
264
|
+
* Wikipedia Main Page. For "Hacker News" thehackernews.com appeared twice and
|
|
265
|
+
* outvoted news.ycombinator.com. Neither look-alike passes this test: its
|
|
266
|
+
* domain is "thehackernews", its title "The Hacker News | …".
|
|
267
|
+
*/
|
|
268
|
+
export function isOfficialResult(entity, url, title) {
|
|
269
|
+
const name = (entity || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
270
|
+
if (!name) return false;
|
|
271
|
+
let u;
|
|
272
|
+
try { u = new URL(url); } catch { return false; }
|
|
273
|
+
const compact = s => s.replace(/[^\p{L}\p{N}]/gu, '');
|
|
274
|
+
if (compact(siteLabel(u.hostname)) === compact(name)) return true;
|
|
275
|
+
const lead = (title || '').split(/\s+[|–—-]\s+|:\s/)[0].trim().toLowerCase().replace(/\s+/g, ' ');
|
|
276
|
+
return u.pathname === '/' && !u.search && lead === name;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A page's share of the synthesis budget. Cut from the top, a page whose
|
|
281
|
+
* answer sits below its navigation menus loses it: python.org's
|
|
282
|
+
* "Latest: Python 3.15.0" sits ~3 500 characters in, under a wall of menu
|
|
283
|
+
* links, past a 2 400-character share (R25, 2026-10-19). When a line past the
|
|
284
|
+
* cut matches more of the task's terms than any line before it, the second
|
|
285
|
+
* half of the share goes to the text around that line.
|
|
286
|
+
*/
|
|
287
|
+
export function sliceForSynthesis(text, cap, terms) {
|
|
288
|
+
if (!text || text.length <= cap) return text;
|
|
289
|
+
const score = line => {
|
|
290
|
+
const lc = line.toLowerCase();
|
|
291
|
+
return terms.filter(t => lc.includes(t)).length;
|
|
292
|
+
};
|
|
293
|
+
let keptBest = 0;
|
|
294
|
+
let best = 0;
|
|
295
|
+
let bestAt = -1;
|
|
296
|
+
let pos = 0;
|
|
297
|
+
for (const line of text.split('\n')) {
|
|
298
|
+
const s = score(line);
|
|
299
|
+
if (pos + line.length <= cap) keptBest = Math.max(keptBest, s);
|
|
300
|
+
else if (s > best) { best = s; bestAt = pos; }
|
|
301
|
+
pos += line.length + 1;
|
|
302
|
+
}
|
|
303
|
+
if (best <= keptBest) return truncate(text, cap);
|
|
304
|
+
const head = Math.floor(cap / 2);
|
|
305
|
+
const from = Math.max(head, bestAt - 200);
|
|
306
|
+
return `${text.slice(0, head)}\n[...]\n${truncate(text.slice(from), cap - head)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
175
309
|
/**
|
|
176
310
|
* The names a prompt uses: words carrying a capital letter that is not just
|
|
177
311
|
* the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
|
|
@@ -309,7 +443,9 @@ export class AgentOrchestrator {
|
|
|
309
443
|
usage.charged = 0;
|
|
310
444
|
|
|
311
445
|
const startTime = Date.now();
|
|
312
|
-
const
|
|
446
|
+
const remaining = () => wallClockMs - (Date.now() - startTime);
|
|
447
|
+
const deadline = () => remaining() <= 0;
|
|
448
|
+
const timeLimitReason = `Stopped at the ${wallClockMs} ms time limit; the evidence gathered before it is returned.`;
|
|
313
449
|
|
|
314
450
|
// Hard-cap params regardless of what caller sends
|
|
315
451
|
const capSteps = Math.min(maxSteps, 10);
|
|
@@ -358,7 +494,10 @@ export class AgentOrchestrator {
|
|
|
358
494
|
? `The task asks about a CURRENT live state: the FIRST query must be ONLY the name of the site or thing whose current state is asked, nothing else. `
|
|
359
495
|
: '') +
|
|
360
496
|
`Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
|
|
361
|
-
const { text } = await this._getSamplingClient().complete(planPrompt, {
|
|
497
|
+
const { text } = await this._getSamplingClient().complete(planPrompt, {
|
|
498
|
+
maxTokens: 200,
|
|
499
|
+
timeoutMs: Math.min(remaining(), wallClockMs * PLAN_MAX_SHARE)
|
|
500
|
+
});
|
|
362
501
|
const lines = text.split('\n')
|
|
363
502
|
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
364
503
|
// A model that fences its list ("```\nq1\nq2\n```") or quotes each
|
|
@@ -477,8 +616,10 @@ export class AgentOrchestrator {
|
|
|
477
616
|
// queue and first at SHAPE time so synthesis answers from it, never from
|
|
478
617
|
// a dated article. A wrong root self-heals: it fails the relevance gate
|
|
479
618
|
// and never enters evidence.
|
|
619
|
+
let promotedOrigin = null;
|
|
480
620
|
if (currentState && searchResults.length > 0) {
|
|
481
621
|
const originCounts = new Map();
|
|
622
|
+
const official = new Set();
|
|
482
623
|
// Only the bare entity query votes: the fact query added above returns
|
|
483
624
|
// guides and news sites, which must not outvote the official domain.
|
|
484
625
|
const entityQuery = searchQueries[0];
|
|
@@ -487,14 +628,23 @@ export class AgentOrchestrator {
|
|
|
487
628
|
try {
|
|
488
629
|
const origin = new URL(s.url).origin;
|
|
489
630
|
originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
|
|
631
|
+
if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
|
|
490
632
|
} catch { /* unparsable result url */ }
|
|
491
633
|
}
|
|
634
|
+
// The entity's own site wins over any vote count; only when no result
|
|
635
|
+
// is official does the vote decide, and a tied vote decides nothing —
|
|
636
|
+
// with one result per origin it would just promote the top-ranked one.
|
|
492
637
|
let bestOrigin = null;
|
|
493
638
|
let bestCount = 0;
|
|
639
|
+
let tied = false;
|
|
494
640
|
for (const [origin, count] of originCounts) {
|
|
641
|
+
if (official.size > 0 && !official.has(origin)) continue;
|
|
495
642
|
// Strict > keeps the earliest-seen (top-ranked) origin on ties.
|
|
496
|
-
if (count > bestCount) { bestOrigin = origin; bestCount = count; }
|
|
643
|
+
if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
|
|
644
|
+
else if (count === bestCount) tied = true;
|
|
497
645
|
}
|
|
646
|
+
if (tied && official.size === 0) bestOrigin = null;
|
|
647
|
+
promotedOrigin = bestOrigin;
|
|
498
648
|
if (bestOrigin) {
|
|
499
649
|
const liveRoot = `${bestOrigin}/`;
|
|
500
650
|
if (!priorityUrls.includes(liveRoot)) priorityUrls.push(liveRoot);
|
|
@@ -512,6 +662,9 @@ export class AgentOrchestrator {
|
|
|
512
662
|
// cap whenever it was smaller than capUrls, leaving maxUrls unreachable
|
|
513
663
|
// at its default. Both caps remain fully enforced (neither is weakened).
|
|
514
664
|
const evidence = [];
|
|
665
|
+
// URLs an answer may cite: every evidence page's URL (requested and
|
|
666
|
+
// final) and the links on it.
|
|
667
|
+
const citable = [];
|
|
515
668
|
let urlsFetched = 0;
|
|
516
669
|
let step = 0;
|
|
517
670
|
const warnings = [];
|
|
@@ -527,7 +680,7 @@ export class AgentOrchestrator {
|
|
|
527
680
|
let plainError = null;
|
|
528
681
|
let verdict = null;
|
|
529
682
|
try {
|
|
530
|
-
plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
|
|
683
|
+
plain = await fetchAndParse(url, { timeoutMs: Math.max(1, Math.min(10000, remaining())), errorDocuments: true });
|
|
531
684
|
verdict = documentVerdict(
|
|
532
685
|
{ url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
|
|
533
686
|
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
@@ -547,6 +700,7 @@ export class AgentOrchestrator {
|
|
|
547
700
|
text: truncate(plain.textContent),
|
|
548
701
|
step
|
|
549
702
|
});
|
|
703
|
+
citable.push(url, plain.finalUrl, ...pageLinks(plain.$, plain.finalUrl));
|
|
550
704
|
continue;
|
|
551
705
|
}
|
|
552
706
|
// A real page that is simply off-topic: nothing to retry.
|
|
@@ -591,6 +745,7 @@ export class AgentOrchestrator {
|
|
|
591
745
|
via: 'stealth',
|
|
592
746
|
step
|
|
593
747
|
});
|
|
748
|
+
citable.push(url, stealth.url || url, ...pageLinks(load(stealth.html || ''), stealth.url || url));
|
|
594
749
|
continue;
|
|
595
750
|
}
|
|
596
751
|
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})` : ''}`}`);
|
|
@@ -601,6 +756,7 @@ export class AgentOrchestrator {
|
|
|
601
756
|
|
|
602
757
|
if (relevantExcerpt) {
|
|
603
758
|
evidence.push({ url, title: relevantExcerpt.title, text: relevantExcerpt.text, snippet: true, step });
|
|
759
|
+
citable.push(url);
|
|
604
760
|
}
|
|
605
761
|
}
|
|
606
762
|
|
|
@@ -622,15 +778,18 @@ export class AgentOrchestrator {
|
|
|
622
778
|
}))
|
|
623
779
|
.sort((a, b) => b._score - a._score);
|
|
624
780
|
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
781
|
+
// Question words match any prose ("What is your name?" in a code sample).
|
|
782
|
+
const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
783
|
+
.filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
|
|
625
784
|
const combinedText = orderedEvidence
|
|
626
|
-
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${
|
|
785
|
+
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
|
|
627
786
|
.join('\n\n');
|
|
628
787
|
|
|
629
788
|
if (!combinedText.trim()) {
|
|
630
789
|
return {
|
|
631
790
|
success: true,
|
|
632
791
|
degraded: true,
|
|
633
|
-
reason: 'No content could be fetched for the given prompt.',
|
|
792
|
+
reason: deadline() ? timeLimitReason : 'No content could be fetched for the given prompt.',
|
|
634
793
|
search_results: searchResults,
|
|
635
794
|
evidence: [],
|
|
636
795
|
answer: null,
|
|
@@ -643,15 +802,19 @@ export class AgentOrchestrator {
|
|
|
643
802
|
}
|
|
644
803
|
|
|
645
804
|
// Schema path: use ExtractWithLlm for structured output
|
|
646
|
-
if (schema && Object.keys(schema).length > 0) {
|
|
805
|
+
if (schema && Object.keys(schema).length > 0 && !deadline()) {
|
|
647
806
|
try {
|
|
648
807
|
const extractWithLlm = await this._getExtractWithLlm();
|
|
649
808
|
const result = await extractWithLlm.execute({
|
|
650
809
|
content: combinedText,
|
|
651
810
|
prompt: `From the following research sources, answer this task and extract structured data:\n${prompt}`,
|
|
652
811
|
schema,
|
|
653
|
-
provider: 'auto'
|
|
812
|
+
provider: 'auto',
|
|
813
|
+
signal: AbortSignal.timeout(Math.max(0, remaining()))
|
|
654
814
|
});
|
|
815
|
+
// Out of time: fall through so the time-limit result below carries
|
|
816
|
+
// the full evidence.
|
|
817
|
+
if (!result.success && deadline()) throw new Error(result.error);
|
|
655
818
|
// A required field left null is an incomplete answer, not a
|
|
656
819
|
// structured success (R24, 2026-10-03).
|
|
657
820
|
const missingRequired = result.success && Array.isArray(schema.required)
|
|
@@ -686,12 +849,14 @@ export class AgentOrchestrator {
|
|
|
686
849
|
let degraded = false;
|
|
687
850
|
let degradedReason;
|
|
688
851
|
let unverified = [];
|
|
852
|
+
let unverifiedLinks = [];
|
|
689
853
|
let forumOnlyVersions = [];
|
|
690
854
|
// Distinct from `degraded`: a run can be degraded because its version
|
|
691
855
|
// claims are unsupported and still have had its provenance checked.
|
|
692
856
|
let synthesisFailed = false;
|
|
693
857
|
|
|
694
858
|
try {
|
|
859
|
+
if (deadline()) throw new Error('time limit reached before synthesis');
|
|
695
860
|
// Wording matters for small local models (llama3.2-class): without the
|
|
696
861
|
// "already fetched / do not refuse" framing they answer "I cannot access
|
|
697
862
|
// the internet" or "the sources do not contain it" even when the answer
|
|
@@ -706,9 +871,13 @@ export class AgentOrchestrator {
|
|
|
706
871
|
`Rules:\n` +
|
|
707
872
|
// Short and imperative on purpose: the executing model is a small
|
|
708
873
|
// local one (gemma3:4b-class) and ignores hedged phrasing.
|
|
709
|
-
|
|
874
|
+
// "The first source is the live page" is only true when GATHER
|
|
875
|
+
// promoted one; otherwise the first source is just the best term match.
|
|
876
|
+
(currentState && promotedOrigin
|
|
710
877
|
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
|
|
711
|
-
:
|
|
878
|
+
: currentState
|
|
879
|
+
? `- The task asks about the CURRENT state. Prefer the subject's own official site over third-party articles. NEVER present older or dated content as the current answer.\n`
|
|
880
|
+
: '') +
|
|
712
881
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
713
882
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
714
883
|
`- Cite the exact source URL(s) you used.\n` +
|
|
@@ -716,7 +885,7 @@ export class AgentOrchestrator {
|
|
|
716
885
|
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
717
886
|
`Provide a clear, concise answer.`;
|
|
718
887
|
|
|
719
|
-
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
888
|
+
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
720
889
|
answer = text;
|
|
721
890
|
|
|
722
891
|
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
@@ -724,18 +893,24 @@ export class AgentOrchestrator {
|
|
|
724
893
|
// 2026-09-04). Versions, dates and counts in the answer must appear in
|
|
725
894
|
// the source text; what does not gets one corrective rewrite, and
|
|
726
895
|
// whatever still remains is named in the answer and in `provenance`.
|
|
896
|
+
// A cited URL is checked the same way (R25 re-check, 2026-10-19: the
|
|
897
|
+
// right HN title with an invented item link).
|
|
898
|
+
const sourceText = evidence.map(e => e.text || '').join('\n');
|
|
727
899
|
unverified = unverifiedValues(answer, combinedText);
|
|
728
|
-
|
|
900
|
+
unverifiedLinks = unverifiedUrls(answer, citable, sourceText);
|
|
901
|
+
if ((unverified.length > 0 || unverifiedLinks.length > 0) && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
729
902
|
const retryPrompt =
|
|
730
903
|
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
731
|
-
`It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
|
|
904
|
+
(unverified.length > 0 ? `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` : '') +
|
|
905
|
+
(unverifiedLinks.length > 0 ? `It cites URLs that are not among the sources or the links on them: ${unverifiedLinks.join(', ')}. Cite only the source URLs given above. ` : '') +
|
|
732
906
|
`Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
|
|
733
907
|
`Where the sources do not state a value, say that it is not stated instead of guessing.`;
|
|
734
908
|
try {
|
|
735
|
-
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
|
|
909
|
+
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
736
910
|
if (rewritten && rewritten.trim()) {
|
|
737
911
|
answer = rewritten;
|
|
738
912
|
unverified = unverifiedValues(answer, combinedText);
|
|
913
|
+
unverifiedLinks = unverifiedUrls(answer, citable, sourceText);
|
|
739
914
|
}
|
|
740
915
|
} catch {
|
|
741
916
|
// Keep the first answer; it is flagged below.
|
|
@@ -746,6 +921,11 @@ export class AgentOrchestrator {
|
|
|
746
921
|
`\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
|
|
747
922
|
`${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
|
|
748
923
|
}
|
|
924
|
+
if (unverifiedLinks.length > 0) {
|
|
925
|
+
answer +=
|
|
926
|
+
`\n\nProvenance warning: ${unverifiedLinks.join(', ')} ` +
|
|
927
|
+
`${unverifiedLinks.length === 1 ? 'is' : 'are'} not among the fetched pages or the links on them and may be invented.`;
|
|
928
|
+
}
|
|
749
929
|
|
|
750
930
|
// Asked for a current version, the agent answered "2.4.3" from a forum
|
|
751
931
|
// thread about a different project while the project's own site and its
|
|
@@ -756,7 +936,7 @@ export class AgentOrchestrator {
|
|
|
756
936
|
// presented as the answer.
|
|
757
937
|
if (currentState && isVersionQuestion(prompt)) {
|
|
758
938
|
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
759
|
-
if (forumOnlyVersions.length > 0) {
|
|
939
|
+
if (forumOnlyVersions.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
760
940
|
const versionRetryPrompt =
|
|
761
941
|
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
762
942
|
`It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
|
|
@@ -765,7 +945,7 @@ export class AgentOrchestrator {
|
|
|
765
945
|
`Rewrite the answer using only versions stated by the project's own sources. ` +
|
|
766
946
|
`If none of them states a current version, say plainly that the fetched sources do not state one.`;
|
|
767
947
|
try {
|
|
768
|
-
const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
|
|
948
|
+
const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
769
949
|
if (rewritten && rewritten.trim()) {
|
|
770
950
|
answer = rewritten;
|
|
771
951
|
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
@@ -786,10 +966,36 @@ export class AgentOrchestrator {
|
|
|
786
966
|
`only in discussion pages. The authoritative pages fetched state no version.`;
|
|
787
967
|
}
|
|
788
968
|
}
|
|
969
|
+
|
|
970
|
+
// A promoted live page is only worth its +1000 if the answer came from
|
|
971
|
+
// it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
|
|
972
|
+
// Main Page, and the HN question from thehackernews.com, both with
|
|
973
|
+
// degraded:false. The answer counts as the live page's when it cites
|
|
974
|
+
// that host or states a value (version, date, number) the page shows.
|
|
975
|
+
if (currentState && promotedOrigin && !degraded) {
|
|
976
|
+
const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
|
|
977
|
+
const liveHost = host(promotedOrigin);
|
|
978
|
+
const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
|
|
979
|
+
// unverifiedValues against an empty text lists every value the answer states.
|
|
980
|
+
const stated = unverifiedValues(answer, '');
|
|
981
|
+
const fromLivePage = answer.toLowerCase().includes(liveHost) ||
|
|
982
|
+
(liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
|
|
983
|
+
if (!fromLivePage) {
|
|
984
|
+
degraded = true;
|
|
985
|
+
degradedReason =
|
|
986
|
+
`The answer does not come from the live page ${promotedOrigin}/: ` +
|
|
987
|
+
(liveText
|
|
988
|
+
? 'it neither cites that page nor states a value shown on it.'
|
|
989
|
+
: 'that page could not be fetched or did not match the task.') +
|
|
990
|
+
' It may describe a dated or third-party source rather than the current state.';
|
|
991
|
+
}
|
|
992
|
+
}
|
|
789
993
|
} catch (err) {
|
|
790
994
|
degraded = true;
|
|
791
995
|
synthesisFailed = true;
|
|
792
|
-
degradedReason =
|
|
996
|
+
degradedReason = err.code === 'LLM_TIMEOUT' || deadline()
|
|
997
|
+
? timeLimitReason
|
|
998
|
+
: `LLM synthesis unavailable: ${err.message}`;
|
|
793
999
|
// Return raw evidence so the host LLM can synthesize
|
|
794
1000
|
answer = null;
|
|
795
1001
|
}
|
|
@@ -809,6 +1015,7 @@ export class AgentOrchestrator {
|
|
|
809
1015
|
provenance: {
|
|
810
1016
|
checked: !synthesisFailed,
|
|
811
1017
|
unverified,
|
|
1018
|
+
...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
|
|
812
1019
|
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
|
|
813
1020
|
}
|
|
814
1021
|
};
|