@dbx-tools/appkit-web-search 0.3.27 → 0.3.29

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.
package/README.md CHANGED
@@ -223,7 +223,9 @@ the [Databricks docs](https://docs.databricks.com/aws/en/machine-learning/model-
223
223
  - `provider` - provider detection + the provider -> tool-spec map.
224
224
  - `scrape` - `runScrapeSearch()` DuckDuckGo fallback for workspaces with no
225
225
  native web-search model.
226
- - `fetch` - `runWebFetch()` over got-scraping, plus `htmlToText()`.
226
+ - `fetch` - `runWebFetch()` over got-scraping.
227
+ - `html-text` - `htmlToText()` / `htmlFragmentToText()` / `decodeHtmlEntities()`, shared by
228
+ `fetch` and the DuckDuckGo scrape fallback.
227
229
  - `runtime` - shared runtime, `getWebSearchRuntime()`, `resetWebSearchRuntime()`.
228
230
  - `config` - config types, JSON schema, `resolveWebSearchConfig()`, approval helpers.
229
231
  - `allowlist` - URL allow-list parsing/compiling on top of `@dbx-tools/path`.
package/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  export * as allowlist from "./src/allowlist";
6
6
  export * as config from "./src/config";
7
7
  export * as fetch from "./src/fetch";
8
+ export * as htmlText from "./src/html-text";
8
9
  export * as plugin from "./src/plugin";
9
10
  export * as provider from "./src/provider";
10
11
  export * as runtime from "./src/runtime";
package/package.json CHANGED
@@ -17,17 +17,17 @@
17
17
  "@mastra/core": "^1.47.0",
18
18
  "got-scraping": "^4.2.1",
19
19
  "zod": "^4.3.6",
20
- "@dbx-tools/model": "0.3.27",
21
- "@dbx-tools/path": "0.3.27",
22
- "@dbx-tools/shared-core": "0.3.27",
23
- "@dbx-tools/shared-model": "0.3.27"
20
+ "@dbx-tools/path": "0.3.29",
21
+ "@dbx-tools/model": "0.3.29",
22
+ "@dbx-tools/shared-core": "0.3.29",
23
+ "@dbx-tools/shared-model": "0.3.29"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.27",
30
+ "version": "0.3.29",
31
31
  "types": "index.ts",
32
32
  "type": "module",
33
33
  "exports": {
package/src/allowlist.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { match, type PathMatcher } from "@dbx-tools/path";
33
- import { object } from "@dbx-tools/shared-core";
33
+ import { string } from "@dbx-tools/shared-core";
34
34
 
35
35
  /** A compiled URL allow-list. Build one with {@link toUrlAllowList}. */
36
36
  export interface UrlAllowList {
@@ -64,15 +64,7 @@ export function normalizeUrlPattern(pattern: string): string {
64
64
  * identically.
65
65
  */
66
66
  export function parseAllowedUrls(raw: string | string[] | undefined): string[] {
67
- const entries = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
68
- return [
69
- ...object
70
- .sequence(entries)
71
- .map((entry) => normalizeUrlPattern(entry))
72
- .filter((entry) => entry.length > 0)
73
- .distinct()
74
- .toArray(),
75
- ];
67
+ return string.parseList(raw, normalizeUrlPattern);
76
68
  }
77
69
 
78
70
  /** One compiled entry: which URL slice it tests, and the matcher for it. */
package/src/config.ts CHANGED
@@ -26,7 +26,8 @@
26
26
  */
27
27
 
28
28
  import type { BasePluginConfig } from "@databricks/appkit";
29
- import { object, type OneOrMany } from "@dbx-tools/shared-core";
29
+ import { serving } from "@dbx-tools/model";
30
+ import { json, object, type OneOrMany, string } from "@dbx-tools/shared-core";
30
31
  import type { JSONSchema7 } from "json-schema";
31
32
  import { parseAllowedUrls, toUrlAllowList, type UrlAllowList } from "./allowlist";
32
33
 
@@ -93,7 +94,7 @@ export interface WebSearchPluginConfig extends BasePluginConfig {
93
94
  * Defaults to `true`; falls back to `WEB_SEARCH_FUZZY`.
94
95
  */
95
96
  modelFuzzyMatch?: boolean;
96
- /** Fuse.js fuzzy threshold. Falls back to `WEB_SEARCH_FUZZY_THRESHOLD`, then 0.4. */
97
+ /** Fuse.js fuzzy threshold. Falls back to `WEB_SEARCH_FUZZY_THRESHOLD`, then the shared default. */
97
98
  modelFuzzyThreshold?: number;
98
99
  /**
99
100
  * Hard cap on the number of citations a single search returns. Falls back
@@ -200,19 +201,6 @@ export const WEB_SEARCH_CONFIG_SCHEMA: JSONSchema7 = {
200
201
  },
201
202
  };
202
203
 
203
- /** Split a CSV / whitespace / array value into a trimmed, non-empty string list. */
204
- function toStringList(raw: string | string[] | undefined): string[] {
205
- const entries = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
206
- return [
207
- ...object
208
- .sequence(entries)
209
- .map((e) => e.trim())
210
- .filter((e) => e.length > 0)
211
- .distinct()
212
- .toArray(),
213
- ];
214
- }
215
-
216
204
  /** Parse a positive integer env/config value, else the fallback. */
217
205
  function resolvePositiveInt(value: number | undefined, envKey: string, fallback: number): number {
218
206
  if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
@@ -222,14 +210,7 @@ function resolvePositiveInt(value: number | undefined, envKey: string, fallback:
222
210
 
223
211
  /** Parse the `WEB_SEARCH_TOOLS` env var (JSON), else `{}`. Bad JSON is ignored. */
224
212
  function parseToolsEnv(): Record<string, unknown> {
225
- const raw = process.env.WEB_SEARCH_TOOLS;
226
- if (!raw) return {};
227
- try {
228
- const parsed = JSON.parse(raw) as unknown;
229
- return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
230
- } catch {
231
- return {};
232
- }
213
+ return json.parseRecord(process.env.WEB_SEARCH_TOOLS) ?? {};
233
214
  }
234
215
 
235
216
  /**
@@ -243,7 +224,9 @@ export function resolveWebSearchConfig(
243
224
  ): ResolvedWebSearchConfig {
244
225
  const patterns = parseAllowedUrls(config.allowedUrls ?? process.env.WEB_SEARCH_ALLOWED_URLS);
245
226
  const model = config.model ?? process.env.WEB_SEARCH_MODEL;
246
- const fallbacks = toStringList(config.modelFallbacks ?? process.env.WEB_SEARCH_MODEL_FALLBACKS);
227
+ const fallbacks = string.parseList(
228
+ config.modelFallbacks ?? process.env.WEB_SEARCH_MODEL_FALLBACKS,
229
+ );
247
230
  const fuzzyThresholdRaw =
248
231
  config.modelFuzzyThreshold ?? Number(process.env.WEB_SEARCH_FUZZY_THRESHOLD);
249
232
  return {
@@ -252,7 +235,9 @@ export function resolveWebSearchConfig(
252
235
  webSearchTools: { ...parseToolsEnv(), ...(config.webSearchTools ?? {}) },
253
236
  fuzzy: config.modelFuzzyMatch ?? object.toBoolean(process.env.WEB_SEARCH_FUZZY) ?? true,
254
237
  fuzzyThreshold:
255
- Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw ? Number(fuzzyThresholdRaw) : 0.4,
238
+ Number.isFinite(fuzzyThresholdRaw) && fuzzyThresholdRaw
239
+ ? Number(fuzzyThresholdRaw)
240
+ : serving.DEFAULT_FUZZY_THRESHOLD,
256
241
  maxCitations: resolvePositiveInt(
257
242
  config.maxCitations,
258
243
  "WEB_SEARCH_MAX_CITATIONS",
package/src/fetch.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { log } from "@dbx-tools/shared-core";
19
19
  import { gotScraping } from "got-scraping";
20
20
  import { assertUrlAllowed } from "./allowlist";
21
+ import { decodeHtmlEntities, htmlToText } from "./html-text";
21
22
  import type { ResolvedWebSearchConfig } from "./config";
22
23
  import type { WebFetchRequest, WebFetchResult } from "./schema";
23
24
 
@@ -26,43 +27,10 @@ const logger = log.logger("web-search/fetch");
26
27
  /** Pull the <title> text out of an HTML document, when present. */
27
28
  function extractTitle(html: string): string | undefined {
28
29
  const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
29
- const title = match?.[1] ? decodeEntities(match[1]).trim() : "";
30
+ const title = match?.[1] ? decodeHtmlEntities(match[1]).trim() : "";
30
31
  return title.length > 0 ? title : undefined;
31
32
  }
32
33
 
33
- /** Decode the handful of HTML entities that survive tag-stripping. */
34
- function decodeEntities(text: string): string {
35
- return text
36
- .replace(/&nbsp;/g, " ")
37
- .replace(/&amp;/g, "&")
38
- .replace(/&lt;/g, "<")
39
- .replace(/&gt;/g, ">")
40
- .replace(/&quot;/g, '"')
41
- .replace(/&#39;/g, "'")
42
- .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
43
- }
44
-
45
- /**
46
- * Reduce an HTML document to readable plain text: drop `<script>` /
47
- * `<style>` / `<noscript>` blocks and HTML comments, turn block-level tags
48
- * into newlines, strip the remaining tags, decode entities, and collapse
49
- * runs of blank lines / trailing spaces.
50
- */
51
- export function htmlToText(html: string): string {
52
- return decodeEntities(
53
- html
54
- .replace(/<!--[\s\S]*?-->/g, "")
55
- .replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
56
- .replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
57
- .replace(/<br\s*\/?>/gi, "\n")
58
- .replace(/<[^>]+>/g, ""),
59
- )
60
- .replace(/[ \t]+\n/g, "\n")
61
- .replace(/\n{3,}/g, "\n\n")
62
- .replace(/[ \t]{2,}/g, " ")
63
- .trim();
64
- }
65
-
66
34
  /** Truncate `text` to `max` chars, reporting whether it was cut. */
67
35
  function truncate(text: string, max: number): { content: string; truncated: boolean } {
68
36
  if (text.length <= max) return { content: text, truncated: false };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Turning fetched HTML into the plain text a model can read.
3
+ *
4
+ * Both scraping paths need this: {@link fetchUrl}'s full-page read and the
5
+ * DuckDuckGo fallback's title/snippet extraction. They previously carried
6
+ * separate entity tables that drifted, so the decode lives here once.
7
+ *
8
+ * This is deliberately regex-based rather than a DOM parse. The inputs are
9
+ * whole pages fetched for summarization, not documents to be queried, so the
10
+ * cost of a real parser buys nothing - and a malformed page still degrades to
11
+ * readable text instead of throwing.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /**
17
+ * The named entities that survive tag-stripping in practice, plus numeric
18
+ * escapes. Not a complete HTML entity table - anything rarer passes through
19
+ * as-is, which reads acceptably in model input.
20
+ */
21
+ const NAMED_ENTITIES: Record<string, string> = {
22
+ "&nbsp;": " ",
23
+ "&amp;": "&",
24
+ "&lt;": "<",
25
+ "&gt;": ">",
26
+ "&quot;": '"',
27
+ "&#39;": "'",
28
+ "&#x27;": "'",
29
+ "&apos;": "'",
30
+ };
31
+
32
+ const NAMED_ENTITY_REGEXP = new RegExp(Object.keys(NAMED_ENTITIES).join("|"), "g");
33
+ const NUMERIC_ENTITY_REGEXP = /&#(\d+);/g;
34
+ const TAG_REGEXP = /<[^>]+>/g;
35
+
36
+ /** Decode the HTML entities that survive tag-stripping. */
37
+ export function decodeHtmlEntities(text: string): string {
38
+ return text
39
+ .replace(NAMED_ENTITY_REGEXP, (entity) => NAMED_ENTITIES[entity] ?? entity)
40
+ .replace(NUMERIC_ENTITY_REGEXP, (_, code) => String.fromCodePoint(Number(code)));
41
+ }
42
+
43
+ /**
44
+ * Strip tags and decode entities from a short HTML fragment, collapsing all
45
+ * whitespace to single spaces. For inline snippets (a search result title or
46
+ * summary), where layout carries no meaning.
47
+ */
48
+ export function htmlFragmentToText(html: string): string {
49
+ return decodeHtmlEntities(html.replace(TAG_REGEXP, "")).replace(/\s+/g, " ").trim();
50
+ }
51
+
52
+ /**
53
+ * Reduce a full HTML document to readable plain text: drop `<script>` /
54
+ * `<style>` / `<noscript>` blocks and comments, turn block-level tags into
55
+ * newlines, strip the remaining tags, decode entities, and collapse runs of
56
+ * blank lines / trailing spaces. Unlike {@link htmlFragmentToText}, this
57
+ * preserves line structure because paragraph breaks carry meaning in a page.
58
+ */
59
+ export function htmlToText(html: string): string {
60
+ return decodeHtmlEntities(
61
+ html
62
+ .replace(/<!--[\s\S]*?-->/g, "")
63
+ .replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
64
+ .replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
65
+ .replace(/<br\s*\/?>/gi, "\n")
66
+ .replace(TAG_REGEXP, ""),
67
+ )
68
+ .replace(/[ \t]+\n/g, "\n")
69
+ .replace(/\n{3,}/g, "\n\n")
70
+ .replace(/[ \t]{2,}/g, " ")
71
+ .trim();
72
+ }
package/src/plugin.ts CHANGED
@@ -13,13 +13,13 @@
13
13
  * @module
14
14
  */
15
15
 
16
- import { getExecutionContext, Plugin, toPlugin, type PluginManifest } from "@databricks/appkit";
16
+ import { Plugin, toPlugin, type PluginManifest } from "@databricks/appkit";
17
17
  import { log } from "@dbx-tools/shared-core";
18
18
  import { WEB_SEARCH_CONFIG_SCHEMA, type WebSearchPluginConfig } from "./config";
19
19
  import { runWebFetch } from "./fetch";
20
20
  import { getWebSearchRuntime } from "./runtime";
21
21
  import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from "./schema";
22
- import { runWebSearch, type WebSearchContext } from "./search";
22
+ import { resolveWebSearchContext, runWebSearch } from "./search";
23
23
 
24
24
  /**
25
25
  * AppKit plugin that resolves and holds the web-search runtime config used
@@ -30,8 +30,9 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
30
30
  name: "web-search",
31
31
  displayName: "Web Search",
32
32
  description:
33
- "Searches the web (via duck-duck-scrape) and fetches pages (via got-scraping), " +
34
- "with an optional URL allow-list and per-tool approval gating.",
33
+ "Searches the web through the Databricks native web-search tool on its own " +
34
+ "web-capable serving endpoint, and fetches pages via got-scraping, behind an " +
35
+ "optional URL allow-list and per-tool approval gating.",
35
36
  stability: "beta",
36
37
  resources: {
37
38
  required: [],
@@ -58,13 +59,6 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
58
59
  });
59
60
  }
60
61
 
61
- /** Resolve the OBO client + host from the active execution context. */
62
- private async searchContext(): Promise<WebSearchContext> {
63
- const ctx = getExecutionContext();
64
- const host = (await ctx.client.config.getHost()).toString();
65
- return { client: ctx.client, host };
66
- }
67
-
68
62
  override exports() {
69
63
  return {
70
64
  /**
@@ -73,7 +67,7 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
73
67
  * runtime config primed at setup.
74
68
  */
75
69
  search: async (request: WebSearchRequest): Promise<WebSearchResult> =>
76
- runWebSearch(request, getWebSearchRuntime().config, await this.searchContext()),
70
+ runWebSearch(request, getWebSearchRuntime().config, await resolveWebSearchContext()),
77
71
  /**
78
72
  * Fetch one URL directly (bypassing the agent tool). Enforces the
79
73
  * configured allow-list. Reads the shared runtime config.
package/src/scrape.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import { log } from "@dbx-tools/shared-core";
22
22
  import { gotScraping } from "got-scraping";
23
23
  import type { ResolvedWebSearchConfig } from "./config";
24
+ import { htmlFragmentToText } from "./html-text";
24
25
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
25
26
 
26
27
  const logger = log.logger("web-search/scrape");
@@ -28,20 +29,6 @@ const logger = log.logger("web-search/scrape");
28
29
  /** DuckDuckGo's no-JS HTML results endpoint (queried via GET). */
29
30
  const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
30
31
 
31
- /** Strip HTML tags and decode the few entities DDG emits in titles/snippets. */
32
- function stripTags(html: string): string {
33
- return html
34
- .replace(/<[^>]+>/g, "")
35
- .replace(/&amp;/g, "&")
36
- .replace(/&lt;/g, "<")
37
- .replace(/&gt;/g, ">")
38
- .replace(/&quot;/g, '"')
39
- .replace(/&#x27;|&#39;/g, "'")
40
- .replace(/&nbsp;/g, " ")
41
- .replace(/\s+/g, " ")
42
- .trim();
43
- }
44
-
45
32
  /**
46
33
  * DDG wraps result URLs in a redirect (`//duckduckgo.com/l/?uddg=<encoded>`).
47
34
  * Unwrap to the real destination; leave already-absolute URLs untouched.
@@ -66,12 +53,12 @@ function parseDdgHtml(html: string): WebSearchCitation[] {
66
53
  const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
67
54
  const snippets: string[] = [];
68
55
  let sm: RegExpExecArray | null;
69
- while ((sm = snippetRe.exec(html)) !== null) snippets.push(stripTags(sm[1] ?? ""));
56
+ while ((sm = snippetRe.exec(html)) !== null) snippets.push(htmlFragmentToText(sm[1] ?? ""));
70
57
  let am: RegExpExecArray | null;
71
58
  let i = 0;
72
59
  while ((am = anchorRe.exec(html)) !== null) {
73
60
  const url = unwrapDdgUrl(am[1] ?? "");
74
- const title = stripTags(am[2] ?? "");
61
+ const title = htmlFragmentToText(am[2] ?? "");
75
62
  if (!url || !title) continue;
76
63
  const snippet = snippets[i] ?? "";
77
64
  citations.push({ url, title, ...(snippet ? { snippet } : {}) });
package/src/search.ts CHANGED
@@ -21,9 +21,10 @@
21
21
  * @module
22
22
  */
23
23
 
24
- import { resolve, serving } from "@dbx-tools/model";
25
- import { error, log } from "@dbx-tools/shared-core";
26
- import { openaiResponses } from "@dbx-tools/shared-model";
24
+ import { getExecutionContext } from "@databricks/appkit";
25
+ import { invoke, resolve, serving } from "@dbx-tools/model";
26
+ import { error, log, string } from "@dbx-tools/shared-core";
27
+ import { openaiChat, openaiResponses } from "@dbx-tools/shared-model";
27
28
  import type { ResolvedWebSearchConfig } from "./config";
28
29
  import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
29
30
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
@@ -40,6 +41,18 @@ export interface WebSearchContext {
40
41
  host: string;
41
42
  }
42
43
 
44
+ /**
45
+ * Resolve the OBO workspace client + host from the active AppKit execution
46
+ * context. Inside `agent.stream`'s `asUser(req)` scope this hits the serving
47
+ * endpoint as the requesting user; outside a user context AppKit falls back to
48
+ * the service principal.
49
+ */
50
+ export async function resolveWebSearchContext(): Promise<WebSearchContext> {
51
+ const ctx = getExecutionContext();
52
+ const host = (await ctx.client.config.getHost()).toString();
53
+ return { client: ctx.client, host };
54
+ }
55
+
43
56
  /**
44
57
  * Resolve a web-search-capable model against the LIVE workspace catalogue - so
45
58
  * we never return an endpoint id that isn't actually deployed (the "endpoint
@@ -113,11 +126,6 @@ async function postServing(
113
126
 
114
127
  /* --------------------------- response extraction --------------------------- */
115
128
 
116
- /** Coerce an unknown to a trimmed string, or "" . */
117
- function str(v: unknown): string {
118
- return typeof v === "string" ? v.trim() : "";
119
- }
120
-
121
129
  /**
122
130
  * Extract answer text + citations from an OpenAI Responses API payload, via the
123
131
  * shared reader in `@dbx-tools/shared-model` (the same module the model-proxy
@@ -143,17 +151,18 @@ function fromChatPayload(payload: Record<string, unknown>): {
143
151
  } {
144
152
  const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : [];
145
153
  const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
146
- const answer = str(message.content);
154
+ const answer = openaiChat.chatContentToText(message.content);
147
155
  const citations: WebSearchCitation[] = [];
148
156
  // Best-effort grounding extraction: walk any nested object for {uri|url,title}.
149
157
  const seen = new Set<string>();
150
158
  const visit = (v: unknown, depth: number): void => {
151
159
  if (depth > 6 || v === null || typeof v !== "object") return;
152
160
  const o = v as Record<string, unknown>;
153
- const url = str(o.url) || str(o.uri);
161
+ const url = string.trimToEmpty(o.url) || string.trimToEmpty(o.uri);
154
162
  if (url && !seen.has(url)) {
155
163
  seen.add(url);
156
- citations.push({ url, ...(str(o.title) ? { title: str(o.title) } : {}) });
164
+ const title = string.trimToEmpty(o.title);
165
+ citations.push({ url, ...(title ? { title } : {}) });
157
166
  }
158
167
  for (const val of Object.values(o)) {
159
168
  if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
@@ -195,9 +204,7 @@ export async function runWebSearch(
195
204
  const spec = webSearchToolSpec(provider, config.webSearchTools);
196
205
 
197
206
  const path =
198
- spec.api === "responses"
199
- ? "/serving-endpoints/responses"
200
- : "/serving-endpoints/chat/completions";
207
+ spec.api === "responses" ? `/${invoke.RESPONSES_PATH}` : `/${invoke.CHAT_COMPLETIONS_PATH}`;
201
208
  const body =
202
209
  spec.api === "responses"
203
210
  ? {
package/src/tool.ts CHANGED
@@ -18,7 +18,6 @@
18
18
  * @module
19
19
  */
20
20
 
21
- import { getExecutionContext } from "@databricks/appkit";
22
21
  import { string } from "@dbx-tools/shared-core";
23
22
  import { createTool } from "@mastra/core/tools";
24
23
  import { approvalMatches, type ApprovalGate } from "./config";
@@ -32,7 +31,7 @@ import {
32
31
  type WebFetchRequest,
33
32
  type WebSearchRequest,
34
33
  } from "./schema";
35
- import { runWebSearch, type WebSearchContext } from "./search";
34
+ import { resolveWebSearchContext, runWebSearch } from "./search";
36
35
 
37
36
  /** Options shared by both web tools. */
38
37
  export interface WebSearchToolOptions {
@@ -51,18 +50,6 @@ function effectiveGate(opts: WebSearchToolOptions): ApprovalGate {
51
50
  return opts.approval ?? getWebSearchRuntime().config.approval;
52
51
  }
53
52
 
54
- /**
55
- * Resolve the OBO workspace client + host from the active AppKit execution
56
- * context. Runs inside `agent.stream`'s `asUser(req)` scope, so the search
57
- * hits the serving endpoint as the requesting user; outside a user context it
58
- * falls back to the service principal.
59
- */
60
- async function webSearchContext(): Promise<WebSearchContext> {
61
- const ctx = getExecutionContext();
62
- const host = (await ctx.client.config.getHost()).toString();
63
- return { client: ctx.client, host };
64
- }
65
-
66
53
  /**
67
54
  * Build the `web_search` tool. Spread it into the agents that should be able
68
55
  * to search the web.
@@ -99,7 +86,7 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
99
86
  : { requireApproval: () => (typeof gate === "boolean" ? gate : true) }),
100
87
  execute: async (input) => {
101
88
  const { config } = getWebSearchRuntime();
102
- return runWebSearch(input as WebSearchRequest, config, await webSearchContext());
89
+ return runWebSearch(input as WebSearchRequest, config, await resolveWebSearchContext());
103
90
  },
104
91
  });
105
92
  }
@@ -7,7 +7,7 @@ import {
7
7
  toUrlAllowList,
8
8
  } from "../src/allowlist";
9
9
  import { approvalMatches, resolveWebSearchConfig } from "../src/config";
10
- import { htmlToText } from "../src/fetch";
10
+ import { htmlToText } from "../src/html-text";
11
11
  import {
12
12
  detectWebSearchProvider,
13
13
  supportsWebSearch,