Tools.sys.mjs (14818B)
1 /** 2 * This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 */ 6 7 /** 8 * This file contains LLM tool abstractions and tool definitions. 9 */ 10 11 import { searchBrowsingHistory as implSearchBrowsingHistory } from "moz-src:///browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs"; 12 import { PageExtractorParent } from "resource://gre/actors/PageExtractorParent.sys.mjs"; 13 14 const lazy = {}; 15 ChromeUtils.defineESModuleGetters(lazy, { 16 AIWindow: 17 "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", 18 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", 19 PageDataService: 20 "moz-src:///browser/components/pagedata/PageDataService.sys.mjs", 21 }); 22 23 const GET_OPEN_TABS = "get_open_tabs"; 24 const SEARCH_BROWSING_HISTORY = "search_browsing_history"; 25 const GET_PAGE_CONTENT = "get_page_content"; 26 27 export const TOOLS = [GET_OPEN_TABS, SEARCH_BROWSING_HISTORY, GET_PAGE_CONTENT]; 28 29 export const toolsConfig = [ 30 { 31 type: "function", 32 function: { 33 name: GET_OPEN_TABS, 34 description: 35 "Access the user's browser and return a list of most recently browsed tabs. " + 36 "Each tab is represented by a JSON with the page's url, title and description " + 37 "if available. Default to return maximum 15 tabs.", 38 parameters: { 39 type: "object", 40 properties: {}, 41 }, 42 }, 43 }, 44 { 45 type: "function", 46 function: { 47 name: SEARCH_BROWSING_HISTORY, 48 description: 49 "Retrieve pages from the user's past browsing history, optionally filtered by " + 50 "topic and/or time range.", 51 parameters: { 52 type: "object", 53 properties: { 54 searchTerm: { 55 type: "string", 56 description: 57 "A concise phrase describing what the user is trying to find in their " + 58 "browsing history (topic, site, or purpose).", 59 }, 60 startTs: { 61 type: "string", 62 description: 63 "Inclusive start of the time range as a local ISO 8601 datetime " + 64 "('YYYY-MM-DDTHH:mm:ss', no timezone).", 65 }, 66 endTs: { 67 type: "string", 68 description: 69 "Inclusive end of the time range as a local ISO 8601 datetime " + 70 "('YYYY-MM-DDTHH:mm:ss', no timezone).", 71 }, 72 }, 73 }, 74 }, 75 }, 76 { 77 type: "function", 78 function: { 79 name: GET_PAGE_CONTENT, 80 description: 81 "Retrieve cleaned text content of the provided browser page URL.", 82 parameters: { 83 properties: { 84 url: { 85 type: "string", 86 description: 87 "The complete URL of the page to fetch content from. This must exactly match " + 88 "a URL from the current conversation context. Use the full URL including " + 89 "protocol (http/https). Example: 'https://www.example.com/article'.", 90 }, 91 }, 92 required: ["url"], 93 }, 94 }, 95 }, 96 ]; 97 98 /** 99 * Retrieves a list of (up to n) the latest open tabs from the current active browser window. 100 * Ignores config pages (about:xxx). 101 * TODO: Ignores chat-only pages (FE to implement isSidebarMode flag). 102 * 103 * @param {number} n 104 * Maximum number of tabs to return. Defaults to 15. 105 * @returns {Promise<Array<object>>} 106 * A promise resolving to an array of tab metadata objects, each containing: 107 * - url {string}: The tab's current URL 108 * - title {string}: The tab's title 109 * - description {string}: Optional description (empty string if not available) 110 * - lastAccessed {number}: Last accessed timestamp in milliseconds 111 * Tabs are sorted by most recently accessed and limited to the first n results. 112 */ 113 export async function getOpenTabs(n = 15) { 114 const tabs = []; 115 116 for (const win of lazy.BrowserWindowTracker.orderedWindows) { 117 if (!lazy.AIWindow.isAIWindowActive(win)) { 118 continue; 119 } 120 121 if (!win.closed && win.gBrowser) { 122 for (const tab of win.gBrowser.tabs) { 123 const browser = tab.linkedBrowser; 124 const url = browser?.currentURI?.spec; 125 const title = tab.label; 126 127 if (url && !url.startsWith("about:")) { 128 tabs.push({ 129 url, 130 title, 131 lastAccessed: tab.lastAccessed, 132 }); 133 } 134 } 135 } 136 } 137 138 tabs.sort((a, b) => b.lastAccessed - a.lastAccessed); 139 140 const topTabs = tabs.slice(0, n); 141 142 return Promise.all( 143 topTabs.map(async ({ url, title, lastAccessed }) => { 144 let description = ""; 145 if (url) { 146 description = 147 lazy.PageDataService.getCached(url)?.description || 148 (await lazy.PageDataService.fetchPageData(url))?.description || 149 ""; 150 } 151 return { url, title, description, lastAccessed }; 152 }) 153 ); 154 } 155 156 /** 157 * Tool entrypoint for search_browsing_history. 158 * 159 * Parameters (defaults shown): 160 * - searchTerm: "" - string used for search 161 * - startTs: null - local ISO timestamp lower bound, or null 162 * - endTs: null - local ISO timestamp upper bound, or null 163 * - historyLimit: 15 - max number of results 164 * 165 * Detailed behavior and implementation are in SearchBrowsingHistory.sys.mjs. 166 * 167 * @param {object} toolParams 168 * The search parameters. 169 * @param {string} toolParams.searchTerm 170 * The search string. If null or empty, semantic search is skipped and 171 * results are filtered by time range and sorted by last_visit_date and frecency. 172 * @param {string|null} toolParams.startTs 173 * Optional local ISO-8601 start timestamp (e.g. "2025-11-07T09:00:00"). 174 * @param {string|null} toolParams.endTs 175 * Optional local ISO-8601 end timestamp (e.g. "2025-11-07T09:00:00"). 176 * @param {number} toolParams.historyLimit 177 * Maximum number of history results to return. 178 * @returns {Promise<object>} 179 * A promise resolving to an object with the search term and history results. 180 * Includes `count` when matches exist, a `message` when none are found, or an 181 * `error` string on failure. 182 */ 183 export async function searchBrowsingHistory({ 184 searchTerm = "", 185 startTs = null, 186 endTs = null, 187 historyLimit = 15, 188 }) { 189 return implSearchBrowsingHistory({ 190 searchTerm, 191 startTs, 192 endTs, 193 historyLimit, 194 }); 195 } 196 197 /** 198 * Strips heavy or unnecessary fields from a browser history search result. 199 * 200 * @param {string} result 201 * A JSON string representing the history search response. 202 * @returns {string} 203 * The sanitized JSON string with large fields (e.g., favicon, thumbnail) 204 * removed, or the original string if parsing fails. 205 */ 206 export function stripSearchBrowsingHistoryFields(result) { 207 try { 208 const data = JSON.parse(result); 209 if ( 210 data.error || 211 !Array.isArray(data.results) || 212 data.results.length === 0 213 ) { 214 return result; 215 } 216 217 // Remove large or unnecessary fields to save tokens 218 const OMIT_KEYS = ["favicon", "thumbnail"]; 219 for (const item of data.results) { 220 if (item && typeof item === "object") { 221 for (const k of OMIT_KEYS) { 222 delete item[k]; 223 } 224 } 225 } 226 return JSON.stringify(data); 227 } catch { 228 return result; 229 } 230 } 231 232 /** 233 * Class for handling page content extraction with configurable modes and limits. 234 */ 235 export class GetPageContent { 236 static DEFAULT_MODE = "reader"; 237 static FALLBACK_MODE = "full"; 238 static MAX_CHARACTERS = 10000; 239 240 static MODE_HANDLERS = { 241 viewport: async pageExtractor => { 242 const result = await pageExtractor.getText({ justViewport: true }); 243 return { text: result.text }; 244 }, 245 reader: async pageExtractor => { 246 const text = await pageExtractor.getReaderModeContent(); 247 return { text: typeof text === "string" ? text : "" }; 248 }, 249 full: async pageExtractor => { 250 const result = await pageExtractor.getText(); 251 return { text: result }; 252 }, 253 }; 254 255 /** 256 * Tool entrypoint for get_page_content. 257 * 258 * @param {object} toolParams 259 * @param {string} toolParams.url 260 * @param {Set<string>} allowedUrls 261 * @returns {Promise<string>} 262 * A promise resolving to a string containing the extracted page content 263 * with a descriptive header, or an error message if extraction fails. 264 */ 265 static async getPageContent({ url }, allowedUrls) { 266 try { 267 // Search through the allowed URLs and extract directly if exists 268 if (!allowedUrls.has(url)) { 269 // Bug 2006418 - This will load the page headlessly, and then extract the content. 270 // It might be a better idea to have the lifetime of the page be tied to the chat 271 // while it's open, and with a "keep alive" timeout. For now it's simpler to just 272 // load the page fresh every time. 273 return PageExtractorParent.getHeadlessExtractor(url, pageExtractor => 274 this.#runExtraction(pageExtractor, this.DEFAULT_MODE, url) 275 ); 276 } 277 278 // Search through all AI Windows to find the tab with the matching URL 279 let targetTab = null; 280 for (const win of lazy.BrowserWindowTracker.orderedWindows) { 281 if (!lazy.AIWindow.isAIWindowActive(win)) { 282 continue; 283 } 284 285 if (!win.closed && win.gBrowser) { 286 const tabs = win.gBrowser.tabs; 287 288 // Find the tab with the matching URL in this window 289 for (let i = 0; i < tabs.length; i++) { 290 const tab = tabs[i]; 291 const currentURI = tab?.linkedBrowser?.currentURI; 292 if (currentURI?.spec === url) { 293 targetTab = tab; 294 break; 295 } 296 } 297 298 // If no match, try hostname matching for cases where protocols differ 299 if (!targetTab) { 300 try { 301 const inputHostPort = new URL(url).host; 302 targetTab = tabs.find(tab => { 303 try { 304 const tabHostPort = tab.linkedBrowser.currentURI.hostPort; 305 return tabHostPort === inputHostPort; 306 } catch { 307 return false; 308 } 309 }); 310 } catch { 311 // Invalid URL, continue with original logic 312 } 313 } 314 315 // If we found the tab, stop searching 316 if (targetTab) { 317 break; 318 } 319 } 320 } 321 322 // If still no match, abort 323 if (!targetTab) { 324 return `Cannot find URL: ${url}, page content extraction failed.`; 325 } 326 327 // Attempt extraction 328 const currentWindowContext = 329 targetTab.linkedBrowser.browsingContext?.currentWindowContext; 330 331 if (!currentWindowContext) { 332 return `Cannot access content from "${targetTab.label}" at ${url}.`; 333 // Stripped message "The tab may still be loading or is not accessible." to not confuse the LLM 334 } 335 336 // Extract page content using PageExtractor 337 const pageExtractor = 338 await currentWindowContext.getActor("PageExtractor"); 339 340 return this.#runExtraction( 341 pageExtractor, 342 this.DEFAULT_MODE, 343 `"${targetTab.label}" (${url})` 344 ); 345 } catch (error) { 346 // Bug 2006425 - Decide on the strategy for error handling in tool calls 347 // i.e., will the LLM keep retrying get_page_content due to error? 348 console.error(error); 349 return `Error retrieving content from ${url}.`; 350 // Stripped ${error.message} content to not confruse the LLM 351 } 352 } 353 354 /** 355 * Main extraction function. 356 * label is of form `{tab.title} ({tab.url})`. 357 * 358 * @param {PageExtractor} pageExtractor 359 * @param {string} mode 360 * @param {string} label 361 * @returns {Promise<string>} 362 * A promise resolving to a formatted string containing the page content 363 * with mode and label information, or an error message if no content is available. 364 */ 365 static async #runExtraction(pageExtractor, mode, label) { 366 const selectedMode = 367 typeof mode === "string" && this.MODE_HANDLERS[mode] 368 ? mode 369 : this.DEFAULT_MODE; 370 const handler = this.MODE_HANDLERS[selectedMode]; 371 let extraction = null; 372 373 try { 374 extraction = await handler(pageExtractor); 375 } catch (err) { 376 console.error( 377 "[SmartWindow] get_page_content mode failed", 378 selectedMode, 379 err 380 ); 381 } 382 383 let pageContent = ""; 384 if (typeof extraction === "string") { 385 pageContent = extraction; 386 } else if (typeof extraction?.text === "string") { 387 pageContent = extraction.text; 388 } 389 390 // Track which mode was actually used (in case we fall back) 391 let actualMode = selectedMode; 392 393 // If reader mode returns no content, fall back to full mode 394 if (!pageContent && selectedMode === "reader") { 395 try { 396 const fallbackHandler = this.MODE_HANDLERS[this.FALLBACK_MODE]; 397 extraction = await fallbackHandler(pageExtractor); 398 if (typeof extraction === "string") { 399 pageContent = extraction; 400 } else if (typeof extraction?.text === "string") { 401 pageContent = extraction.text; 402 } 403 if (pageContent) { 404 actualMode = this.FALLBACK_MODE; 405 } 406 } catch (err) { 407 console.error( 408 "[SmartWindow] get_page_content fallback mode failed", 409 this.FALLBACK_MODE, 410 err 411 ); 412 } 413 } 414 415 if (!pageContent) { 416 return `get_page_content(${selectedMode}) returned no content for ${label}.`; 417 // Stripped message "Try another mode if you still need information." to not confruse the LLM 418 } 419 420 // Clean and truncate content for better LLM consumption 421 // Bug 2006436 - Consider doing this directly in pageExtractor if absolutely needed. 422 let cleanContent = pageContent 423 .replace(/\s+/g, " ") // Normalize whitespace 424 .replace(/\n\s*\n/g, "\n") // Clean up line breaks 425 .trim(); 426 427 // Limit content length but be more generous for LLM processing 428 // Bug 1995043 - once reader mode has length truncation, 429 // we can remove this and directly do this in pageExtractor. 430 if (cleanContent.length > this.MAX_CHARACTERS) { 431 // Try to cut at a sentence boundary 432 const truncatePoint = cleanContent.lastIndexOf(".", this.MAX_CHARACTERS); 433 if (truncatePoint > this.MAX_CHARACTERS - 100) { 434 cleanContent = cleanContent.substring(0, truncatePoint + 1); 435 } else { 436 cleanContent = cleanContent.substring(0, this.MAX_CHARACTERS) + "..."; 437 } 438 } 439 440 let modeLabel; 441 switch (actualMode) { 442 case "viewport": 443 modeLabel = "current viewport"; 444 break; 445 case "reader": 446 modeLabel = "reader mode"; 447 break; 448 case "full": 449 modeLabel = "full page"; 450 break; 451 } 452 453 return `Content (${modeLabel}) from ${label}: 454 455 ${cleanContent}`; 456 } 457 }