@stackfactor/agent-utils 1.11.0 → 1.13.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.
@@ -11,6 +11,12 @@
11
11
  // references cite, searched under the run's own token, so a run reads only
12
12
  // what the person it acts for may open.
13
13
  //
14
+ // A cited knowledge base that cannot be read stops the run (Candin,
15
+ // 2026-10-13): with no token, a refusal or a failed search, the gathering
16
+ // throws a KnowledgeAccessError naming the documents, and the agent answers
17
+ // with it rather than writing as if the content cited nothing. A search that
18
+ // works and finds nothing is not an error.
19
+ //
14
20
  // A reference is cited by its short id `r<N>`, N being its index in the
15
21
  // supplied list, whatever its kind.
16
22
  //
@@ -18,9 +24,43 @@ import logger from "./logger.js";
18
24
  import { STACKFACTOR_REFERENCE, retrievePassages, sourcesFromReferences, } from "./stackfactorKnowledge.js";
19
25
  /** How many passages one search asks for when the caller does not say. */
20
26
  const DEFAULT_REFERENCE_TOP_K = 5;
27
+ /** How many passages a gathering keeps when the caller does not say: the best-ranked. */
28
+ const DEFAULT_MAX_PASSAGES = 40;
21
29
  /** The prompt key the passages are handed to the model under. */
22
30
  export const REFERENCE_PASSAGES_KEY = "Knowledge Base Passages";
23
31
  const text = (value) => (typeof value === "string" ? value.trim() : "");
32
+ /**
33
+ * The knowledge base documents a run's references cite could not be read: the
34
+ * run has no token, the person may not open a cited knowledge base (401, 403,
35
+ * 404), or its search failed. The agent answers with it — `code` and `message`
36
+ * are what the person sees — and generates nothing.
37
+ */
38
+ export class KnowledgeAccessError extends Error {
39
+ /** 403 when every skipped knowledge base refused the person (or there was no token), else 502. */
40
+ code;
41
+ /** The knowledge bases that could not be searched, and why; empty when there was no token. */
42
+ skipped;
43
+ /** The titles of the STACKFACTOR references citing those knowledge bases, each once. */
44
+ documents;
45
+ /**
46
+ * @param details `skipped` (from the search) and `documents` (titles); the
47
+ * code and the message follow from them
48
+ */
49
+ constructor(details = {}) {
50
+ const skipped = Array.isArray(details.skipped) ? details.skipped : [];
51
+ const documents = [...new Set((Array.isArray(details.documents) ? details.documents : []).map(text).filter(Boolean))];
52
+ const code = skipped.every((source) => source?.noAccess) ? 403 : 502;
53
+ const titles = documents.map((title) => `"${title}"`).join(", ");
54
+ super(code === 403
55
+ ? `The knowledge base documents this content cites could not be read with your access${titles ? `: ${titles}` : ""}.`
56
+ : `The knowledge base documents this content cites could not be read: the knowledge base search failed${titles ? ` for ${titles}` : ""}.`);
57
+ this.name = "KnowledgeAccessError";
58
+ this.code = code;
59
+ this.skipped = skipped;
60
+ this.documents = documents;
61
+ Object.setPrototypeOf(this, KnowledgeAccessError.prototype);
62
+ }
63
+ }
24
64
  const id = (value) => typeof value === "string" ? value.trim() : value != null && typeof value === "object" ? String(value) : "";
25
65
  const shortId = (index) => `r${index}`;
26
66
  /** The document a STACKFACTOR reference cites, or "" when it cites none. */
@@ -29,6 +69,14 @@ const documentOf = (reference) => {
29
69
  return (id(reference.knowledgeBase?.documentId) ||
30
70
  (target.elementType === STACKFACTOR_REFERENCE.TARGET_ELEMENT_TYPE ? id(target.elementId) : ""));
31
71
  };
