melchizedek-agents 0.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/AGENT_SETUP.md +182 -0
- package/DOCUMENTATION.md +614 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +164 -0
- package/README.md +151 -0
- package/config/agents/README.md +27 -0
- package/config/agents/examples/ares.yaml +74 -0
- package/config/agents/examples/cartographers.yaml +115 -0
- package/config/agents/examples/claude.yaml +17 -0
- package/config/agents/examples/council.yaml +65 -0
- package/config/agents/examples/critic.yaml +74 -0
- package/config/agents/examples/delegation.yaml +29 -0
- package/config/agents/examples/hierarchical.yaml +26 -0
- package/config/agents/examples/image_production.yaml +112 -0
- package/config/agents/examples/librarian.yaml +75 -0
- package/config/agents/examples/model_zoo.yaml +81 -0
- package/config/agents/examples/patient_advocate.yaml +107 -0
- package/config/agents/examples/scriptorium.yaml +96 -0
- package/config/agents/examples/style_council.yaml +83 -0
- package/config/agents/examples/syndicate.yaml +34 -0
- package/config/agents/examples/tutor.yaml +66 -0
- package/config/agents/syndicateSchema.yaml +291 -0
- package/dist/lib/config.d.ts +108 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/dispatch.d.ts +133 -0
- package/dist/lib/dispatch.js +182 -0
- package/dist/lib/grounding.d.ts +28 -0
- package/dist/lib/grounding.js +61 -0
- package/dist/lib/index.d.ts +14 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/loadEnv.d.ts +21 -0
- package/dist/lib/loadEnv.js +58 -0
- package/dist/lib/loadSyndicate.d.ts +177 -0
- package/dist/lib/loadSyndicate.js +264 -0
- package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
- package/dist/lib/memory/supabaseMemoryService.js +499 -0
- package/dist/lib/models/claudeLlm.d.ts +93 -0
- package/dist/lib/models/claudeLlm.js +332 -0
- package/dist/lib/models/gptLlm.d.ts +110 -0
- package/dist/lib/models/gptLlm.js +497 -0
- package/dist/lib/models/grokLlm.d.ts +64 -0
- package/dist/lib/models/grokLlm.js +86 -0
- package/dist/lib/models/ollamaLlm.d.ts +105 -0
- package/dist/lib/models/ollamaLlm.js +134 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
- package/dist/lib/models/openAiCompatibleLlm.js +331 -0
- package/dist/lib/models/providerMap.d.ts +28 -0
- package/dist/lib/models/providerMap.js +45 -0
- package/dist/lib/models/registry.d.ts +71 -0
- package/dist/lib/models/registry.js +159 -0
- package/dist/lib/models/schemaNormalize.d.ts +22 -0
- package/dist/lib/models/schemaNormalize.js +58 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
- package/dist/lib/observability/supabaseSpanExporter.js +115 -0
- package/dist/lib/observability/tracer.d.ts +44 -0
- package/dist/lib/observability/tracer.js +242 -0
- package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
- package/dist/lib/persistence/supabaseProvider.js +88 -0
- package/dist/lib/session/supabaseSessionService.d.ts +26 -0
- package/dist/lib/session/supabaseSessionService.js +193 -0
- package/dist/lib/session/transcript.d.ts +193 -0
- package/dist/lib/session/transcript.js +309 -0
- package/dist/lib/toolRegistry.d.ts +15 -0
- package/dist/lib/toolRegistry.js +58 -0
- package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
- package/dist/lib/tools/collectionsSearchTool.js +86 -0
- package/dist/lib/tools/generateImageTool.d.ts +16 -0
- package/dist/lib/tools/generateImageTool.js +119 -0
- package/dist/lib/tools/inspectImageTool.d.ts +21 -0
- package/dist/lib/tools/inspectImageTool.js +97 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
- package/dist/lib/tools/mcpToolFactory.js +116 -0
- package/dist/lib/tools/toolContract.d.ts +78 -0
- package/dist/lib/tools/toolContract.js +129 -0
- package/dist/lib/tools/webExtractTool.d.ts +70 -0
- package/dist/lib/tools/webExtractTool.js +461 -0
- package/dist/lib/tools/webSearchTool.d.ts +53 -0
- package/dist/lib/tools/webSearchTool.js +126 -0
- package/dist/lib/tools/wikiTools.d.ts +112 -0
- package/dist/lib/tools/wikiTools.js +697 -0
- package/dist/lib/tools/xSearchTool.d.ts +61 -0
- package/dist/lib/tools/xSearchTool.js +125 -0
- package/dist/lib/wiki/agentRun.d.ts +39 -0
- package/dist/lib/wiki/agentRun.js +79 -0
- package/dist/lib/wiki/builder.d.ts +71 -0
- package/dist/lib/wiki/builder.js +165 -0
- package/dist/lib/wiki/dive.d.ts +56 -0
- package/dist/lib/wiki/dive.js +208 -0
- package/dist/lib/wiki/entities.d.ts +220 -0
- package/dist/lib/wiki/entities.js +661 -0
- package/dist/lib/wiki/extract.d.ts +97 -0
- package/dist/lib/wiki/extract.js +305 -0
- package/dist/lib/wiki/fill.d.ts +37 -0
- package/dist/lib/wiki/fill.js +175 -0
- package/dist/lib/wiki/format.d.ts +132 -0
- package/dist/lib/wiki/format.js +177 -0
- package/dist/lib/wiki/graph.d.ts +80 -0
- package/dist/lib/wiki/graph.js +147 -0
- package/dist/lib/wiki/lint.d.ts +58 -0
- package/dist/lib/wiki/lint.js +267 -0
- package/dist/lib/wiki/markdown.d.ts +127 -0
- package/dist/lib/wiki/markdown.js +336 -0
- package/dist/lib/wiki/pipeline.d.ts +69 -0
- package/dist/lib/wiki/pipeline.js +119 -0
- package/dist/lib/wiki/vault.d.ts +84 -0
- package/dist/lib/wiki/vault.js +194 -0
- package/dist/scripts/a2a_server.d.ts +2 -0
- package/dist/scripts/a2a_server.js +997 -0
- package/dist/scripts/syndicate_chat.d.ts +2 -0
- package/dist/scripts/syndicate_chat.js +485 -0
- package/package.json +96 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/webExtractTool.ts — deterministic URL → clean-text reading.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* `web_search` routes to each provider's SERVER-SIDE search (§7.5): the
|
|
6
|
+
* provider decides which pages to fetch and which snippets to quote. An
|
|
7
|
+
* agent could never say "now read THIS article in full" — for a news
|
|
8
|
+
* agent that is the difference between arbitrating what a search chose
|
|
9
|
+
* to quote and actually reading the five articles being compared.
|
|
10
|
+
* `web_extract` closes that gap: a plain client-side function tool that
|
|
11
|
+
* fetches given URLs and returns the page's clean text. Deterministic,
|
|
12
|
+
* no LLM summarization, and KEYLESS — it also works on local Ollama
|
|
13
|
+
* agents, the first web capability local mode gets.
|
|
14
|
+
*
|
|
15
|
+
* DESIGN (cribbed from NousResearch/hermes-agent `web_extract`, adapted):
|
|
16
|
+
* - Up to 5 URLs per call; per-page character budget (default 15k,
|
|
17
|
+
* WEB_EXTRACT_CHAR_LIMIT env, clamped 2k–500k — deployment config,
|
|
18
|
+
* never YAML, the XAI_COLLECTION_IDS doctrine).
|
|
19
|
+
* - Over-budget pages return a head+tail window (~75%/25%) with an
|
|
20
|
+
* omission marker. Hermes spills full text to disk and tells the
|
|
21
|
+
* agent to page with read_file; melchizedek agents have no filesystem
|
|
22
|
+
* tools, so paging is an `offset` param instead: the full text is
|
|
23
|
+
* held in a 15-minute in-process cache and re-calling with
|
|
24
|
+
* `{urls:[same], offset:N}` continues reading without refetching.
|
|
25
|
+
* - Zero new dependencies: native fetch + a heuristic HTML→markdown
|
|
26
|
+
* pipeline (strip script/style/nav chrome, prefer <article>/<main>,
|
|
27
|
+
* headings/lists/links/blockquotes to markdown, entities decoded,
|
|
28
|
+
* base64 images never survive because <img> collapses to alt text).
|
|
29
|
+
*
|
|
30
|
+
* SECURITY (SSRF):
|
|
31
|
+
* Agents pass arbitrary URLs and this process may run server-side
|
|
32
|
+
* (A2A on Heroku), so http(s)-only plus a literal-host guard: loopback,
|
|
33
|
+
* RFC-1918, link-local/metadata (169.254.*), CGNAT, ULA/IPv6-local and
|
|
34
|
+
* localhost-ish hostnames are refused, and redirects are followed
|
|
35
|
+
* manually so every hop is re-checked. Deliberate v1 limit: the guard
|
|
36
|
+
* inspects literals only — it does not resolve DNS, so a public
|
|
37
|
+
* hostname that resolves privately can still slip; acceptable for a
|
|
38
|
+
* tool that runs with no ambient credentials, revisit if that changes.
|
|
39
|
+
*
|
|
40
|
+
* FAILURE CONTRACT: never throws. Each URL yields either its content
|
|
41
|
+
* block or an inline `Error:` block; one bad URL never sinks the rest.
|
|
42
|
+
*/
|
|
43
|
+
import { z } from 'zod';
|
|
44
|
+
import { defineTool, toFunctionTool } from "./toolContract.js";
|
|
45
|
+
export const WEB_EXTRACT_TOOL_NAME = 'web_extract';
|
|
46
|
+
const MAX_URLS = 5;
|
|
47
|
+
const DEFAULT_CHAR_LIMIT = 15_000;
|
|
48
|
+
const MIN_CHAR_LIMIT = 2_000;
|
|
49
|
+
const MAX_CHAR_LIMIT = 500_000;
|
|
50
|
+
const HEAD_FRACTION = 0.75;
|
|
51
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
52
|
+
const MAX_REDIRECTS = 5;
|
|
53
|
+
const MAX_FETCH_BYTES = 5 * 1024 * 1024;
|
|
54
|
+
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
55
|
+
const CACHE_MAX_ENTRIES = 50;
|
|
56
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
57
|
+
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 melchizedek-web-extract';
|
|
58
|
+
/** Per-page char budget from the environment; malformed values warn and fall back. */
|
|
59
|
+
export function extractCharLimit() {
|
|
60
|
+
const raw = process.env.WEB_EXTRACT_CHAR_LIMIT?.trim();
|
|
61
|
+
if (!raw)
|
|
62
|
+
return DEFAULT_CHAR_LIMIT;
|
|
63
|
+
const value = Number(raw);
|
|
64
|
+
if (!Number.isFinite(value)) {
|
|
65
|
+
console.warn(`[web_extract] WEB_EXTRACT_CHAR_LIMIT="${raw}" is not a number — using ${DEFAULT_CHAR_LIMIT}.`);
|
|
66
|
+
return DEFAULT_CHAR_LIMIT;
|
|
67
|
+
}
|
|
68
|
+
return Math.max(MIN_CHAR_LIMIT, Math.min(Math.floor(value), MAX_CHAR_LIMIT));
|
|
69
|
+
}
|
|
70
|
+
// ── SSRF guard ───────────────────────────────────────────────────────────────
|
|
71
|
+
/** Reason the host is refused, or null when it looks routable. Literals only — see header. */
|
|
72
|
+
export function blockedHostReason(hostname) {
|
|
73
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
74
|
+
if (host === 'localhost' ||
|
|
75
|
+
host.endsWith('.localhost') ||
|
|
76
|
+
host.endsWith('.local') ||
|
|
77
|
+
host.endsWith('.internal') ||
|
|
78
|
+
host.endsWith('.home.arpa')) {
|
|
79
|
+
return 'local hostname';
|
|
80
|
+
}
|
|
81
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
82
|
+
if (v4) {
|
|
83
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
84
|
+
if (a === 0 || a === 10 || a === 127)
|
|
85
|
+
return 'private/loopback IPv4';
|
|
86
|
+
if (a === 169 && b === 254)
|
|
87
|
+
return 'link-local/metadata IPv4';
|
|
88
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
89
|
+
return 'private IPv4';
|
|
90
|
+
if (a === 192 && b === 168)
|
|
91
|
+
return 'private IPv4';
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
93
|
+
return 'CGNAT IPv4';
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (host.includes(':')) {
|
|
97
|
+
if (host === '::' || host === '::1')
|
|
98
|
+
return 'IPv6 loopback/unspecified';
|
|
99
|
+
if (/^f[cd]/.test(host))
|
|
100
|
+
return 'IPv6 unique-local';
|
|
101
|
+
if (host.startsWith('fe80'))
|
|
102
|
+
return 'IPv6 link-local';
|
|
103
|
+
if (host.startsWith('::ffff:'))
|
|
104
|
+
return blockedHostReason(host.slice(7)) ?? null;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
// ── HTML → markdown-ish text (zero-dep heuristic) ───────────────────────────
|
|
109
|
+
const NAMED_ENTITIES = {
|
|
110
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
|
111
|
+
mdash: '—', ndash: '–', hellip: '…', middot: '·', bull: '•',
|
|
112
|
+
lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
|
|
113
|
+
laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', times: '×', deg: '°',
|
|
114
|
+
};
|
|
115
|
+
export function decodeEntities(text) {
|
|
116
|
+
return text
|
|
117
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => safeCodePoint(parseInt(hex, 16)))
|
|
118
|
+
.replace(/&#(\d+);/g, (_, dec) => safeCodePoint(parseInt(dec, 10)))
|
|
119
|
+
.replace(/&([a-zA-Z]+);/g, (m, name) => NAMED_ENTITIES[name.toLowerCase()] ?? m);
|
|
120
|
+
}
|
|
121
|
+
function safeCodePoint(code) {
|
|
122
|
+
try {
|
|
123
|
+
return String.fromCodePoint(code);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Inline content of a captured tag body: tags out, entities decoded, whitespace collapsed. */
|
|
130
|
+
function inlineText(html) {
|
|
131
|
+
return decodeEntities(html.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
132
|
+
}
|
|
133
|
+
/** Remove every `<tag ...>...</tag>` block (non-greedy — nested same-name tags mis-slice, acceptable heuristic). */
|
|
134
|
+
function stripBlocks(html, tags) {
|
|
135
|
+
let out = html;
|
|
136
|
+
for (const tag of tags) {
|
|
137
|
+
out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
export function extractHtml(html) {
|
|
142
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
143
|
+
const title = titleMatch ? inlineText(titleMatch[1]) || null : null;
|
|
144
|
+
let doc = html.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
145
|
+
doc = stripBlocks(doc, ['script', 'style', 'noscript', 'template', 'svg', 'iframe', 'canvas', 'head']);
|
|
146
|
+
// Prefer the semantic article/main region; fall back to <body>, then the whole doc.
|
|
147
|
+
const region = longestMatch(doc, /<article\b[^>]*>[\s\S]*?<\/article>/gi) ??
|
|
148
|
+
longestMatch(doc, /<main\b[^>]*>[\s\S]*?<\/main>/gi) ??
|
|
149
|
+
longestMatch(doc, /<body\b[^>]*>[\s\S]*?<\/body>/gi) ??
|
|
150
|
+
doc;
|
|
151
|
+
let text = stripBlocks(region, ['nav', 'header', 'footer', 'aside', 'form', 'button', 'select', 'dialog']);
|
|
152
|
+
// Structure → markdown. Headings first (they consume their own bodies).
|
|
153
|
+
text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, body) => {
|
|
154
|
+
const line = inlineText(body);
|
|
155
|
+
return line ? `\n\n${'#'.repeat(Number(level))} ${line}\n\n` : '\n\n';
|
|
156
|
+
});
|
|
157
|
+
// Links: keep absolute http(s) targets as markdown; relative links keep text only.
|
|
158
|
+
text = text.replace(/<a\b[^>]*href=["']?(https?:\/\/[^"'\s>]+)["']?[^>]*>([\s\S]*?)<\/a>/gi, (_, href, body) => {
|
|
159
|
+
const label = inlineText(body);
|
|
160
|
+
if (!label)
|
|
161
|
+
return ' ';
|
|
162
|
+
return label === href ? ` ${href} ` : ` [${label}](${href}) `;
|
|
163
|
+
});
|
|
164
|
+
// Images collapse to alt text — base64 payloads and trackers never reach the model.
|
|
165
|
+
text = text.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
166
|
+
const alt = tag.match(/\balt=["']([^"']*)["']/i)?.[1]?.trim();
|
|
167
|
+
return alt ? ` [IMAGE: ${decodeEntities(alt)}] ` : ' ';
|
|
168
|
+
});
|
|
169
|
+
text = text
|
|
170
|
+
.replace(/<blockquote\b[^>]*>/gi, '\n\n> ')
|
|
171
|
+
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
172
|
+
.replace(/<(td|th)\b[^>]*>/gi, ' | ')
|
|
173
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
174
|
+
.replace(/<\/(p|div|section|tr|table|ul|ol|blockquote|figure|pre)>/gi, '\n')
|
|
175
|
+
.replace(/<p\b[^>]*>/gi, '\n\n');
|
|
176
|
+
text = decodeEntities(text.replace(/<[^>]+>/g, ' '));
|
|
177
|
+
text = text
|
|
178
|
+
.replace(/\r/g, '')
|
|
179
|
+
.replace(/[ \t ]+/g, ' ')
|
|
180
|
+
.replace(/ ?\n ?/g, '\n')
|
|
181
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
182
|
+
.trim();
|
|
183
|
+
return { title, text };
|
|
184
|
+
}
|
|
185
|
+
// ── Block-page detection ────────────────────────────────────────────────────
|
|
186
|
+
//
|
|
187
|
+
// The dangerous failure class is 200-with-garbage: Cloudflare challenges,
|
|
188
|
+
// consent walls, "enable JavaScript" shells, and paywall stubs all return
|
|
189
|
+
// HTTP 200 with real HTML, and the extractor would hand their boilerplate
|
|
190
|
+
// to the model AS IF it were the article. Detection is deterministic and
|
|
191
|
+
// lives here, not in agent prompts — a model judging "is this a real
|
|
192
|
+
// article?" fails silently one time in ten; a signature scan doesn't.
|
|
193
|
+
// Detected blocks collapse into the same labeled `Error:` shape as an
|
|
194
|
+
// HTTP 403, so prompts only ever govern the RESPONSE to a known failure.
|
|
195
|
+
//
|
|
196
|
+
// False-positive guards: signature scans only run on SHORT extractions
|
|
197
|
+
// (a real article quoting "just a moment" is long); paywall phrasing is
|
|
198
|
+
// held to a tighter threshold than bot-check phrasing because it also
|
|
199
|
+
// appears in the footers of readable articles.
|
|
200
|
+
/** Extraction lengths at or below these invite the corresponding scan. */
|
|
201
|
+
const BOT_CHECK_SCAN_MAX_CHARS = 2_000;
|
|
202
|
+
const PAYWALL_SCAN_MAX_CHARS = 1_500;
|
|
203
|
+
/** A big HTML document that extracts to almost nothing is a JS shell. */
|
|
204
|
+
const THIN_TEXT_CHARS = 400;
|
|
205
|
+
const THIN_HTML_MIN_BYTES = 20_000;
|
|
206
|
+
const BOT_CHECK_SIGNATURES = [
|
|
207
|
+
[/just a moment/i, 'Cloudflare challenge'],
|
|
208
|
+
[/checking your browser before accessing/i, 'Cloudflare challenge'],
|
|
209
|
+
[/verify(?:ing)? (?:that )?you are (?:a )?human/i, 'bot check'],
|
|
210
|
+
[/enable javascript and cookies to continue/i, 'bot check'],
|
|
211
|
+
[/are you a robot/i, 'bot check'],
|
|
212
|
+
[/access to this page has been denied/i, 'access-denied page'],
|
|
213
|
+
[/pardon our interruption/i, 'bot check'],
|
|
214
|
+
];
|
|
215
|
+
const PAYWALL_SIGNATURES = [
|
|
216
|
+
[/subscribe to (?:read|continue)/i, 'paywall'],
|
|
217
|
+
[/subscription (?:is )?required/i, 'paywall'],
|
|
218
|
+
[/to continue reading(?:,| this)/i, 'paywall'],
|
|
219
|
+
[/create a free account to (?:read|continue)/i, 'registration wall'],
|
|
220
|
+
[/log in to (?:read|continue)/i, 'registration wall'],
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Reason the extraction looks like a block page rather than content, or
|
|
224
|
+
* null when it looks legitimate. `htmlBytes` is the raw document size —
|
|
225
|
+
* the thinness check needs it to tell "big page, no text" (a JS shell)
|
|
226
|
+
* from a genuinely small page, which stays valid.
|
|
227
|
+
*/
|
|
228
|
+
export function blockedPageReason(text, htmlBytes) {
|
|
229
|
+
if (text.length <= BOT_CHECK_SCAN_MAX_CHARS) {
|
|
230
|
+
for (const [pattern, label] of BOT_CHECK_SIGNATURES) {
|
|
231
|
+
const hit = text.match(pattern);
|
|
232
|
+
if (hit)
|
|
233
|
+
return `page is a ${label} (detected: "${hit[0]}") — content unavailable`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (text.length <= PAYWALL_SCAN_MAX_CHARS) {
|
|
237
|
+
for (const [pattern, label] of PAYWALL_SIGNATURES) {
|
|
238
|
+
const hit = text.match(pattern);
|
|
239
|
+
if (hit)
|
|
240
|
+
return `page is behind a ${label} (detected: "${hit[0]}") — content unavailable`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (htmlBytes >= THIN_HTML_MIN_BYTES && text.length < THIN_TEXT_CHARS) {
|
|
244
|
+
return (`page yielded almost no text (${text.length} chars from ${htmlBytes} bytes of HTML) — ` +
|
|
245
|
+
'likely requires JavaScript or blocks automated readers');
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
function longestMatch(html, pattern) {
|
|
250
|
+
let best = null;
|
|
251
|
+
for (const match of html.match(pattern) ?? []) {
|
|
252
|
+
if (best === null || match.length > best.length)
|
|
253
|
+
best = match;
|
|
254
|
+
}
|
|
255
|
+
return best;
|
|
256
|
+
}
|
|
257
|
+
// ── Windowing (budget + paging) ─────────────────────────────────────────────
|
|
258
|
+
/** Continuation hint rendered into omission markers — the agent copies it verbatim. */
|
|
259
|
+
function continueCall(url, offset) {
|
|
260
|
+
return `web_extract({"urls":["${url}"],"offset":${offset}})`;
|
|
261
|
+
}
|
|
262
|
+
export function windowContent(text, url, limit, offset) {
|
|
263
|
+
const total = text.length;
|
|
264
|
+
if (offset !== undefined) {
|
|
265
|
+
if (offset >= total) {
|
|
266
|
+
return `[offset ${offset} is past the end — the cached page is ${total} chars long]`;
|
|
267
|
+
}
|
|
268
|
+
const end = Math.min(offset + limit, total);
|
|
269
|
+
const slice = text.slice(offset, end);
|
|
270
|
+
const head = `[resuming at char ${offset} of ${total}]\n\n`;
|
|
271
|
+
const foot = end < total
|
|
272
|
+
? `\n\n[... ${total - end} chars remain — ${continueCall(url, end)} to continue ...]`
|
|
273
|
+
: '';
|
|
274
|
+
return head + slice + foot;
|
|
275
|
+
}
|
|
276
|
+
if (total <= limit)
|
|
277
|
+
return text;
|
|
278
|
+
const headLen = Math.floor(limit * HEAD_FRACTION);
|
|
279
|
+
const tailLen = limit - headLen;
|
|
280
|
+
const omittedFrom = headLen;
|
|
281
|
+
const omittedTo = total - tailLen;
|
|
282
|
+
return (text.slice(0, headLen) +
|
|
283
|
+
`\n\n[... chars ${omittedFrom}–${omittedTo} of ${total} omitted — ` +
|
|
284
|
+
`${continueCall(url, omittedFrom)} to continue reading ...]\n\n` +
|
|
285
|
+
text.slice(omittedTo));
|
|
286
|
+
}
|
|
287
|
+
const pageCache = new Map();
|
|
288
|
+
/** Test hook — the cache is process-global. */
|
|
289
|
+
export function clearWebExtractCache() {
|
|
290
|
+
pageCache.clear();
|
|
291
|
+
}
|
|
292
|
+
function cachedPage(url) {
|
|
293
|
+
const entry = pageCache.get(url);
|
|
294
|
+
if (!entry)
|
|
295
|
+
return null;
|
|
296
|
+
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) {
|
|
297
|
+
pageCache.delete(url);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return entry.page;
|
|
301
|
+
}
|
|
302
|
+
function cachePage(url, page) {
|
|
303
|
+
while (pageCache.size >= CACHE_MAX_ENTRIES) {
|
|
304
|
+
const oldest = pageCache.keys().next().value;
|
|
305
|
+
if (oldest === undefined)
|
|
306
|
+
break;
|
|
307
|
+
pageCache.delete(oldest);
|
|
308
|
+
}
|
|
309
|
+
pageCache.set(url, { page, fetchedAt: Date.now() });
|
|
310
|
+
}
|
|
311
|
+
/** Validate scheme + host; returns an error string or the parsed URL. */
|
|
312
|
+
function checkUrl(raw) {
|
|
313
|
+
let parsed;
|
|
314
|
+
try {
|
|
315
|
+
parsed = new URL(raw);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return `Error: "${raw}" is not a valid absolute URL.`;
|
|
319
|
+
}
|
|
320
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
321
|
+
return `Error: only http(s) URLs are supported (got ${parsed.protocol}//).`;
|
|
322
|
+
}
|
|
323
|
+
const blocked = blockedHostReason(parsed.hostname);
|
|
324
|
+
if (blocked)
|
|
325
|
+
return `Error: refusing to fetch ${parsed.hostname} (${blocked}).`;
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
/** Read the body with a hard byte cap so a huge response can't balloon memory. */
|
|
329
|
+
async function readBodyCapped(res) {
|
|
330
|
+
if (!res.body)
|
|
331
|
+
return '';
|
|
332
|
+
const reader = res.body.getReader();
|
|
333
|
+
const chunks = [];
|
|
334
|
+
let received = 0;
|
|
335
|
+
for (;;) {
|
|
336
|
+
const { done, value } = await reader.read();
|
|
337
|
+
if (done)
|
|
338
|
+
break;
|
|
339
|
+
received += value.byteLength;
|
|
340
|
+
if (received > MAX_FETCH_BYTES) {
|
|
341
|
+
chunks.push(value.subarray(0, value.byteLength - (received - MAX_FETCH_BYTES)));
|
|
342
|
+
await reader.cancel();
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
chunks.push(value);
|
|
346
|
+
}
|
|
347
|
+
const decoder = new TextDecoder('utf-8'); // charset sniffing deliberately skipped
|
|
348
|
+
return chunks.map((c) => decoder.decode(c, { stream: true })).join('') + decoder.decode();
|
|
349
|
+
}
|
|
350
|
+
/** Fetch with manual redirect following so every hop passes the SSRF guard. */
|
|
351
|
+
async function fetchPage(url) {
|
|
352
|
+
let current = url;
|
|
353
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
354
|
+
let res;
|
|
355
|
+
try {
|
|
356
|
+
res = await fetch(current, {
|
|
357
|
+
headers: {
|
|
358
|
+
'User-Agent': USER_AGENT,
|
|
359
|
+
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8',
|
|
360
|
+
},
|
|
361
|
+
redirect: 'manual',
|
|
362
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
return `Error: fetch failed (${msg}).`;
|
|
368
|
+
}
|
|
369
|
+
if (res.status >= 300 && res.status < 400) {
|
|
370
|
+
const location = res.headers.get('location');
|
|
371
|
+
if (!location)
|
|
372
|
+
return `Error: HTTP ${res.status} redirect with no Location header.`;
|
|
373
|
+
let next;
|
|
374
|
+
try {
|
|
375
|
+
next = new URL(location, current);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return `Error: redirect to unparseable URL "${location}".`;
|
|
379
|
+
}
|
|
380
|
+
const checked = checkUrl(next.toString());
|
|
381
|
+
if (typeof checked === 'string')
|
|
382
|
+
return checked;
|
|
383
|
+
current = checked;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (!res.ok)
|
|
387
|
+
return `Error: HTTP ${res.status} ${res.statusText || ''}`.trim() + '.';
|
|
388
|
+
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
389
|
+
if (contentType.includes('application/pdf')) {
|
|
390
|
+
return 'Error: PDF extraction is not supported (keyless v1 reads HTML/text only).';
|
|
391
|
+
}
|
|
392
|
+
const isHtml = contentType.includes('html') || contentType === '';
|
|
393
|
+
const isText = contentType.startsWith('text/') ||
|
|
394
|
+
contentType.includes('json') ||
|
|
395
|
+
contentType.includes('xml');
|
|
396
|
+
if (!isHtml && !isText) {
|
|
397
|
+
return `Error: unsupported content-type "${contentType}".`;
|
|
398
|
+
}
|
|
399
|
+
const body = await readBodyCapped(res);
|
|
400
|
+
if (!isHtml)
|
|
401
|
+
return { title: null, text: body.trim() };
|
|
402
|
+
const page = extractHtml(body);
|
|
403
|
+
// Block pages are errors, not content — and deliberately NOT cached,
|
|
404
|
+
// so a later run against a recovered site fetches fresh.
|
|
405
|
+
const blocked = blockedPageReason(page.text, body.length);
|
|
406
|
+
if (blocked)
|
|
407
|
+
return `Error: ${blocked}`;
|
|
408
|
+
return page;
|
|
409
|
+
}
|
|
410
|
+
return `Error: more than ${MAX_REDIRECTS} redirects.`;
|
|
411
|
+
}
|
|
412
|
+
// ── The contract ────────────────────────────────────────────────────────────
|
|
413
|
+
export const webExtractContract = defineTool({
|
|
414
|
+
name: WEB_EXTRACT_TOOL_NAME,
|
|
415
|
+
description: 'Read web pages in full. Fetches each URL directly and returns the clean page text ' +
|
|
416
|
+
'(markdown-ish, no summarization) — use it after web_search, or with any known URL, ' +
|
|
417
|
+
'to read an article beyond its headline or snippet. Long pages return a head+tail ' +
|
|
418
|
+
'window with an omission marker; to keep reading, call again with a SINGLE url and ' +
|
|
419
|
+
'the `offset` from the marker. Up to 5 URLs per call. HTML and plain text only (no PDFs).',
|
|
420
|
+
schema: z.object({
|
|
421
|
+
urls: z
|
|
422
|
+
.array(z.string())
|
|
423
|
+
.min(1)
|
|
424
|
+
.max(MAX_URLS)
|
|
425
|
+
.describe(`Absolute http(s) URLs to read (1–${MAX_URLS}).`),
|
|
426
|
+
offset: z
|
|
427
|
+
.number()
|
|
428
|
+
.int()
|
|
429
|
+
.min(0)
|
|
430
|
+
.optional()
|
|
431
|
+
.describe('Character offset to resume reading a previously truncated page from. ' +
|
|
432
|
+
'Only valid with exactly one url; copy the value from the omission marker.'),
|
|
433
|
+
}),
|
|
434
|
+
execute: async ({ urls, offset }) => {
|
|
435
|
+
if (offset !== undefined && urls.length !== 1) {
|
|
436
|
+
return 'Error: `offset` is only valid with exactly one url.';
|
|
437
|
+
}
|
|
438
|
+
const limit = extractCharLimit();
|
|
439
|
+
const blocks = await Promise.all(urls.map(async (raw) => {
|
|
440
|
+
const checked = checkUrl(raw.trim());
|
|
441
|
+
if (typeof checked === 'string')
|
|
442
|
+
return `=== ${raw} ===\n${checked}`;
|
|
443
|
+
const url = checked.toString();
|
|
444
|
+
let page = cachedPage(url);
|
|
445
|
+
if (!page) {
|
|
446
|
+
const fetched = await fetchPage(checked);
|
|
447
|
+
if (typeof fetched === 'string')
|
|
448
|
+
return `=== ${url} ===\n${fetched}`;
|
|
449
|
+
page = fetched;
|
|
450
|
+
cachePage(url, page);
|
|
451
|
+
}
|
|
452
|
+
if (!page.text)
|
|
453
|
+
return `=== ${url} ===\nError: page yielded no readable text.`;
|
|
454
|
+
const header = page.title ? `=== ${url} ===\nTitle: ${page.title}\n\n` : `=== ${url} ===\n\n`;
|
|
455
|
+
return header + windowContent(page.text, url, limit, offset);
|
|
456
|
+
}));
|
|
457
|
+
return blocks.join('\n\n');
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
/** ADK surface, ready for the registry. */
|
|
461
|
+
export const webExtractTool = toFunctionTool(webExtractContract);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/webSearchTool.ts — provider-agnostic web search.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* ADK's built-in GOOGLE_SEARCH tool is Gemini grounding — it THROWS for any
|
|
6
|
+
* non-Gemini model. But every cloud provider this framework routes to has
|
|
7
|
+
* its own native web search: Gemini grounding, Anthropic's web_search
|
|
8
|
+
* server tool, OpenAI's Responses web_search tool, xAI's live search.
|
|
9
|
+
* Declaring `web_search` in an agent's YAML tools list routes to whichever
|
|
10
|
+
* of those the agent's model supports — the YAML stays model-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* HOW IT WORKS:
|
|
13
|
+
* ADK calls processLlmRequest() on every tool while building the request,
|
|
14
|
+
* with llmRequest.model already set (LlmAgent resolves the model first).
|
|
15
|
+
* - Gemini model → push { googleSearch: {} } into config.tools, exactly
|
|
16
|
+
* what ADK's own GoogleSearchTool does for Gemini 2+.
|
|
17
|
+
* - Anything else → self-register in llmRequest.toolsDict as a sentinel.
|
|
18
|
+
* _getDeclaration() returns undefined, so the sentinel is never sent as
|
|
19
|
+
* a client-side function tool; each provider adapter calls
|
|
20
|
+
* wantsWebSearch(llmRequest) and enables its provider's NATIVE search
|
|
21
|
+
* (Claude: web_search server tool; GPT: Responses web_search; Grok:
|
|
22
|
+
* Agent Tools web_search). Models with no native search (local Ollama) omit
|
|
23
|
+
* the tool and warn — local mode stays keyless by design.
|
|
24
|
+
*
|
|
25
|
+
* `google_search` remains available as the Gemini-only ADK tool for
|
|
26
|
+
* backward compatibility; new YAMLs should declare `web_search`.
|
|
27
|
+
*/
|
|
28
|
+
import { BaseTool } from '@google/adk';
|
|
29
|
+
import type { LlmRequest } from '@google/adk';
|
|
30
|
+
export declare const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
31
|
+
export declare class WebSearchTool extends BaseTool {
|
|
32
|
+
constructor();
|
|
33
|
+
/** Never a client-side function tool — providers run search server-side. */
|
|
34
|
+
_getDeclaration(): undefined;
|
|
35
|
+
/** Server-side tool: nothing to execute locally. */
|
|
36
|
+
runAsync(): Promise<unknown>;
|
|
37
|
+
processLlmRequest({ llmRequest }: {
|
|
38
|
+
llmRequest: LlmRequest;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/** Shared instance, mirroring ADK's GOOGLE_SEARCH constant pattern. */
|
|
42
|
+
export declare const WEB_SEARCH: WebSearchTool;
|
|
43
|
+
/** True when the agent requested web search (non-Gemini sentinel path). */
|
|
44
|
+
export declare function wantsWebSearch(llmRequest: LlmRequest): boolean;
|
|
45
|
+
/** True for toolsDict entries adapters must NOT send as function tools. */
|
|
46
|
+
export declare function isWebSearchSentinel(tool: unknown): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Optional server-side domain filters for xAI's web_search tool, read from
|
|
49
|
+
* the environment at request-build time (see WHICH CONSTRAINTS above). With
|
|
50
|
+
* nothing configured this returns {} and the tool ships bare, exactly as it
|
|
51
|
+
* always has. xAI-only: apply on the xAI path, never on OpenAI's web_search.
|
|
52
|
+
*/
|
|
53
|
+
export declare function xaiWebSearchParamsFromEnv(): Record<string, unknown>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/webSearchTool.ts — provider-agnostic web search.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* ADK's built-in GOOGLE_SEARCH tool is Gemini grounding — it THROWS for any
|
|
6
|
+
* non-Gemini model. But every cloud provider this framework routes to has
|
|
7
|
+
* its own native web search: Gemini grounding, Anthropic's web_search
|
|
8
|
+
* server tool, OpenAI's Responses web_search tool, xAI's live search.
|
|
9
|
+
* Declaring `web_search` in an agent's YAML tools list routes to whichever
|
|
10
|
+
* of those the agent's model supports — the YAML stays model-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* HOW IT WORKS:
|
|
13
|
+
* ADK calls processLlmRequest() on every tool while building the request,
|
|
14
|
+
* with llmRequest.model already set (LlmAgent resolves the model first).
|
|
15
|
+
* - Gemini model → push { googleSearch: {} } into config.tools, exactly
|
|
16
|
+
* what ADK's own GoogleSearchTool does for Gemini 2+.
|
|
17
|
+
* - Anything else → self-register in llmRequest.toolsDict as a sentinel.
|
|
18
|
+
* _getDeclaration() returns undefined, so the sentinel is never sent as
|
|
19
|
+
* a client-side function tool; each provider adapter calls
|
|
20
|
+
* wantsWebSearch(llmRequest) and enables its provider's NATIVE search
|
|
21
|
+
* (Claude: web_search server tool; GPT: Responses web_search; Grok:
|
|
22
|
+
* Agent Tools web_search). Models with no native search (local Ollama) omit
|
|
23
|
+
* the tool and warn — local mode stays keyless by design.
|
|
24
|
+
*
|
|
25
|
+
* `google_search` remains available as the Gemini-only ADK tool for
|
|
26
|
+
* backward compatibility; new YAMLs should declare `web_search`.
|
|
27
|
+
*/
|
|
28
|
+
import { BaseTool } from '@google/adk';
|
|
29
|
+
import { providerForModel } from "../models/providerMap.js";
|
|
30
|
+
export const WEB_SEARCH_TOOL_NAME = 'web_search';
|
|
31
|
+
export class WebSearchTool extends BaseTool {
|
|
32
|
+
constructor() {
|
|
33
|
+
super({
|
|
34
|
+
name: WEB_SEARCH_TOOL_NAME,
|
|
35
|
+
description: "Web search via the agent model's native search capability " +
|
|
36
|
+
'(Gemini grounding / Anthropic web_search / OpenAI web_search / xAI live search).',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Never a client-side function tool — providers run search server-side. */
|
|
40
|
+
_getDeclaration() {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Server-side tool: nothing to execute locally. */
|
|
44
|
+
async runAsync() {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
async processLlmRequest({ llmRequest }) {
|
|
48
|
+
if (!llmRequest.model)
|
|
49
|
+
return;
|
|
50
|
+
if (providerForModel(llmRequest.model) === 'gemini') {
|
|
51
|
+
// Native Gemini grounding — same wire shape ADK's GoogleSearchTool
|
|
52
|
+
// emits for Gemini 2+ (we can't reuse that class: it throws for
|
|
53
|
+
// non-Gemini models, and this tool must never throw on routing).
|
|
54
|
+
llmRequest.config = llmRequest.config ?? {};
|
|
55
|
+
llmRequest.config.tools = llmRequest.config.tools ?? [];
|
|
56
|
+
llmRequest.config.tools.push({ googleSearch: {} });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Non-Gemini: leave a sentinel for the provider adapter. Safe because
|
|
60
|
+
// _getDeclaration() is undefined — ADK's appendTools never adds this
|
|
61
|
+
// to the function-tool declarations sent to the model.
|
|
62
|
+
llmRequest.toolsDict[WEB_SEARCH_TOOL_NAME] = this;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Shared instance, mirroring ADK's GOOGLE_SEARCH constant pattern. */
|
|
66
|
+
export const WEB_SEARCH = new WebSearchTool();
|
|
67
|
+
/** True when the agent requested web search (non-Gemini sentinel path). */
|
|
68
|
+
export function wantsWebSearch(llmRequest) {
|
|
69
|
+
return llmRequest.toolsDict?.[WEB_SEARCH_TOOL_NAME] instanceof WebSearchTool;
|
|
70
|
+
}
|
|
71
|
+
/** True for toolsDict entries adapters must NOT send as function tools. */
|
|
72
|
+
export function isWebSearchSentinel(tool) {
|
|
73
|
+
return tool instanceof WebSearchTool;
|
|
74
|
+
}
|
|
75
|
+
// ── xAI-only server-side constraints ─────────────────────────────────────────
|
|
76
|
+
//
|
|
77
|
+
// WHICH CONSTRAINTS (verified against docs.x.ai/developers/tools/web-search,
|
|
78
|
+
// 2026-08-08): xAI's web_search accepts domain filters — allowed_domains /
|
|
79
|
+
// excluded_domains (≤5 each, mutually exclusive), nested under a `filters`
|
|
80
|
+
// object on the OpenAI-compatible wire — plus enable_image_search /
|
|
81
|
+
// enable_image_understanding booleans (not plumbed here). Unlike x_search it
|
|
82
|
+
// accepts NO from_date/to_date; there is no server-side date bound for web
|
|
83
|
+
// results, so timeliness must come from the prompt or from x_search.
|
|
84
|
+
//
|
|
85
|
+
// Same doctrine as xSearchTool.xSearchParamsFromEnv: constraints are
|
|
86
|
+
// deployment configuration (XAI_WEB_SEARCH_ALLOWED_DOMAINS /
|
|
87
|
+
// XAI_WEB_SEARCH_EXCLUDED_DOMAINS, comma-separated), never YAML — the YAML
|
|
88
|
+
// stays shareable. Misconfiguration degrades with a warning, never fatally:
|
|
89
|
+
// oversize domain lists truncate, and when both lists are set the allowlist
|
|
90
|
+
// wins. OpenAI's web_search takes no such params — callers must apply this
|
|
91
|
+
// helper on the xAI path only.
|
|
92
|
+
/** xAI's documented ceiling on allowed_domains / excluded_domains. */
|
|
93
|
+
const XAI_WEB_SEARCH_MAX_DOMAINS = 5;
|
|
94
|
+
/** Comma-separated domains from an env var; trimmed, capped at xAI's limit. */
|
|
95
|
+
function domainsFromEnv(name) {
|
|
96
|
+
const domains = (process.env[name] ?? '')
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((d) => d.trim())
|
|
99
|
+
.filter(Boolean);
|
|
100
|
+
if (domains.length > XAI_WEB_SEARCH_MAX_DOMAINS) {
|
|
101
|
+
console.warn(`[web_search] ${name} lists ${domains.length} domains — xAI caps the ` +
|
|
102
|
+
`list at ${XAI_WEB_SEARCH_MAX_DOMAINS}; keeping the first ${XAI_WEB_SEARCH_MAX_DOMAINS}.`);
|
|
103
|
+
return domains.slice(0, XAI_WEB_SEARCH_MAX_DOMAINS);
|
|
104
|
+
}
|
|
105
|
+
return domains;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Optional server-side domain filters for xAI's web_search tool, read from
|
|
109
|
+
* the environment at request-build time (see WHICH CONSTRAINTS above). With
|
|
110
|
+
* nothing configured this returns {} and the tool ships bare, exactly as it
|
|
111
|
+
* always has. xAI-only: apply on the xAI path, never on OpenAI's web_search.
|
|
112
|
+
*/
|
|
113
|
+
export function xaiWebSearchParamsFromEnv() {
|
|
114
|
+
const allowed = domainsFromEnv('XAI_WEB_SEARCH_ALLOWED_DOMAINS');
|
|
115
|
+
const excluded = domainsFromEnv('XAI_WEB_SEARCH_EXCLUDED_DOMAINS');
|
|
116
|
+
if (allowed.length > 0 && excluded.length > 0) {
|
|
117
|
+
console.warn('[web_search] XAI_WEB_SEARCH_ALLOWED_DOMAINS and _EXCLUDED_DOMAINS are ' +
|
|
118
|
+
'mutually exclusive (xAI rejects both together) — keeping the ' +
|
|
119
|
+
'allowlist, dropping the exclusions.');
|
|
120
|
+
}
|
|
121
|
+
if (allowed.length > 0)
|
|
122
|
+
return { filters: { allowed_domains: allowed } };
|
|
123
|
+
if (excluded.length > 0)
|
|
124
|
+
return { filters: { excluded_domains: excluded } };
|
|
125
|
+
return {};
|
|
126
|
+
}
|