@mlx-bun/module-chat 0.0.0-stage → 1.0.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.
@@ -0,0 +1,108 @@
1
+ // Durable per-tool approval choices for web chat. Stored in the module's `approvals`
2
+ // storage entry (`tool-approvals.json` under MLX_BUN_HOME) with mode 0600; absent,
3
+ // corrupt, or unknown-version data means ask again.
4
+
5
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ export const TOOL_APPROVALS_VERSION = 1 as const;
9
+
10
+ /** On-disk shape. `allows` maps tool name -> true (present+true = always
11
+ * allow; absent = ask every time, the default). A plain boolean map (not a
12
+ * richer per-tool record) is deliberately the whole shape for v1 — nothing
13
+ * here needs migrating yet, but the version field exists so a future
14
+ * richer shape (e.g. per-tool scoping, expiry) has somewhere to land. */
15
+ export interface ToolApprovalsFileV1 {
16
+ version: 1;
17
+ allows: Record<string, true>;
18
+ }
19
+
20
+ /** Anything this module can load and return today. Widen this union (not
21
+ * ToolApprovalsFileV1) when a v2 shape ships, and teach loadToolApprovals'
22
+ * migration step to upgrade older files in place. */
23
+ export type ToolApprovalsFile = ToolApprovalsFileV1;
24
+
25
+ const EMPTY: ToolApprovalsFileV1 = { version: 1, allows: {} };
26
+
27
+ /**
28
+ * Load the durable approvals file, migrating forward if needed.
29
+ *
30
+ * Never throws: a missing file, unreadable file, or corrupt JSON all
31
+ * degrade to the empty default (ask every time) rather than crashing the
32
+ * approval gate — a config-file bug must never make mutating tools MORE
33
+ * permissive by accident, and "ask every time" is the safe failure mode.
34
+ */
35
+ export function loadToolApprovals(path: string): ToolApprovalsFile {
36
+ let raw: string;
37
+ try {
38
+ raw = readFileSync(path, "utf8");
39
+ } catch {
40
+ return { ...EMPTY, allows: {} };
41
+ }
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ } catch {
46
+ return { ...EMPTY, allows: {} };
47
+ }
48
+ return migrate(parsed);
49
+ }
50
+
51
+ /**
52
+ * Migrate an arbitrary parsed JSON value to the current ToolApprovalsFile
53
+ * shape. Unknown/missing `version` is treated as "pre-versioning" (there
54
+ * isn't one yet — this IS v1 — but the shape is defensive against a
55
+ * hand-edited or truncated file rather than assuming well-formedness).
56
+ * Unknown future versions (a downgrade scenario) fall back to empty rather
57
+ * than guessing at an unfamiliar shape.
58
+ */
59
+ function migrate(parsed: unknown): ToolApprovalsFile {
60
+ if (!parsed || typeof parsed !== "object") return { ...EMPTY, allows: {} };
61
+ const obj = parsed as Record<string, unknown>;
62
+ const version = obj.version;
63
+ if (version === 1 || version === undefined) {
64
+ const allowsRaw = obj.allows;
65
+ const allows: Record<string, true> = {};
66
+ if (allowsRaw && typeof allowsRaw === "object") {
67
+ for (const [k, v] of Object.entries(allowsRaw as Record<string, unknown>)) {
68
+ if (v === true) allows[k] = true;
69
+ }
70
+ }
71
+ return { version: 1, allows };
72
+ }
73
+ // Unrecognized/future version: don't guess at its shape. Ask every time
74
+ // is always the safe degradation.
75
+ return { ...EMPTY, allows: {} };
76
+ }
77
+
78
+ function saveToolApprovals(file: ToolApprovalsFile, path: string): void {
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ writeFileSync(path, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
81
+ chmodSync(path, 0o600); // writeFileSync's mode is masked by umask on create
82
+ }
83
+
84
+ /** Whether `toolName` is currently in the durable always-allow set. */
85
+ export function isToolAlwaysAllowed(toolName: string, path: string): boolean {
86
+ return loadToolApprovals(path).allows[toolName] === true;
87
+ }
88
+
89
+ /** Persist "always allow this tool" for `toolName`. Idempotent. */
90
+ export function setToolAlwaysAllowed(toolName: string, path: string): ToolApprovalsFile {
91
+ const file = loadToolApprovals(path);
92
+ file.allows[toolName] = true;
93
+ saveToolApprovals(file, path);
94
+ return file;
95
+ }
96
+
97
+ /** Revoke a previously-granted always-allow (settings UI "forget" action). */
98
+ export function revokeToolAlwaysAllowed(toolName: string, path: string): ToolApprovalsFile {
99
+ const file = loadToolApprovals(path);
100
+ delete file.allows[toolName];
101
+ saveToolApprovals(file, path);
102
+ return file;
103
+ }
104
+
105
+ /** Full always-allow set, for a settings-panel listing. */
106
+ export function listAlwaysAllowedTools(path: string): string[] {
107
+ return Object.keys(loadToolApprovals(path).allows).sort();
108
+ }
@@ -0,0 +1,478 @@
1
+ // mlx-bun web-tools — outward-facing capabilities for the web chat agent:
2
+ // web_search, web_fetch, and weather. These are pi custom tools (defineTool)
3
+ // registered alongside the built-in coding tools so the local model can pull
4
+ // in current, real-world information instead of only inspecting the machine.
5
+ //
6
+ // Design notes:
7
+ // - Keyless by default. weather uses Open-Meteo (no key, no anti-bot),
8
+ // web_fetch is a plain HTTP GET, and web_search defaults to DuckDuckGo's
9
+ // keyless HTML endpoint. Setting TAVILY_API_KEY or BRAVE_API_KEY upgrades
10
+ // web_search to that provider's JSON API (more reliable, ranked results).
11
+ // - Non-mutating: none of these touch the user's filesystem, so the web
12
+ // chat's approval gate auto-allows them (they're not in GATED_TOOLS).
13
+ // They DO make outbound network requests — that's the one thing that
14
+ // leaves the machine, and only when the user asks for live info.
15
+ // - execute() never throws on a network/upstream failure: it returns the
16
+ // error as tool text so the model can read it and adapt (retry, tell the
17
+ // user, fall back) rather than the turn aborting.
18
+ //
19
+ // The pure helpers (htmlToText, wmoCodeToText, parseDuckDuckGoHtml, the
20
+ // formatters) are exported and unit-tested without touching the network.
21
+
22
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
23
+ import { Type } from "typebox";
24
+ import {
25
+ fetchRestrictedHttpBytes,
26
+ type MediaFetchPolicy,
27
+ type RestrictedFetchDependencies,
28
+ } from "@mlx-bun/inference/input/media-fetch";
29
+
30
+ /** Tool names, exported so pi-web can add them to its tool allowlist. */
31
+ export const WEB_TOOL_NAMES = ["web_search", "web_fetch", "weather"] as const;
32
+
33
+ /** Browser-ish UA: some endpoints (DuckDuckGo) serve a challenge to blanks. */
34
+ const USER_AGENT =
35
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
36
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
37
+
38
+ const FETCH_TIMEOUT_MS = 20_000;
39
+ const MAX_FETCH_BYTES = 1024 * 1024;
40
+ /** Cap on text returned to the model from a fetched page. */
41
+ const MAX_FETCH_CHARS = 12_000;
42
+ const DEFAULT_SEARCH_RESULTS = 5;
43
+ const MAX_SEARCH_RESULTS = 10;
44
+
45
+ // ---- low-level fetch helper ------------------------------------------
46
+
47
+ /** Combine the tool's abort signal with a wall-clock timeout. */
48
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
49
+ const timeout = AbortSignal.timeout(ms);
50
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
51
+ }
52
+
53
+ export interface FetchResult {
54
+ ok: boolean;
55
+ status: number;
56
+ finalUrl: string;
57
+ contentType: string;
58
+ text: string;
59
+ }
60
+
61
+ export interface WebFetchOptions {
62
+ policy?: MediaFetchPolicy;
63
+ dependencies?: Partial<RestrictedFetchDependencies>;
64
+ }
65
+
66
+ const WEB_FETCH_POLICY: MediaFetchPolicy = {
67
+ allowPrivate: false,
68
+ privateDestinationHint:
69
+ "private/loopback destinations are never available to the model-driven web_fetch tool",
70
+ timeoutMs: FETCH_TIMEOUT_MS,
71
+ maxBytes: MAX_FETCH_BYTES,
72
+ maxRedirects: 5,
73
+ };
74
+
75
+ /** GET/POST a URL and read the body as text, with timeout + abort wiring. */
76
+ async function httpText(
77
+ url: string,
78
+ opts: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal; timeoutMs?: number },
79
+ ): Promise<FetchResult> {
80
+ const res = await fetch(url, {
81
+ method: opts.method ?? "GET",
82
+ headers: { "user-agent": USER_AGENT, ...opts.headers },
83
+ body: opts.body,
84
+ redirect: "follow",
85
+ signal: withTimeout(opts.signal, opts.timeoutMs ?? FETCH_TIMEOUT_MS),
86
+ });
87
+ const contentType = res.headers.get("content-type") ?? "";
88
+ const text = await res.text();
89
+ return { ok: res.ok, status: res.status, finalUrl: res.url || url, contentType, text };
90
+ }
91
+
92
+ /**
93
+ * Fetch a model-selected URL through the shared restricted-destination
94
+ * transport. Exported with dependency injection for deterministic,
95
+ * model-free security tests.
96
+ */
97
+ export async function fetchWebText(
98
+ url: string,
99
+ signal?: AbortSignal,
100
+ options: WebFetchOptions = {},
101
+ ): Promise<FetchResult> {
102
+ const result = await fetchRestrictedHttpBytes(
103
+ url,
104
+ "web",
105
+ options.policy ?? WEB_FETCH_POLICY,
106
+ {
107
+ signal,
108
+ dependencies: options.dependencies,
109
+ headers: {
110
+ "user-agent": USER_AGENT,
111
+ accept: "text/html,application/json,text/plain,*/*",
112
+ },
113
+ },
114
+ );
115
+ return {
116
+ ok: result.ok,
117
+ status: result.status,
118
+ finalUrl: result.finalUrl,
119
+ contentType: result.contentType,
120
+ text: new TextDecoder().decode(result.bytes),
121
+ };
122
+ }
123
+
124
+ /** Normalize anything thrown by fetch (timeout, DNS, abort) into a message. */
125
+ function describeFetchError(err: unknown): string {
126
+ if (err instanceof Error) {
127
+ if (err.name === "TimeoutError") return "request timed out";
128
+ if (err.name === "AbortError") return "request was cancelled";
129
+ return err.message;
130
+ }
131
+ return String(err);
132
+ }
133
+
134
+ // ---- pure helpers (unit-tested) --------------------------------------
135
+
136
+ const NAMED_ENTITIES: Record<string, string> = {
137
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#34": '"',
138
+ };
139
+
140
+ /** Decode the handful of HTML entities that show up in scraped text. */
141
+ export function decodeEntities(s: string): string {
142
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body: string) => {
143
+ if (body[0] === "#") {
144
+ const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
145
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
146
+ }
147
+ return NAMED_ENTITIES[body] ?? whole;
148
+ });
149
+ }
150
+
151
+ /** Strip all HTML tags from a fragment and decode entities (single-line). */
152
+ export function stripTags(html: string): string {
153
+ return decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
154
+ }
155
+
156
+ /** Extract the <title> of an HTML document, if any. */
157
+ export function extractTitle(html: string): string | undefined {
158
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
159
+ return m ? stripTags(m[1] ?? "") : undefined;
160
+ }
161
+
162
+ /**
163
+ * Reduce an HTML document to readable plain text: drop script/style/head,
164
+ * turn block-level boundaries into newlines, strip remaining tags, decode
165
+ * entities, and collapse runaway whitespace.
166
+ */
167
+ export function htmlToText(html: string): string {
168
+ let s = html
169
+ .replace(/<!--[\s\S]*?-->/g, "")
170
+ .replace(/<(script|style|noscript|template|svg)[^>]*>[\s\S]*?<\/\1>/gi, "")
171
+ .replace(/<head[^>]*>[\s\S]*?<\/head>/gi, "");
172
+ // Block-level close/break tags become newlines so structure survives.
173
+ s = s.replace(/<\/(p|div|section|article|li|ul|ol|tr|h[1-6]|header|footer|nav|blockquote)>/gi, "\n");
174
+ s = s.replace(/<br\s*\/?>/gi, "\n");
175
+ s = s.replace(/<[^>]+>/g, "");
176
+ s = decodeEntities(s);
177
+ // Collapse spaces/tabs, trim each line, squeeze blank-line runs.
178
+ s = s.replace(/[ \t\f\v]+/g, " ");
179
+ s = s.split("\n").map((line) => line.trim()).join("\n");
180
+ s = s.replace(/\n{3,}/g, "\n\n");
181
+ return s.trim();
182
+ }
183
+
184
+ /** Clamp text to a max length, appending a clear truncation marker. */
185
+ export function truncate(text: string, max: number): string {
186
+ if (text.length <= max) return text;
187
+ return text.slice(0, max) + `\n\n…[truncated ${text.length - max} more characters]`;
188
+ }
189
+
190
+ /** WMO weather interpretation codes (Open-Meteo) → human description. */
191
+ export function wmoCodeToText(code: number): string {
192
+ const map: Record<number, string> = {
193
+ 0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
194
+ 45: "fog", 48: "depositing rime fog",
195
+ 51: "light drizzle", 53: "moderate drizzle", 55: "dense drizzle",
196
+ 56: "light freezing drizzle", 57: "dense freezing drizzle",
197
+ 61: "slight rain", 63: "moderate rain", 65: "heavy rain",
198
+ 66: "light freezing rain", 67: "heavy freezing rain",
199
+ 71: "slight snowfall", 73: "moderate snowfall", 75: "heavy snowfall",
200
+ 77: "snow grains",
201
+ 80: "slight rain showers", 81: "moderate rain showers", 82: "violent rain showers",
202
+ 85: "slight snow showers", 86: "heavy snow showers",
203
+ 95: "thunderstorm", 96: "thunderstorm with slight hail", 99: "thunderstorm with heavy hail",
204
+ };
205
+ return map[code] ?? `unknown (code ${code})`;
206
+ }
207
+
208
+ export interface SearchResult {
209
+ title: string;
210
+ url: string;
211
+ snippet: string;
212
+ }
213
+
214
+ /** Resolve a DuckDuckGo redirect href (//duckduckgo.com/l/?uddg=…) to the real URL. */
215
+ export function resolveDdgHref(href: string): string {
216
+ const decoded = decodeEntities(href);
217
+ const m = decoded.match(/[?&]uddg=([^&]+)/);
218
+ if (m && m[1]) {
219
+ try {
220
+ return decodeURIComponent(m[1]);
221
+ } catch {
222
+ return decoded;
223
+ }
224
+ }
225
+ return decoded.startsWith("//") ? "https:" + decoded : decoded;
226
+ }
227
+
228
+ /**
229
+ * Parse the result list out of html.duckduckgo.com/html/ output. Pairs each
230
+ * result anchor (title + href) with the following snippet anchor by order.
231
+ * Returns [] when the page carries no results (e.g. a challenge/empty page).
232
+ */
233
+ export function parseDuckDuckGoHtml(html: string, limit: number): SearchResult[] {
234
+ const results: SearchResult[] = [];
235
+ const linkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
236
+ const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
237
+ const snippets: string[] = [];
238
+ let sm: RegExpExecArray | null;
239
+ while ((sm = snippetRe.exec(html)) !== null) snippets.push(stripTags(sm[1] ?? ""));
240
+ let lm: RegExpExecArray | null;
241
+ let i = 0;
242
+ while ((lm = linkRe.exec(html)) !== null && results.length < limit) {
243
+ const url = resolveDdgHref(lm[1] ?? "");
244
+ const title = stripTags(lm[2] ?? "");
245
+ if (!title || !url) {
246
+ i++;
247
+ continue;
248
+ }
249
+ results.push({ title, url, snippet: snippets[i] ?? "" });
250
+ i++;
251
+ }
252
+ return results;
253
+ }
254
+
255
+ /** Render a result list as numbered Markdown for the model. */
256
+ export function formatSearchResults(query: string, results: SearchResult[], provider: string): string {
257
+ if (results.length === 0) {
258
+ return `No results found for "${query}" (via ${provider}).`;
259
+ }
260
+ const lines = results.map((r, idx) => {
261
+ const head = `${idx + 1}. ${r.title}\n ${r.url}`;
262
+ return r.snippet ? `${head}\n ${r.snippet}` : head;
263
+ });
264
+ return `Search results for "${query}" (via ${provider}):\n\n${lines.join("\n\n")}`;
265
+ }
266
+
267
+ // ---- weather: Open-Meteo geocode + forecast --------------------------
268
+
269
+ interface GeoPlace {
270
+ name: string;
271
+ latitude: number;
272
+ longitude: number;
273
+ country?: string;
274
+ admin1?: string;
275
+ }
276
+
277
+ /** Format an Open-Meteo forecast response into a compact human summary. */
278
+ export function formatWeather(place: GeoPlace, data: unknown, fahrenheit: boolean): string {
279
+ const d = data as {
280
+ current?: {
281
+ temperature_2m?: number;
282
+ apparent_temperature?: number;
283
+ relative_humidity_2m?: number;
284
+ wind_speed_10m?: number;
285
+ weather_code?: number;
286
+ };
287
+ daily?: { time?: string[]; weather_code?: number[]; temperature_2m_max?: number[]; temperature_2m_min?: number[]; precipitation_probability_max?: number[] };
288
+ };
289
+ const tUnit = fahrenheit ? "°F" : "°C";
290
+ const wUnit = fahrenheit ? "mph" : "km/h";
291
+ const where = [place.name, place.admin1, place.country].filter(Boolean).join(", ");
292
+ const lines: string[] = [`Weather for ${where}:`];
293
+
294
+ const cur = d.current;
295
+ if (cur?.temperature_2m != null) {
296
+ const desc = cur.weather_code != null ? `, ${wmoCodeToText(cur.weather_code)}` : "";
297
+ const parts = [`${Math.round(cur.temperature_2m)}${tUnit}${desc}`];
298
+ if (cur.apparent_temperature != null) parts.push(`feels like ${Math.round(cur.apparent_temperature)}${tUnit}`);
299
+ if (cur.relative_humidity_2m != null) parts.push(`humidity ${Math.round(cur.relative_humidity_2m)}%`);
300
+ if (cur.wind_speed_10m != null) parts.push(`wind ${Math.round(cur.wind_speed_10m)} ${wUnit}`);
301
+ lines.push(`Now: ${parts.join(", ")}.`);
302
+ }
303
+
304
+ const daily = d.daily;
305
+ if (daily?.time?.length) {
306
+ lines.push("");
307
+ lines.push("Forecast:");
308
+ for (let i = 0; i < daily.time.length; i++) {
309
+ const code = daily.weather_code?.[i];
310
+ const hi = daily.temperature_2m_max?.[i];
311
+ const lo = daily.temperature_2m_min?.[i];
312
+ const pop = daily.precipitation_probability_max?.[i];
313
+ const desc = code != null ? wmoCodeToText(code) : "";
314
+ const range = hi != null && lo != null ? `${Math.round(lo)}–${Math.round(hi)}${tUnit}` : "";
315
+ const rain = pop != null ? `, precip ${pop}%` : "";
316
+ lines.push(`- ${daily.time[i]}: ${[range, desc].filter(Boolean).join(", ")}${rain}`);
317
+ }
318
+ }
319
+ return lines.join("\n");
320
+ }
321
+
322
+ async function geocode(name: string, signal: AbortSignal | undefined): Promise<GeoPlace | undefined> {
323
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1&language=en&format=json`;
324
+ const res = await httpText(url, { signal });
325
+ if (!res.ok) return undefined;
326
+ const json = JSON.parse(res.text) as { results?: GeoPlace[] };
327
+ return json.results?.[0];
328
+ }
329
+
330
+ // ---- web_search providers --------------------------------------------
331
+
332
+ async function searchTavily(query: string, count: number, signal: AbortSignal | undefined, apiKey: string): Promise<SearchResult[]> {
333
+ const res = await httpText("https://api.tavily.com/search", {
334
+ method: "POST",
335
+ headers: { "content-type": "application/json" },
336
+ body: JSON.stringify({ api_key: apiKey, query, max_results: count, search_depth: "basic" }),
337
+ signal,
338
+ });
339
+ if (!res.ok) throw new Error(`Tavily returned HTTP ${res.status}`);
340
+ const json = JSON.parse(res.text) as { results?: Array<{ title?: string; url?: string; content?: string }> };
341
+ return (json.results ?? []).slice(0, count).map((r) => ({ title: r.title ?? r.url ?? "", url: r.url ?? "", snippet: r.content ?? "" }));
342
+ }
343
+
344
+ async function searchBrave(query: string, count: number, signal: AbortSignal | undefined, apiKey: string): Promise<SearchResult[]> {
345
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
346
+ const res = await httpText(url, { headers: { "x-subscription-token": apiKey, accept: "application/json" }, signal });
347
+ if (!res.ok) throw new Error(`Brave returned HTTP ${res.status}`);
348
+ const json = JSON.parse(res.text) as { web?: { results?: Array<{ title?: string; url?: string; description?: string }> } };
349
+ return (json.web?.results ?? []).slice(0, count).map((r) => ({ title: r.title ?? r.url ?? "", url: r.url ?? "", snippet: stripTags(r.description ?? "") }));
350
+ }
351
+
352
+ async function searchDuckDuckGo(query: string, count: number, signal: AbortSignal | undefined): Promise<SearchResult[]> {
353
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
354
+ const res = await httpText(url, { headers: { accept: "text/html" }, signal });
355
+ if (!res.ok) throw new Error(`DuckDuckGo returned HTTP ${res.status}`);
356
+ return parseDuckDuckGoHtml(res.text, count);
357
+ }
358
+
359
+ /** Pick the search backend from env; returns the provider label too. */
360
+ function resolveSearchProvider(): { name: string; run: (q: string, n: number, s: AbortSignal | undefined) => Promise<SearchResult[]> } {
361
+ const tavily = process.env.TAVILY_API_KEY;
362
+ if (tavily) return { name: "Tavily", run: (q, n, s) => searchTavily(q, n, s, tavily) };
363
+ const brave = process.env.BRAVE_API_KEY;
364
+ if (brave) return { name: "Brave", run: (q, n, s) => searchBrave(q, n, s, brave) };
365
+ return { name: "DuckDuckGo", run: searchDuckDuckGo };
366
+ }
367
+
368
+ // ---- tool definitions ------------------------------------------------
369
+
370
+ function textResult(text: string): { content: [{ type: "text"; text: string }]; details: Record<string, never> } {
371
+ return { content: [{ type: "text", text }], details: {} };
372
+ }
373
+
374
+ const webSearchTool = defineTool({
375
+ name: "web_search",
376
+ label: "Web Search",
377
+ description:
378
+ "Search the web for current, real-world information (news, facts, docs, anything that changes over time or that you don't already know). " +
379
+ "Returns a ranked list of result titles, URLs, and snippets. Follow up with web_fetch to read a result in full.",
380
+ parameters: Type.Object({
381
+ query: Type.String({ description: "The search query." }),
382
+ count: Type.Optional(Type.Number({ description: `Number of results to return (default ${DEFAULT_SEARCH_RESULTS}, max ${MAX_SEARCH_RESULTS}).` })),
383
+ }),
384
+ execute: async (_id, params, signal) => {
385
+ const query = params.query.trim();
386
+ if (!query) return textResult("web_search needs a non-empty query.");
387
+ const count = Math.max(1, Math.min(MAX_SEARCH_RESULTS, Math.round(params.count ?? DEFAULT_SEARCH_RESULTS)));
388
+ const provider = resolveSearchProvider();
389
+ try {
390
+ const results = await provider.run(query, count, signal);
391
+ if (results.length === 0 && provider.name === "DuckDuckGo") {
392
+ return textResult(
393
+ `No results for "${query}". DuckDuckGo (the keyless default) may be rate-limiting; ` +
394
+ `set TAVILY_API_KEY or BRAVE_API_KEY for a reliable search backend.`,
395
+ );
396
+ }
397
+ return textResult(formatSearchResults(query, results, provider.name));
398
+ } catch (err) {
399
+ return textResult(`Web search failed (${provider.name}): ${describeFetchError(err)}`);
400
+ }
401
+ },
402
+ });
403
+
404
+ const webFetchTool = defineTool({
405
+ name: "web_fetch",
406
+ label: "Fetch URL",
407
+ description:
408
+ "Fetch a URL and return its contents as readable text (HTML pages are stripped to plain text). " +
409
+ "Use this to read a web page, article, or API/JSON endpoint — e.g. after web_search, or when the user gives you a link.",
410
+ parameters: Type.Object({
411
+ url: Type.String({ description: "The absolute http(s) URL to fetch." }),
412
+ }),
413
+ execute: async (_id, params, signal) => {
414
+ let url = params.url.trim();
415
+ if (!/^https?:\/\//i.test(url)) url = "https://" + url;
416
+ try {
417
+ const res = await fetchWebText(url, signal);
418
+ const header = `Fetched ${res.finalUrl} (HTTP ${res.status})`;
419
+ if (!res.ok) return textResult(`${header}\n\n${truncate(htmlToText(res.text), 2_000)}`);
420
+ const isHtml = /text\/html|application\/xhtml/i.test(res.contentType);
421
+ if (isHtml) {
422
+ const title = extractTitle(res.text);
423
+ const body = truncate(htmlToText(res.text), MAX_FETCH_CHARS);
424
+ return textResult(`${header}${title ? `\nTitle: ${title}` : ""}\n\n${body}`);
425
+ }
426
+ const isTextual = /text\/|application\/(json|xml|javascript|x-ndjson)|\+json|\+xml/i.test(res.contentType) || res.contentType === "";
427
+ if (!isTextual) {
428
+ return textResult(`${header}\n\nContent-Type ${res.contentType || "unknown"} is not text; not displaying ${res.text.length} bytes of binary data.`);
429
+ }
430
+ return textResult(`${header}\n\n${truncate(res.text, MAX_FETCH_CHARS)}`);
431
+ } catch (err) {
432
+ return textResult(`Failed to fetch ${url}: ${describeFetchError(err)}`);
433
+ }
434
+ },
435
+ });
436
+
437
+ const weatherTool = defineTool({
438
+ name: "weather",
439
+ label: "Weather",
440
+ description:
441
+ "Get current weather conditions and a short forecast for a place. Accepts a city/place name (e.g. 'Austin', 'Paris, France', 'Tokyo'). " +
442
+ "No API key required.",
443
+ parameters: Type.Object({
444
+ location: Type.String({ description: "City or place name to look up." }),
445
+ unit: Type.Optional(Type.String({ description: "Temperature unit: 'celsius' or 'fahrenheit' (default celsius)." })),
446
+ }),
447
+ execute: async (_id, params, signal) => {
448
+ const location = params.location.trim();
449
+ if (!location) return textResult("weather needs a location.");
450
+ const fahrenheit = params.unit === "fahrenheit";
451
+ try {
452
+ const place = await geocode(location, signal);
453
+ if (!place) return textResult(`Couldn't find a place named "${location}".`);
454
+ const params2 = new URLSearchParams({
455
+ latitude: String(place.latitude),
456
+ longitude: String(place.longitude),
457
+ current: "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m",
458
+ daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
459
+ timezone: "auto",
460
+ forecast_days: "3",
461
+ });
462
+ if (fahrenheit) {
463
+ params2.set("temperature_unit", "fahrenheit");
464
+ params2.set("wind_speed_unit", "mph");
465
+ }
466
+ const res = await httpText(`https://api.open-meteo.com/v1/forecast?${params2.toString()}`, { signal });
467
+ if (!res.ok) return textResult(`Weather lookup failed: Open-Meteo returned HTTP ${res.status}.`);
468
+ return textResult(formatWeather(place, JSON.parse(res.text), fahrenheit));
469
+ } catch (err) {
470
+ return textResult(`Weather lookup failed: ${describeFetchError(err)}`);
471
+ }
472
+ },
473
+ });
474
+
475
+ /** All web tools, ready to pass to createAgentSession({ customTools }). */
476
+ export function createWebTools(): ToolDefinition[] {
477
+ return [webSearchTool, webFetchTool, weatherTool];
478
+ }