72
+ /** The knowledge base a STACKFACTOR reference's document is in, or "" when it cites no document. */
73
+ const knowledgeBaseOf = (reference) => {
74
+ const target = reference.target ?? {};
75
+ return (id(reference.knowledgeBase?.knowledgeBaseId) ||
76
+ (target.elementType === STACKFACTOR_REFERENCE.TARGET_ELEMENT_TYPE ? id(target.parentId) : ""));
77
+ };
78
+ /** A cap the caller passed (a whole number, 0 or more), else `fallback`. */
79
+ const capOf = (value, fallback) => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
32
80
  /** The kinds the platform retired; it reads them as PUBLICATION. */
33
81
  const RETIRED_KINDS = new Set(["DOCUMENT", "KNOWLEDGE_BASE", "NOTE"]);
34
82
  /** A reference's kind as the platform reads it: missing or retired is PUBLICATION. */
@@ -54,39 +102,71 @@ const pagesOf = (references) => {
54
102
  });
55
103
  return pages;
56
104
  };
57
- /** The short ids of the STACKFACTOR references citing each document. */
58
- const citersByDocument = (references) => {
59
- const citers = new Map();
105
+ /**
106
+ * The short ids of the STACKFACTOR references citing each document, and of
107
+ * those citing each chunk (`knowledgeBase.chunkId`) of a document.
108
+ */
109
+ const citersOf = (references) => {
110
+ const byDocument = new Map();
111
+ const byChunk = new Map();
60
112
  references.forEach((reference, index) => {
61
113
  if (reference?.kind !== STACKFACTOR_REFERENCE.KIND)
62
114
  return;
63
115
  const documentId = documentOf(reference);
64
- if (documentId)
65
- citers.set(documentId, [...(citers.get(documentId) ?? []), shortId(index)]);
116
+ if (!documentId)
117
+ return;
118
+ byDocument.set(documentId, [...(byDocument.get(documentId) ?? []), shortId(index)]);
119
+ const chunkId = id(reference.knowledgeBase?.chunkId);
120
+ if (chunkId)
121
+ byChunk.set(chunkId, [...(byChunk.get(chunkId) ?? []), shortId(index)]);
66
122
  });
67
- return citers;
123
+ return { byChunk, byDocument };
68
124
  };
125
+ /**
126
+ * The error for cited knowledge bases that could not be read: it names the
127
+ * documents of the STACKFACTOR references citing them.
128
+ */
129
+ const accessError = (references, unread, skipped) => new KnowledgeAccessError({
130
+ documents: references
131
+ .filter((reference) => reference?.kind === STACKFACTOR_REFERENCE.KIND && documentOf(reference) && unread.has(knowledgeBaseOf(reference)))
132
+ .map((reference) => reference.title),
133
+ skipped,
134
+ });
69
135
  /**
70
136
  * What a list of references points at: its pages, and the passages of the
71
137
  * documents its STACKFACTOR references cite. One search per knowledge base per
72
- * query (per batch of 500 documents), however many references there are. With
73
- * no token no search is made and the pages are still listed. A knowledge base
74
- * the person cannot open is in `skipped`; the other passages stand.
138
+ * query (per batch of 500 documents), however many references there are.
139
+ *
140
+ * References that cite a knowledge base the run cannot read stop it: with no
141
+ * token, or when any cited knowledge base is skipped (refused or failed), it
142
+ * throws a KnowledgeAccessError naming the documents. A search that works and
143
+ * finds nothing is not an error. With no knowledge cited, no token is needed.
144
+ *
145
+ * A passage is attributed to the references citing its chunk, else to every
146
+ * reference citing its document. The best-ranked `maxPassages` are kept, and
147
+ * the first `maxPages` pages in reference order.
75
148
  * @param options `references` (a skill's, a microskill's…), `queries` to
76
149
  * search the cited documents with, `search` (client-api
77
150
  * `knowledgeBaseAgent.search`), `token` (the run's AGENT token), `topK` per
78
- * search (default 5)
79
- * @returns `{ pages, passages, skipped, citesKnowledge }`
151
+ * search (default 5), `maxPassages` (default 40), `maxPages` (default: all)
152
+ * @returns `{ pages, passages, skipped, citesKnowledge }`; `skipped` is always
153
+ * empty, since a skip throws
154
+ * @throws KnowledgeAccessError When cited knowledge bases cannot be read
80
155
  */
81
156
  export const gatherReferenceContext = async (options) => {
82
157
  const references = Array.isArray(options.references) ? options.references : [];
83
158
  const sources = sourcesFromReferences(references);
84
159
  const citesKnowledge = sources.length > 0;
85
- const pages = pagesOf(references);
86
- if (!options.token || !citesKnowledge) {
87
- logContext(pages.length, 0, [], citesKnowledge && !options.token);
160
+ const pages = pagesOf(references).slice(0, capOf(options.maxPages, Infinity));
161
+ if (!citesKnowledge) {
162
+ logContext(pages.length, 0, []);
88
163
  return { citesKnowledge, pages, passages: [], skipped: [] };
89
164
  }
165
+ if (!options.token) {
166
+ const error = accessError(references, new Set(sources.map((source) => source.knowledgeBaseId)), []);
167
+ logContext(pages.length, 0, [], { error, noToken: true });
168
+ throw error;
169
+ }
90
170
  const found = await retrievePassages({
91
171
  queries: options.queries,
92
172
  search: options.search,
@@ -94,30 +174,44 @@ export const gatherReferenceContext = async (options) => {
94
174
  token: options.token,
95
175
  topK: options.topK ?? DEFAULT_REFERENCE_TOP_K,
96
176
  });
97
- const citers = citersByDocument(references);
177
+ if (found.skipped.length > 0) {
178
+ const unread = new Set(found.skipped.map((source) => source.knowledgeBaseId));
179
+ const error = accessError(references, unread, found.skipped);
180
+ logContext(pages.length, 0, found.skipped, { error });
181
+ throw error;
182
+ }
183
+ const citers = citersOf(references);
98
184
  const passages = found.passages
99
- .filter((passage) => citers.has(passage.documentId))
185
+ .filter((passage) => citers.byDocument.has(passage.documentId))
186
+ .slice(0, capOf(options.maxPassages, DEFAULT_MAX_PASSAGES))
100
187
  .map((passage) => ({
101
188
  chunkId: passage.chunkId,
102
189
  document: passage.documentName,
103
190
  documentId: passage.documentId,
104
191
  knowledgeBaseId: passage.knowledgeBaseId,
105
- references: citers.get(passage.documentId),
192
+ references: citers.byChunk.get(passage.chunkId) ?? citers.byDocument.get(passage.documentId),
106
193
  section: passage.sectionTitle,
107
194
  text: passage.text,
108
195
  }));
109
- logContext(pages.length, passages.length, found.skipped, false);
110
- return { citesKnowledge, pages, passages, skipped: found.skipped };
196
+ logContext(pages.length, passages.length, []);
197
+ return { citesKnowledge, pages, passages, skipped: [] };
111
198
  };
112
- /** The one summary line of a gathering, for the logs. */
113
- const logContext = (pages, passages, skipped, noToken) => {
199
+ /**
200
+ * The one summary line of a gathering, for the logs: a warning when it was
201
+ * refused. Document titles stay out of the logs.
202
+ */
203
+ const logContext = (pages, passages, skipped, refusal) => {
114
204
  const parts = [`${pages} page${pages === 1 ? "" : "s"}`, `${passages} passage${passages === 1 ? "" : "s"}`];
115
205
  if (skipped.length > 0) {
116
206
  parts.push(`skipped knowledge bases: ${skipped.map((s) => `${s.knowledgeBaseId} (noAccess: ${s.noAccess})`).join(", ")}`);
117
207
  }
118
- if (noToken)
119
- parts.push("no token, so no passages");
120
- logger.log(null, logger.levels.info, `Reference context: ${parts.join("; ")}`);
208
+ if (refusal?.noToken)
209
+ parts.push("no token");
210
+ if (refusal) {
211
+ const count = refusal.error.documents.length;
212
+ parts.push(`refused (${refusal.error.code}): ${count} document${count === 1 ? "" : "s"} unreadable`);
213
+ }
214
+ logger.log(null, refusal ? logger.levels.warn : logger.levels.info, `Reference context: ${parts.join("; ")}`);
121
215
  };
122
216
  /**
123
217
  * The passages as a prompt shows them: who cites each, where it came from, and
@@ -133,19 +227,23 @@ export const referencePassagesForPrompt = (context) => context.passages.map((pas
133
227
  }));
134
228
  /**
135
229
  * The tools a run is given. Never `search`, `map` or `crawl`: a run reads what
136
- * its references point at and discovers nothing. `extract` only, and only when
137
- * there are pages to open.
230
+ * its references point at and discovers nothing. `extract` only, only when
231
+ * there are pages to open, and kept to those pages: any other url the model
232
+ * asks for is refused (`allowedUrls`).
138
233
  * @param context From `gatherReferenceContext`
139
- * @returns `{ tavily: { tools: ["extract"] } }` with pages, else `{}`. With no
140
- * pages it returns `{}`, so a caller spreading it over a config that already
141
- * has a `tavily` key keeps that key (and `tavily: true` means search plus
142
- * extract): callers must not carry a base `tavily` key.
234
+ * @returns `{ tavily: { tools: ["extract"], allowedUrls } }` with pages, else
235
+ * `{}`. With no pages it returns `{}`, so a caller spreading it over a config
236
+ * that already has a `tavily` key keeps that key (and `tavily: true` means
237
+ * search plus extract): callers must not carry a base `tavily` key.
143
238
  */
144
- export const toolsFor = (context) => context.pages.length > 0 ? { tavily: { tools: ["extract"] } } : {};
239
+ export const toolsFor = (context) => context.pages.length > 0
240
+ ? { tavily: { tools: ["extract"], allowedUrls: context.pages.map((page) => page.url) } }
241
+ : {};
145
242
  /**
146
243
  * The stage prompt followed by the guidance for each kind of source the run
147
- * has: pages, then passages; when it has neither, the `none` guidance. An empty
148
- * guidance adds nothing.
244
+ * has: pages, then passages; when it has neither (knowledge cited and readable
245
+ * but no passage found included), the `none` guidance. An empty guidance adds
246
+ * nothing.
149
247
  * @param stagePrompt The stage's own prompt
150
248
  * @param context From `gatherReferenceContext`
151
249
  * @param guidance From `sourceGuidanceFrom`
@@ -271,6 +369,7 @@ export const sourceGuidanceFrom = (config) => ({
271
369
  none: text(config?.noReferencesGuidance),
272
370
  });
273
371
  export default {
372
+ KnowledgeAccessError,
274
373
  REFERENCE_PASSAGES_KEY,
275
374
  citedReferences,
276
375
  composeSourceInstructions,
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAWtC,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAwPpB;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,uEAAuE;IACvE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IACpE,sEAAsE;IACtE,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,mFAAmF;IACnF,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACxD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,EAAE,CACN,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,KACV,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACpC;AAKD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;;;CAKhB,CAAC;AAEX,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,OACJ,CAAC;AAKvE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAEhB,CAAC;AAKlD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAGN,CAAC;AA4JrD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,OAAO,GAClB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,OAAO,CAAC,IAAI,CA8Id,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MAyOP,CAAC"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAWtC,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAwPpB;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,uEAAuE;IACvE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IACpE,sEAAsE;IACtE,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,mFAAmF;IACnF,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACxD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,EAAE,CACN,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,KACV,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACpC;AAKD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;;;CAKhB,CAAC;AAEX,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,OACJ,CAAC;AAKvE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAEhB,CAAC;AAKlD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAGN,CAAC;AAmKrD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,OAAO,GAClB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,OAAO,CAAC,IAAI,CA8Id,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MAyOP,CAAC"}
package/dist/esm/serve.js CHANGED
@@ -339,8 +339,15 @@ const safeParse = (value, fallback) => {
339
339
  /**
340
340
  * Agents that fail throw `new Error(JSON.stringify({ code, message }))`. Unwrap
341
341
  * that back into a structured gRPC error frame; fall back to a 500 otherwise.
342
+ * A KnowledgeAccessError an agent did not catch (referenceContext) answers
343
+ * with its own code (403, 502) and message, which names the documents that
344
+ * could not be read. It is known by its name, so one thrown by another copy of
345
+ * this package in the agent's dependencies is known too.
342
346
  */
343
347
  const toErrorFrame = (error) => {
348
+ if (error?.name === "KnowledgeAccessError" && typeof error.code === "number") {
349
+ return { code: error.code, message: String(error.message ?? "") };
350
+ }
344
351
  try {
345
352
  const parsed = JSON.parse(error?.message);
346
353
  if (parsed && typeof parsed.code === "number") {
@@ -17,6 +17,8 @@ export interface KnowledgeChunk {
17
17
  knowledgeBaseId?: string;
18
18
  documentId: string;
19
19
  documentName?: string | null;
20
+ /** The title taken from the document itself (its front matter or first heading); null when it has none. */
21
+ documentTitle?: string | null;
20
22
  /** The document's page at its source (sent by the platform's search since backend-core #630). */
21
23
  documentUrl?: string | null;
22
24
  sectionTitle?: string | null;
@@ -50,6 +52,8 @@ export interface GroundingPassage {
50
52
  knowledgeBaseId: string;
51
53
  documentId: string;
52
54
  documentName: string | null;
55
+ /** The title taken from the document itself; null when it has none. */
56
+ documentTitle?: string | null;
53
57
  /** The document's page at its source, an http(s) address; null when the platform has none. */
54
58
  documentUrl?: string | null;
55
59
  sectionTitle: string | null;
@@ -67,7 +71,10 @@ export interface SkippedSource {
67
71
  knowledgeBaseId: string;
68
72
  /** The HTTP status, when the platform answered one. */
69
73
  status: number | null;
70
- /** True when the person may not read it (or it is gone): expected, not a fault. */
74
+ /**
75
+ * True when the platform refused the person (401, 403) or does not have the
76
+ * knowledge base (404); false when the search failed otherwise.
77
+ */
71
78
  noAccess: boolean;
72
79
  message: string;
73
80
  }
@@ -86,6 +93,8 @@ export interface StackFactorReference {
86
93
  knowledgeBase: {
87
94
  knowledgeBaseId: string;
88
95
  documentId: string;
96
+ /** The document's name at its source (GitHub: `<repo>/<path>`); absent when the passage has none. */
97
+ documentName?: string;
89
98
  chunkId: string;
90
99
  sectionTitle: string | null;
91
100
  };
@@ -116,9 +125,11 @@ export declare const sourcesFromCoverage: (coverage: unknown) => KnowledgeSource
116
125
  * model to cite. A source with documents is searched within them (in batches of
117
126
  * SEARCH_DOCUMENT_IDS_MAX); one without, whole. A source the person may not
118
127
  * read (401, 403, 404) is skipped: none of its passages are used and no more of
119
- * its searches are made. A search that fails otherwise is reported and the
120
- * passages its other searches found stand. Either way it is in `skipped`, once
121
- * per knowledge base, and the passages of the others stand.
128
+ * its searches are made. A search that fails otherwise is tried once more; when
129
+ * that fails too it is reported and the passages its other searches found
130
+ * stand. A refusal is never tried again. Either way it is in `skipped`, once
131
+ * per knowledge base, and the passages of the others stand; whether a skip is
132
+ * an error is the caller's to say.
122
133
  * @param options `sources`, `queries`, `search` (client-api
123
134
  * `knowledgeBaseAgent.search`), `token` (the run's AGENT token), `topK`
124
135
  * per search (default DEFAULT_TOP_K)
@@ -149,7 +160,9 @@ export declare const passagesForPrompt: (passages: GroundingPassage[]) => {
149
160
  * STACKFACTOR references for passages: one per passage, in their order, with no
150
161
  * cap. With `tags`, only the passages the model cited by tag (any tag it made
151
162
  * up is passed over); without, every passage. Each links to its document's
152
- * page at the source (`url`) when the passage has one.
163
+ * page at the source (`url`) when the passage has one. Each is titled by its
164
+ * document's title, else its name, else its section; its pointer keeps the
165
+ * document's name (`knowledgeBase.documentName`) when the passage has one.
153
166
  * @param passages From `retrievePassages`
154
167
  * @param tags The tags the model cited ("k3", "K3" and " k3 " alike)
155
168
  * @returns The references, ready for a skill's, a microskill's or a question's `references`
@@ -1 +1 @@
1
- {"version":3,"file":"stackfactorKnowledge.d.ts","sourceRoot":"","sources":["../../src/stackfactorKnowledge.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"stackfactorKnowledge.d.ts","sourceRoot":"","sources":["../../src/stackfactorKnowledge.ts"],"names":[],"mappings":"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"}
@@ -14,13 +14,19 @@
14
14
  // microskill;
15
15
  // - access: the platform's search judges the person the run acts for, so a
16
16
  // run reads only what that person may open. A knowledge base the person
17
- // cannot read is skipped and reported, never an error: the agent
18
- // generates without it.
17
+ // cannot read is skipped and reported (`retrievePassages`): grounding from
18
+ // skill coverage generates without it. A knowledge base that references
19
+ // cite is another matter (Candin, 2026-10-13): the references reader
20
+ // (referenceContext `gatherReferenceContext`) stops the run with a
21
+ // KnowledgeAccessError instead.
19
22
  //
20
23
  // A reference points at the content and never copies it: the passage is
21
24
  // searched again, under the reader's own access, whenever it is used. Its
22
25
  // `url` is the document's page at its source (GitHub, Confluence…), when the
23
- // platform knows it, so a person can open what was cited. It is
26
+ // platform knows it, so a person can open what was cited. Its title is the
27
+ // title taken from the document itself, when ingestion found one, and its
28
+ // pointer keeps the document's name at the source (`documentName`), so a
29
+ // person can still see where the titled document lives. It is
24
30
  // EDIT-only (`visibility: "EDIT"`): the platform strips it from what is
25
31
  // published, so a knowledge base's titles never reach a learner; agents read
26
32
  // it from the draft.
@@ -46,6 +52,8 @@ export const DEFAULT_TOP_K = 8;
46
52
  const CONCURRENCY = 4;
47
53
  /** Statuses that mean the person may not read the knowledge base, or it is gone. */
48
54
  const NO_ACCESS = new Set([401, 403, 404]);
55
+ /** How long a search that failed for another reason waits before it is tried once more. */
56
+ const RETRY_DELAY_MS = 200;
49
57
  const text = (value) => (typeof value === "string" ? value.trim() : "");
50
58
  /** An http(s) address, or null: a reference's url lands in an `href`. */
51
59
  const webUrl = (value) => (/^https?:\/\//i.test(text(value)) ? text(value) : null);
@@ -125,15 +133,18 @@ const statusOf = (error) => {
125
133
  const status = error?.response?.status ?? error?.status;
126
134
  return typeof status === "number" ? status : null;
127
135
  };
136
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
128
137
  /**
129
138
  * Searches every source for every query, under the person's access, and hands
130
139
  * back the passages found, best first, each once, tagged "k1", "k2"… for the
131
140
  * model to cite. A source with documents is searched within them (in batches of
132
141
  * SEARCH_DOCUMENT_IDS_MAX); one without, whole. A source the person may not
133
142
  * read (401, 403, 404) is skipped: none of its passages are used and no more of
134
- * its searches are made. A search that fails otherwise is reported and the
135
- * passages its other searches found stand. Either way it is in `skipped`, once
136
- * per knowledge base, and the passages of the others stand.
143
+ * its searches are made. A search that fails otherwise is tried once more; when
144
+ * that fails too it is reported and the passages its other searches found
145
+ * stand. A refusal is never tried again. Either way it is in `skipped`, once
146
+ * per knowledge base, and the passages of the others stand; whether a skip is
147
+ * an error is the caller's to say.
137
148
  * @param options `sources`, `queries`, `search` (client-api
138
149
  * `knowledgeBaseAgent.search`), `token` (the run's AGENT token), `topK`
139
150
  * per search (default DEFAULT_TOP_K)
@@ -162,8 +173,24 @@ export const retrievePassages = async (options) => {
162
173
  await inPool(searches, CONCURRENCY, async ({ source, query, documentIds, order }) => {
163
174
  if (skipped.get(source.knowledgeBaseId)?.noAccess)
164
175
  return;
176
+ const searchOnce = () => options.search(source.knowledgeBaseId, query, documentIds ? { topK, documentIds } : { topK }, options.token);
165
177
  try {
166
- const answer = await options.search(source.knowledgeBaseId, query, documentIds ? { topK, documentIds } : { topK }, options.token);
178
+ let answer;
179
+ try {
180
+ answer = await searchOnce();
181
+ }
182
+ catch (error) {
183
+ // A refusal is the platform's answer and is not asked again. Any other
184
+ // failure may pass (a dropped connection, a 5xx), and one failed search
185
+ // would skip its whole knowledge base, so it is tried once more —
186
+ // unless the base refused the person meanwhile.
187
+ if (NO_ACCESS.has(statusOf(error)))
188
+ throw error;
189
+ await delay(RETRY_DELAY_MS);
190
+ if (skipped.get(source.knowledgeBaseId)?.noAccess)
191
+ return;
192
+ answer = await searchOnce();
193
+ }
167
194
  (Array.isArray(answer?.chunks) ? answer.chunks : []).forEach((chunk, rank) => {
168
195
  const chunkId = id(chunk?._id);
169
196
  if (!chunkId || !id(chunk?.documentId))
@@ -199,6 +226,7 @@ export const retrievePassages = async (options) => {
199
226
  chunkId,
200
227
  documentId: id(chunk.documentId),
201
228
  documentName: text(chunk.documentName) || null,
229
+ documentTitle: text(chunk.documentTitle) || null,
202
230
  documentUrl: webUrl(chunk.documentUrl),
203
231
  knowledgeBaseId: id(chunk.knowledgeBaseId) || knowledgeBaseId,
204
232
  pageNumber: typeof chunk.pageNumber === "number" ? chunk.pageNumber : null,
@@ -228,7 +256,9 @@ export const passagesForPrompt = (passages) => (Array.isArray(passages) ? passag
228
256
  * STACKFACTOR references for passages: one per passage, in their order, with no
229
257
  * cap. With `tags`, only the passages the model cited by tag (any tag it made
230
258
  * up is passed over); without, every passage. Each links to its document's
231
- * page at the source (`url`) when the passage has one.
259
+ * page at the source (`url`) when the passage has one. Each is titled by its
260
+ * document's title, else its name, else its section; its pointer keeps the
261
+ * document's name (`knowledgeBase.documentName`) when the passage has one.
232
262
  * @param passages From `retrievePassages`
233
263
  * @param tags The tags the model cited ("k3", "K3" and " k3 " alike)
234
264
  * @returns The references, ready for a skill's, a microskill's or a question's `references`
@@ -242,12 +272,14 @@ export const referencesFor = (passages, tags) => {
242
272
  .filter((passage) => !cited || cited.has(passage.tag.toLowerCase()))
243
273
  .map((passage) => {
244
274
  const url = webUrl(passage.documentUrl);
275
+ const documentName = text(passage.documentName);
245
276
  return {
246
277
  description: passage.sectionTitle,
247
278
  kind: STACKFACTOR_REFERENCE.KIND,
248
279
  knowledgeBase: {
249
280
  chunkId: passage.chunkId,
250
281
  documentId: passage.documentId,
282
+ ...(documentName ? { documentName } : {}),
251
283
  knowledgeBaseId: passage.knowledgeBaseId,
252
284
  sectionTitle: passage.sectionTitle,
253
285
  },
@@ -257,7 +289,7 @@ export const referencesFor = (passages, tags) => {
257
289
  elementType: STACKFACTOR_REFERENCE.TARGET_ELEMENT_TYPE,
258
290
  parentId: passage.knowledgeBaseId,
259
291
  },
260
- title: passage.documentName || passage.sectionTitle || "Knowledge base document",
292
+ title: text(passage.documentTitle) || documentName || passage.sectionTitle || "Knowledge base document",
261
293
  ...(url ? { url } : {}),
262
294
  visibility: STACKFACTOR_REFERENCE.VISIBILITY,
263
295
  };
@@ -46,6 +46,14 @@ export type TavilyConfig = {
46
46
  * for the tabular documents this is usually pointed at.
47
47
  */
48
48
  format?: "markdown" | "text";
49
+ /**
50
+ * The only URLs `web_extract` may read (matched trimmed, without case or a
51
+ * trailing slash, and sent as listed). Any other URL is never sent to
52
+ * Tavily: the tool answers the model with an error line for it and never
53
+ * throws. Unset, any URL may be read. Only `web_extract` reads it, so a run
54
+ * kept to its pages enables `extract` alone (referenceContext `toolsFor`).
55
+ */
56
+ allowedUrls?: string[];
49
57
  };
50
58
  /**
51
59
  * Normalizes `config.tavily` into an options object, returning `null` when the
@@ -1 +1 @@
1
- {"version":3,"file":"tavily.d.ts","sourceRoot":"","sources":["../../src/tavily.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AA0BnD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAEpE;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAC;IAC3D,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IACpC,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;CAC9B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,GAAG,KAAG,YAAY,GAAG,IAI7D,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,SAAS,YAAY,KAAG,IAsBvE,CAAC;AAsLF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,GAAG,EACX,SAAS,YAAY,EACrB,eAAc,YAAY,GAAG,IAAW,KACvC,GAAG,EA2PL,CAAC;AAqBF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,kBAAkB,GAC7B,MAAM,SAAS,MAAM,EAAE,EACvB,QAAQ,GAAG,EACX,eAAc,YAAY,GAAG,IAAW,KACvC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CA6DrB,CAAC;;iCA/kByC,GAAG,WAAW,YAAY,KAAG,IAAI;+BA8NlE,GAAG,WACF,YAAY,iBACP,YAAY,GAAG,IAAI,KAChC,GAAG,EAAE;+BA8SA,SAAS,MAAM,EAAE,UACf,GAAG,iBACG,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;+BAjiBkB,GAAG,KAAG,YAAY,GAAG,IAAI;iCAsIvD,YAAY,GAAG,IAAI,GAAG,SAAS,WAC/B,GAAG,WACH,MAAM,WACN;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,KACzC,IAAI;;AAsdP,wBAME"}
1
+ {"version":3,"file":"tavily.d.ts","sourceRoot":"","sources":["../../src/tavily.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AA0BnD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAEpE;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAC;IAC3D,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IACpC,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;IAC7B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,GAAG,KAAG,YAAY,GAAG,IAI7D,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,SAAS,YAAY,KAAG,IA+BvE,CAAC;AA0OF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,GAAG,EACX,SAAS,YAAY,EACrB,eAAc,YAAY,GAAG,IAAW,KACvC,GAAG,EAmRL,CAAC;AAiBF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,kBAAkB,GAC7B,MAAM,SAAS,MAAM,EAAE,EACvB,QAAQ,GAAG,EACX,eAAc,YAAY,GAAG,IAAW,KACvC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CA6DrB,CAAC;;iCAhqByC,GAAG,WAAW,YAAY,KAAG,IAAI;+BA2RlE,GAAG,WACF,YAAY,iBACP,YAAY,GAAG,IAAI,KAChC,GAAG,EAAE;+BAkUA,SAAS,MAAM,EAAE,UACf,GAAG,iBACG,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;+BAlnBkB,GAAG,KAAG,YAAY,GAAG,IAAI;iCA+IvD,YAAY,GAAG,IAAI,GAAG,SAAS,WAC/B,GAAG,WACH,MAAM,WACN;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,KACzC,IAAI;;AA8hBP,wBAME"}
@@ -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.11.0",
6
+ "version": "1.13.0",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",