@stackfactor/agent-utils 1.10.0 → 1.12.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.
@@ -53,6 +53,10 @@ export const assertTavilyUsable = (config, options) => {
53
53
  if (!config?.tavilyAPIKey) {
54
54
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Tavily tools are enabled but config.tavilyAPIKey is missing.");
55
55
  }
56
+ if (options.allowedUrls !== undefined &&
57
+ !(Array.isArray(options.allowedUrls) && options.allowedUrls.every((url) => typeof url === "string"))) {
58
+ throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "config.tavily.allowedUrls must be a list of URLs.");
59
+ }
56
60
  const unknown = (options.tools || []).filter((name) => !["search", "extract", "crawl", "map"].includes(name));
57
61
  if (unknown.length > 0) {
58
62
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, `Unknown Tavily tool(s): ${unknown.join(", ")}. Valid values are search, extract, crawl, map.`);
@@ -160,6 +164,52 @@ const recordTavilyUsage = (tracker, _config, credits, sources) => {
160
164
  }
161
165
  }
162
166
  };
167
+ /**
168
+ * Adds the URLs `web_extract` is about to ask Tavily for to
169
+ * `tracker.attemptedUrls`, each once across the run. Recorded before the call,
170
+ * so a URL the call then fails on is still there.
171
+ * @param tracker - The caller's usage accumulator, when it passed one
172
+ * @param urls - The URLs sent
173
+ */
174
+ const recordAttemptedUrls = (tracker, urls) => {
175
+ if (!tracker || urls.length === 0)
176
+ return;
177
+ const attempted = tracker.attemptedUrls || (tracker.attemptedUrls = []);
178
+ for (const url of urls) {
179
+ if (!attempted.includes(url))
180
+ attempted.push(url);
181
+ }
182
+ };
183
+ /**
184
+ * How a URL is matched, against Tavily's answer or `allowedUrls`: trimmed,
185
+ * lowercased, no trailing slash.
186
+ */
187
+ const urlMatchKey = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
188
+ /** What `web_extract` answers for each URL `allowedUrls` refused. */
189
+ const REFUSED_HEADING = "Refused, not read (only the referenced pages may be read):";
190
+ /**
191
+ * Splits the URLs the model asked `web_extract` for into those it may read —
192
+ * as `allowedUrls` lists them — and those it may not, each once. With no
193
+ * `allowedUrls`, every URL may be read, as asked.
194
+ * @param urls - The URLs the model asked for
195
+ * @param allowed - `allowedUrls` by match key, or `null` when unset
196
+ */
197
+ const screenUrls = (urls, allowed) => {
198
+ if (!allowed)
199
+ return { read: urls, refused: [] };
200
+ const read = [];
201
+ const refused = [];
202
+ for (const url of urls) {
203
+ const listed = allowed.get(urlMatchKey(url));
204
+ if (listed === undefined) {
205
+ if (!refused.includes(url))
206
+ refused.push(url);
207
+ }
208
+ else if (!read.includes(listed))
209
+ read.push(listed);
210
+ }
211
+ return { read, refused };
212
+ };
163
213
  /**
164
214
  * Trims one document to whatever the per-URL and per-call budgets allow,
165
215
  * appending a note when anything was cut.
@@ -230,6 +280,9 @@ export const buildTavilyTools = (config, options, usageTracker = null) => {
230
280
  const maxCharsPerUrl = options.maxCharsPerUrl ?? DEFAULT_MAX_CHARS_PER_URL;
231
281
  const maxCharsTotal = options.maxCharsTotal ?? DEFAULT_MAX_CHARS_TOTAL;
232
282
  const crawlLimit = options.crawlLimit ?? DEFAULT_CRAWL_LIMIT;
283
+ const allowed = Array.isArray(options.allowedUrls)
284
+ ? new Map(options.allowedUrls.map((url) => [urlMatchKey(url), url.trim()]))
285
+ : null;
233
286
  const newBudget = () => ({
234
287
  remaining: maxCharsTotal,
235
288
  perUrl: maxCharsPerUrl,
@@ -277,8 +330,22 @@ export const buildTavilyTools = (config, options, usageTracker = null) => {
277
330
  }
278
331
  if (enabled.includes("extract")) {
279
332
  tools.push(tool(async ({ urls, query }) => {
333
+ // Outside `allowedUrls` nothing is sent: the model is told per URL,
334
+ // and the run goes on with what it may read.
335
+ const { read, refused } = screenUrls(urls, allowed);
336
+ const refusal = refused.length
337
+ ? `${REFUSED_HEADING}\n${refused
338
+ .map((url) => `- ${url}: not one of the referenced pages`)
339
+ .join("\n")}`
340
+ : "";
341
+ if (refused.length > 0) {
342
+ logger.log(null, logger.levels.warn, `Tavily extract refused ${refused.length} URL(s) not among the allowed pages: ${refused.join(", ")}`);
343
+ }
344
+ if (read.length === 0)
345
+ return refusal;
346
+ recordAttemptedUrls(usageTracker, read);
280
347
  const data = await callTavily("extract", {
281
- urls,
348
+ urls: read,
282
349
  extract_depth: options.extractDepth,
283
350
  format,
284
351
  // `query` switches the API from whole-document extraction to
@@ -309,10 +376,14 @@ export const buildTavilyTools = (config, options, usageTracker = null) => {
309
376
  .join("\n");
310
377
  rendered += `\n\n---\n\nFailed to extract:\n${lines}`;
311
378
  }
379
+ if (refusal)
380
+ rendered += `\n\n---\n\n${refusal}`;
312
381
  return rendered;
313
382
  }, {
314
383
  name: "web_extract",
315
- description: "Read the content of specific URLs. Pass a query to get back only the passages relevant to it — much cheaper and usually sufficient. Omit the query to retrieve the whole document, which is appropriate when you need its full structure. Long documents are truncated, and the result says so when that happens.",
384
+ description: `Read the content of specific URLs. Pass a query to get back only the passages relevant to it — much cheaper and usually sufficient. Omit the query to retrieve the whole document, which is appropriate when you need its full structure. Long documents are truncated, and the result says so when that happens.${allowed
385
+ ? " Only the referenced pages can be read; any other URL is refused."
386
+ : ""}`,
316
387
  schema: z.object({
317
388
  urls: z
318
389
  .array(z.string())
@@ -390,8 +461,6 @@ export const buildTavilyTools = (config, options, usageTracker = null) => {
390
461
  const EXTRACT_MAX_URLS = 20;
391
462
  /** Seconds. A reachability check needs the page fetched, not rendered at length. */
392
463
  const REACHABILITY_TIMEOUT_SECONDS = 20;
393
- /** How a URL is matched against Tavily's answer: trimmed, lowercased, no trailing slash. */
394
- const urlMatchKey = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
395
464
  /** True for an `http:` or `https:` URL; false for anything else, unparsable included. */
396
465
  const isWebUrl = (url) => {
397
466
  try {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.10.0",
6
+ "version": "1.12.0",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",