tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }