@stackfactor/agent-utils 1.2.20 → 1.3.2

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,395 @@
1
+ import { tool } from "langchain";
2
+ import { z } from "zod";
3
+ import constants from "./const.js";
4
+ import errorHandlingHelper from "./errorHandling.js";
5
+ import logger from "./logger.js";
6
+ import { getAbortSignal } from "./runtimeContext.js";
7
+ const TAVILY_API_BASE = "https://api.tavily.com";
8
+ /** Tavily list price per credit, used when the integration defines no rate. */
9
+ const DEFAULT_CREDIT_COST_USD = 0.008;
10
+ /**
11
+ * Context budgets, in characters, applied to everything a tool hands back to
12
+ * the model. Tavily returns whatever the page holds, and unlike a provider's
13
+ * native search there is nothing between its response and the context window,
14
+ * so these caps are the only thing standing between one `extract` call and a
15
+ * 100-page PDF. Roughly four characters per token: 60k ≈ 15k tokens per URL,
16
+ * 150k ≈ 37k tokens per call.
17
+ */
18
+ const DEFAULT_MAX_CHARS_PER_URL = 60_000;
19
+ const DEFAULT_MAX_CHARS_TOTAL = 150_000;
20
+ /** Kept deliberately small — search finds documents, `extract` reads them. */
21
+ const DEFAULT_MAX_RESULTS = 5;
22
+ /** Tavily's own default when a query is supplied. */
23
+ const DEFAULT_CHUNKS_PER_SOURCE = 3;
24
+ /** Crawl returns full content per page, so the page count is the cost driver. */
25
+ const DEFAULT_CRAWL_LIMIT = 10;
26
+ /** Seconds. Tavily's own per-request default; crawls legitimately run long. */
27
+ const DEFAULT_TIMEOUT_SECONDS = 60;
28
+ const DEFAULT_CRAWL_TIMEOUT_SECONDS = 120;
29
+ /**
30
+ * Normalizes `config.tavily` into an options object, returning `null` when the
31
+ * Tavily tools are off so callers can use it as the single enablement gate.
32
+ * @param config - The integration config object
33
+ * @returns Normalized options, or `null` when Tavily is not enabled
34
+ */
35
+ export const getTavilyOptions = (config) => {
36
+ const tavily = config?.tavily;
37
+ if (!tavily)
38
+ return null;
39
+ return tavily === true ? {} : tavily;
40
+ };
41
+ /**
42
+ * Fails fast on the two ways a Tavily config cannot work. Tavily tools are
43
+ * executed by the agent loop in this process, so outside agentic mode the model
44
+ * would be handed tools whose calls nothing answers: the turn ends silently and
45
+ * looks like the model ignored them. Called once at config time rather than
46
+ * discovered per request.
47
+ * @param config - The integration config object
48
+ * @param options - Normalized options from `getTavilyOptions`
49
+ */
50
+ export const assertTavilyUsable = (config, options) => {
51
+ if (config?.agentic !== true) {
52
+ throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "Tavily tools run client-side and are only executed by the agent loop; set config.agentic to true to use them.");
53
+ }
54
+ if (!config?.tavilyAPIKey) {
55
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Tavily tools are enabled but config.tavilyAPIKey is missing.");
56
+ }
57
+ const unknown = (options.tools || []).filter((name) => !["search", "extract", "crawl", "map"].includes(name));
58
+ if (unknown.length > 0) {
59
+ throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, `Unknown Tavily tool(s): ${unknown.join(", ")}. Valid values are search, extract, crawl, map.`);
60
+ }
61
+ };
62
+ /** Retries for a transient (429/5xx) Tavily response before giving up. */
63
+ const MAX_TAVILY_RETRIES = 2;
64
+ /** Base delay for retry backoff; doubled on each subsequent attempt. */
65
+ const RETRY_BASE_DELAY_MS = 500;
66
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
67
+ /**
68
+ * POSTs to one Tavily endpoint. The REST API is snake_case while the published
69
+ * SDK's option names are camelCase, so bodies here are written in the wire
70
+ * format the API actually accepts. `include_usage` is always set: the response
71
+ * then carries the exact credits the call consumed, which is what the tracker
72
+ * bills instead of an estimate. `timeout` is always set from the caller's
73
+ * budget so Tavily's own (shorter) per-endpoint defaults don't cut a call off
74
+ * before the time this library already told the caller it had.
75
+ *
76
+ * Rate-limit (429) and server (5xx) responses are retried with exponential
77
+ * backoff — everything else, including a caller-cancelled request, fails
78
+ * immediately.
79
+ * @param endpoint - The Tavily endpoint name, e.g. `"search"`
80
+ * @param body - Request body in the API's snake_case form; `undefined` values
81
+ * are dropped so Tavily applies its own defaults
82
+ * @param apiKey - The Tavily API key
83
+ * @param timeoutSeconds - Per-request timeout forwarded to Tavily
84
+ * @returns The parsed JSON response
85
+ */
86
+ const callTavily = async (endpoint, body, apiKey, timeoutSeconds) => {
87
+ const payload = {
88
+ include_usage: true,
89
+ timeout: timeoutSeconds,
90
+ };
91
+ for (const [key, value] of Object.entries(body)) {
92
+ if (value !== undefined && value !== null)
93
+ payload[key] = value;
94
+ }
95
+ for (let attempt = 0;; attempt++) {
96
+ let response;
97
+ try {
98
+ response = await fetch(`${TAVILY_API_BASE}/${endpoint}`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ Authorization: `Bearer ${apiKey}`,
103
+ },
104
+ body: JSON.stringify(payload),
105
+ // Honour caller cancellation the same way the LLM calls do, so an
106
+ // abandoned run stops paying for in-flight crawls.
107
+ signal: getAbortSignal() ?? undefined,
108
+ });
109
+ }
110
+ catch (err) {
111
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Tavily ${endpoint} request failed: ${err?.message || String(err)}`);
112
+ }
113
+ if (response.ok)
114
+ return response.json();
115
+ const retryable = response.status === 429 || response.status >= 500;
116
+ if (retryable && attempt < MAX_TAVILY_RETRIES) {
117
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
118
+ continue;
119
+ }
120
+ const detail = await response.text().catch(() => "");
121
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Tavily ${endpoint} returned ${response.status}: ${detail.slice(0, 500)}`);
122
+ }
123
+ };
124
+ /**
125
+ * Adds one Tavily call's credits to the tracker and records the URLs it
126
+ * returned. Credits come from the response's own `usage` block, priced with the
127
+ * `tavily-credit-costs` constant (USD per credit) and accumulated under
128
+ * `tavilyCredits`. Sources land on `tracker.webSearchSources`, deduplicated by
129
+ * URL across the whole run, so callers have the citation list Tavily's terms
130
+ * require when this output is shown to end users.
131
+ * @param tracker - The caller's usage accumulator; no-ops when absent
132
+ * @param config - Config object carrying the `tavily-credit-costs` constant
133
+ * @param credits - Credits reported by the Tavily response
134
+ * @param sources - URLs (and titles where known) the call returned
135
+ */
136
+ const recordTavilyUsage = (tracker, config, credits, sources) => {
137
+ if (!tracker)
138
+ return;
139
+ if (typeof tracker.cost !== "number")
140
+ tracker.cost = 0;
141
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
142
+ tracker.tokens = {};
143
+ if (Number.isFinite(credits) && credits > 0) {
144
+ const configured = Number(config?.["tavily-credit-costs"]);
145
+ const rate = Number.isFinite(configured)
146
+ ? configured
147
+ : DEFAULT_CREDIT_COST_USD;
148
+ const addedCost = credits * rate;
149
+ if (Number.isFinite(addedCost) && addedCost > 0)
150
+ tracker.cost += addedCost;
151
+ tracker.tokens.tavilyCredits =
152
+ (tracker.tokens.tavilyCredits || 0) + credits;
153
+ }
154
+ if (sources.length === 0)
155
+ return;
156
+ const existing = tracker.webSearchSources || (tracker.webSearchSources = []);
157
+ for (const source of sources) {
158
+ if (!source?.url)
159
+ continue;
160
+ if (!existing.some((entry) => entry.url === source.url)) {
161
+ existing.push(source);
162
+ }
163
+ }
164
+ };
165
+ /**
166
+ * Trims one document to whatever the per-URL and per-call budgets allow,
167
+ * appending a note when anything was cut.
168
+ * @param text - The document text
169
+ * @param budget - Mutable budget for the current tool call
170
+ * @returns The text, truncated with an inline note when over budget
171
+ */
172
+ const applyBudget = (text, budget) => {
173
+ const body = typeof text === "string" ? text : "";
174
+ if (!body)
175
+ return "";
176
+ const allowance = Math.min(budget.perUrl, budget.remaining);
177
+ if (allowance <= 0) {
178
+ return "[omitted: this call's character budget is exhausted — request fewer URLs at a time]";
179
+ }
180
+ if (body.length <= allowance) {
181
+ budget.remaining -= body.length;
182
+ return body;
183
+ }
184
+ budget.remaining -= allowance;
185
+ return `${body.slice(0, allowance)}\n\n[truncated: ${allowance.toLocaleString()} of ${body.length.toLocaleString()} characters shown — narrow the query or request fewer URLs to see the parts that matter]`;
186
+ };
187
+ /**
188
+ * Renders documents for the model as titled markdown sections. Tavily's own
189
+ * result objects carry `score`, `published_date`, `favicon` and `id` alongside
190
+ * the content; none of that informs an answer, so only the title, URL and text
191
+ * are forwarded and the rest is dropped rather than billed as input tokens.
192
+ * @param documents - Documents to render
193
+ * @param budget - Mutable budget for the current tool call
194
+ * @returns A markdown string, or a not-found notice when there is nothing
195
+ */
196
+ const renderDocuments = (documents, budget) => {
197
+ const sections = [];
198
+ for (const document of documents) {
199
+ const body = applyBudget(document.text, budget);
200
+ if (!body)
201
+ continue;
202
+ const heading = document.title
203
+ ? `## ${document.title}\n${document.url}`
204
+ : `## ${document.url}`;
205
+ sections.push(`${heading}\n\n${body}`);
206
+ }
207
+ if (sections.length === 0)
208
+ return "No content was returned.";
209
+ return sections.join("\n\n---\n\n");
210
+ };
211
+ /**
212
+ * Builds the LangChain tools for the Tavily endpoints named in
213
+ * `options.tools`, closed over the run's config and usage tracker. Tools are
214
+ * created per run rather than shared, because each one bills into that run's
215
+ * tracker.
216
+ *
217
+ * `web_extract` covers both of the reading modes Tavily supports through one
218
+ * tool: with a `query` the API returns only the matching chunks of each page,
219
+ * and without one it returns the whole document. Splitting that into two tools
220
+ * would duplicate the description tokens on every request to express a
221
+ * distinction the API already makes with one optional field.
222
+ *
223
+ * @param config - Config object carrying `tavilyAPIKey` and cost constants
224
+ * @param options - Normalized options from `getTavilyOptions`
225
+ * @param usageTracker - Optional accumulator billed for every Tavily call
226
+ * @returns LangChain tool instances to append to the agent's tool list
227
+ */
228
+ export const buildTavilyTools = (config, options, usageTracker = null) => {
229
+ const apiKey = config.tavilyAPIKey;
230
+ const enabled = options.tools || ["search", "extract"];
231
+ const format = options.format || "markdown";
232
+ const maxCharsPerUrl = options.maxCharsPerUrl ?? DEFAULT_MAX_CHARS_PER_URL;
233
+ const maxCharsTotal = options.maxCharsTotal ?? DEFAULT_MAX_CHARS_TOTAL;
234
+ const crawlLimit = options.crawlLimit ?? DEFAULT_CRAWL_LIMIT;
235
+ const newBudget = () => ({
236
+ remaining: maxCharsTotal,
237
+ perUrl: maxCharsPerUrl,
238
+ });
239
+ const tools = [];
240
+ if (enabled.includes("search")) {
241
+ tools.push(tool(async ({ query, maxResults }) => {
242
+ const data = await callTavily("search", {
243
+ query,
244
+ search_depth: options.searchDepth,
245
+ max_results: maxResults || options.maxResults || DEFAULT_MAX_RESULTS,
246
+ include_domains: options.includeDomains,
247
+ exclude_domains: options.excludeDomains,
248
+ // Snippets only. Full page text is `web_extract`'s job, and
249
+ // asking for it here would pay for every result to find one.
250
+ include_raw_content: false,
251
+ }, apiKey, DEFAULT_TIMEOUT_SECONDS);
252
+ const results = data?.results || [];
253
+ recordTavilyUsage(usageTracker, config, data?.usage?.credits || 0, results.map((result) => ({
254
+ url: result?.url,
255
+ ...(result?.title ? { title: result.title } : {}),
256
+ })));
257
+ if (results.length === 0)
258
+ return "No results found.";
259
+ const budget = newBudget();
260
+ return renderDocuments(results.map((result) => ({
261
+ url: result?.url,
262
+ title: result?.title,
263
+ text: result?.content || "",
264
+ })), budget);
265
+ }, {
266
+ name: "web_search",
267
+ description: "Search the web and return short relevant snippets with their URLs. Use this to find documents; to read one in full or to pull specific passages from it, follow up with web_extract on the URL.",
268
+ schema: z.object({
269
+ query: z.string().describe("The search query."),
270
+ maxResults: z
271
+ .number()
272
+ .int()
273
+ .min(1)
274
+ .max(20)
275
+ .optional()
276
+ .describe("How many results to return. Defaults to 5."),
277
+ }),
278
+ }));
279
+ }
280
+ if (enabled.includes("extract")) {
281
+ tools.push(tool(async ({ urls, query }) => {
282
+ const data = await callTavily("extract", {
283
+ urls,
284
+ extract_depth: options.extractDepth,
285
+ format,
286
+ // `query` switches the API from whole-document extraction to
287
+ // returning only the chunks that match it.
288
+ query: query || undefined,
289
+ chunks_per_source: query
290
+ ? options.chunksPerSource || DEFAULT_CHUNKS_PER_SOURCE
291
+ : undefined,
292
+ }, apiKey, DEFAULT_TIMEOUT_SECONDS);
293
+ const results = data?.results || [];
294
+ recordTavilyUsage(usageTracker, config, data?.usage?.credits || 0, results.map((result) => ({
295
+ url: result?.url,
296
+ ...(result?.title ? { title: result.title } : {}),
297
+ })));
298
+ const budget = newBudget();
299
+ let rendered = renderDocuments(results.map((result) => ({
300
+ url: result?.url,
301
+ title: result?.title,
302
+ text: result?.raw_content || "",
303
+ })), budget);
304
+ // Surface per-URL failures: the model asked for these pages, and
305
+ // silently returning fewer than requested invites it to treat a
306
+ // fetch failure as an absence of content.
307
+ const failed = data?.failed_results || [];
308
+ if (failed.length > 0) {
309
+ const lines = failed
310
+ .map((entry) => `- ${entry?.url}: ${entry?.error}`)
311
+ .join("\n");
312
+ rendered += `\n\n---\n\nFailed to extract:\n${lines}`;
313
+ }
314
+ return rendered;
315
+ }, {
316
+ name: "web_extract",
317
+ 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.",
318
+ schema: z.object({
319
+ urls: z
320
+ .array(z.string())
321
+ .min(1)
322
+ .max(20)
323
+ .describe("The URLs to read."),
324
+ query: z
325
+ .string()
326
+ .optional()
327
+ .describe("What to look for in each page. Omit to retrieve whole documents."),
328
+ }),
329
+ }));
330
+ }
331
+ if (enabled.includes("map")) {
332
+ tools.push(tool(async ({ url, instructions }) => {
333
+ const data = await callTavily("map", { url, instructions: instructions || undefined, limit: crawlLimit }, apiKey, DEFAULT_TIMEOUT_SECONDS);
334
+ const urls = data?.results || [];
335
+ recordTavilyUsage(usageTracker, config, data?.usage?.credits || 0, urls.map((entry) => ({ url: entry })));
336
+ if (urls.length === 0)
337
+ return "No URLs found.";
338
+ return `Found ${urls.length} URL(s) under ${data?.base_url || url}:\n${urls
339
+ .map((entry) => `- ${entry}`)
340
+ .join("\n")}`;
341
+ }, {
342
+ name: "web_map",
343
+ description: "List the URLs available under a site or section, without retrieving any page content. Use this to discover what a documentation site or document library holds, then read the ones you want with web_extract.",
344
+ schema: z.object({
345
+ url: z.string().describe("The site or section URL to map."),
346
+ instructions: z
347
+ .string()
348
+ .optional()
349
+ .describe("Plain-language description of the pages worth including."),
350
+ }),
351
+ }));
352
+ }
353
+ if (enabled.includes("crawl")) {
354
+ tools.push(tool(async ({ url, instructions, limit }) => {
355
+ const data = await callTavily("crawl", {
356
+ url,
357
+ instructions: instructions || undefined,
358
+ limit: Math.min(limit || crawlLimit, crawlLimit),
359
+ extract_depth: options.extractDepth,
360
+ format,
361
+ }, apiKey, DEFAULT_CRAWL_TIMEOUT_SECONDS);
362
+ const results = data?.results || [];
363
+ recordTavilyUsage(usageTracker, config, data?.usage?.credits || 0, results.map((result) => ({ url: result?.url })));
364
+ const budget = newBudget();
365
+ return renderDocuments(results.map((result) => ({
366
+ url: result?.url,
367
+ title: result?.title,
368
+ text: result?.raw_content || "",
369
+ })), budget);
370
+ }, {
371
+ name: "web_crawl",
372
+ description: "Follow links from a starting URL and return the content of the pages found. This returns full page content for every page visited, so prefer web_map followed by web_extract when you only need a few pages. Use the instructions argument to describe which pages matter.",
373
+ schema: z.object({
374
+ url: z.string().describe("The URL to start crawling from."),
375
+ instructions: z
376
+ .string()
377
+ .optional()
378
+ .describe("Plain-language description of the pages worth following."),
379
+ limit: z
380
+ .number()
381
+ .int()
382
+ .min(1)
383
+ .optional()
384
+ .describe("Maximum pages to return; capped by the configured crawl limit."),
385
+ }),
386
+ }));
387
+ }
388
+ logger.log(null, logger.levels.info, `Tavily tools enabled: ${tools.map((entry) => entry.name).join(", ")}`);
389
+ return tools;
390
+ };
391
+ export default {
392
+ assertTavilyUsable,
393
+ buildTavilyTools,
394
+ getTavilyOptions,
395
+ };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.20",
6
+ "version": "1.3.2",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -14,6 +14,7 @@
14
14
  "scripts": {
15
15
  "clean": "rm -rf dist",
16
16
  "build": "npm run clean && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json && echo '{\"type\":\"module\"}' > dist/esm/package.json",
17
+ "test": "npm run build && node --test \"test/**/*.test.js\"",
17
18
  "work:start": "bash scripts/work-start.sh",
18
19
  "work:commit": "bash scripts/work-commit.sh",
19
20
  "work:release": "bash scripts/release.sh",
@@ -62,6 +63,7 @@
62
63
  "zod": "^4.3.6"
63
64
  },
64
65
  "devDependencies": {
66
+ "@langchain/core": "^1.2.9",
65
67
  "typescript": "^5.9.3"
66
68
  }
67
69
  }