@poncho-ai/harness 0.27.0 → 0.28.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.27.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.28.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,8 +8,8 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 278.61 KB
12
- ESM ⚡️ Build success in 148ms
11
+ ESM dist/index.js 284.05 KB
12
+ ESM ⚡️ Build success in 147ms
13
13
  DTS Build start
14
- DTS ⚡️ Build success in 7054ms
15
- DTS dist/index.d.ts 29.13 KB
14
+ DTS ⚡️ Build success in 7926ms
15
+ DTS dist/index.d.ts 29.26 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`c0ca56b`](https://github.com/cesr/poncho-ai/commit/c0ca56b54bb877d96ba8088537d6f1c7461d2a55) Thanks [@cesr](https://github.com/cesr)! - Add built-in `web_search` and `web_fetch` tools so agents can search the web and fetch page content without a browser or API keys. Remove the scaffolded `fetch-page` skill (superseded by `web_fetch`). Fix `browser_open` crash when agent projects have an older `@poncho-ai/browser` installed.
8
+
3
9
  ## 0.27.0
4
10
 
5
11
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -351,6 +351,8 @@ type BuiltInToolToggles = {
351
351
  todo_add?: boolean;
352
352
  todo_update?: boolean;
353
353
  todo_remove?: boolean;
354
+ web_search?: boolean;
355
+ web_fetch?: boolean;
354
356
  };
355
357
  interface MessagingChannelConfig {
356
358
  platform: "slack" | "resend" | "telegram";
@@ -738,6 +740,8 @@ declare class LatitudeCapture {
738
740
  */
739
741
  declare function jsonSchemaToZod(schema: JsonSchema): z.ZodType;
740
742
 
743
+ declare const createSearchTools: () => ToolDefinition[];
744
+
741
745
  /**
742
746
  * Resolve the full list of skill directories to scan.
743
747
  * Merges the defaults with any extra paths provided via config.
@@ -813,4 +817,4 @@ declare class TelemetryEmitter {
813
817
 
814
818
  declare const createSubagentTools: (manager: SubagentManager) => ToolDefinition[];
815
819
 
816
- export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentResult, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryTools, createModelProvider, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
820
+ export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentResult, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryTools, createModelProvider, createSearchTools, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
package/dist/index.js CHANGED
@@ -4258,10 +4258,146 @@ var extractRunnableFunction = (value) => {
4258
4258
  return void 0;
4259
4259
  };
4260
4260
 
4261
- // src/subagent-tools.ts
4261
+ // src/search-tools.ts
4262
+ import { load as cheerioLoad } from "cheerio";
4262
4263
  import { defineTool as defineTool5 } from "@poncho-ai/sdk";
4263
- var createSubagentTools = (manager) => [
4264
+ var SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
4265
+ var FETCH_TIMEOUT_MS = 15e3;
4266
+ async function braveSearch(query, maxResults) {
4267
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
4268
+ const res = await fetch(url, {
4269
+ headers: {
4270
+ "User-Agent": SEARCH_UA,
4271
+ Accept: "text/html,application/xhtml+xml",
4272
+ "Accept-Language": "en-US,en;q=0.9"
4273
+ },
4274
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
4275
+ });
4276
+ if (!res.ok) {
4277
+ throw new Error(`Search request failed (${res.status} ${res.statusText})`);
4278
+ }
4279
+ const html = await res.text();
4280
+ return parseBraveResults(html, maxResults);
4281
+ }
4282
+ function parseBraveResults(html, max) {
4283
+ const $ = cheerioLoad(html);
4284
+ const results = [];
4285
+ $('div.snippet[data-type="web"]').each((_i, el) => {
4286
+ if (results.length >= max) return false;
4287
+ const $el = $(el);
4288
+ const anchor = $el.find(".result-content a").first();
4289
+ const href = anchor.attr("href") ?? "";
4290
+ if (!href.startsWith("http")) return;
4291
+ const title = $el.find(".title").first().text().trim();
4292
+ const snippet = $el.find(".generic-snippet .content").first().text().trim();
4293
+ if (title) {
4294
+ results.push({ title, url: href, snippet });
4295
+ }
4296
+ });
4297
+ return results;
4298
+ }
4299
+ var DEFAULT_MAX_LENGTH = 16e3;
4300
+ function extractReadableText($, maxLength) {
4301
+ const title = $("title").first().text().trim();
4302
+ $("script, style, noscript, nav, footer, header, aside, [role='navigation'], [role='banner'], [role='contentinfo']").remove();
4303
+ $("svg, iframe, form, button, input, select, textarea").remove();
4304
+ let root = $("article").first();
4305
+ if (!root.length) root = $("main").first();
4306
+ if (!root.length) root = $("[role='main']").first();
4307
+ if (!root.length) root = $("body").first();
4308
+ const text = root.text().replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
4309
+ const content = text.length > maxLength ? text.slice(0, maxLength) + "\n\u2026(truncated)" : text;
4310
+ return { title, content };
4311
+ }
4312
+ var createSearchTools = () => [
4313
+ defineTool5({
4314
+ name: "web_search",
4315
+ description: "Search the web and return a list of results (title, URL, snippet). Use this instead of opening a browser when you need to find information online.",
4316
+ inputSchema: {
4317
+ type: "object",
4318
+ properties: {
4319
+ query: {
4320
+ type: "string",
4321
+ description: "The search query"
4322
+ },
4323
+ max_results: {
4324
+ type: "number",
4325
+ description: "Maximum number of results to return (1-10, default 5)"
4326
+ }
4327
+ },
4328
+ required: ["query"],
4329
+ additionalProperties: false
4330
+ },
4331
+ handler: async (input) => {
4332
+ const query = typeof input.query === "string" ? input.query.trim() : "";
4333
+ if (!query) {
4334
+ return { error: "A non-empty query string is required." };
4335
+ }
4336
+ const max = Math.min(Math.max(Number(input.max_results) || 5, 1), 10);
4337
+ try {
4338
+ const results = await braveSearch(query, max);
4339
+ if (results.length === 0) {
4340
+ return { query, results: [], note: "No results found. Try rephrasing your query." };
4341
+ }
4342
+ return { query, results };
4343
+ } catch (err) {
4344
+ const msg = err instanceof Error ? err.message : String(err);
4345
+ return {
4346
+ error: `Search failed: ${msg}`,
4347
+ hint: "The search provider may be rate-limiting requests. Try again shortly, or use browser tools as a fallback."
4348
+ };
4349
+ }
4350
+ }
4351
+ }),
4264
4352
  defineTool5({
4353
+ name: "web_fetch",
4354
+ description: "Fetch a web page and return its text content (HTML tags stripped). Useful for reading articles, documentation, or any web page without opening a browser.",
4355
+ inputSchema: {
4356
+ type: "object",
4357
+ properties: {
4358
+ url: {
4359
+ type: "string",
4360
+ description: "The URL to fetch"
4361
+ },
4362
+ max_length: {
4363
+ type: "number",
4364
+ description: `Maximum character length of returned content (default ${DEFAULT_MAX_LENGTH})`
4365
+ }
4366
+ },
4367
+ required: ["url"],
4368
+ additionalProperties: false
4369
+ },
4370
+ handler: async (input) => {
4371
+ const url = typeof input.url === "string" ? input.url.trim() : "";
4372
+ if (!url) {
4373
+ return { error: 'A "url" string is required.' };
4374
+ }
4375
+ const maxLength = Math.max(Number(input.max_length) || DEFAULT_MAX_LENGTH, 1e3);
4376
+ try {
4377
+ const res = await fetch(url, {
4378
+ headers: { "User-Agent": SEARCH_UA, Accept: "text/html,application/xhtml+xml" },
4379
+ redirect: "follow",
4380
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
4381
+ });
4382
+ if (!res.ok) {
4383
+ return { url, status: res.status, error: res.statusText };
4384
+ }
4385
+ const html = await res.text();
4386
+ const $ = cheerioLoad(html);
4387
+ const { title, content } = extractReadableText($, maxLength);
4388
+ return { url, status: res.status, title, content };
4389
+ } catch (err) {
4390
+ const msg = err instanceof Error ? err.message : String(err);
4391
+ return { url, error: `Fetch failed: ${msg}` };
4392
+ }
4393
+ }
4394
+ })
4395
+ ];
4396
+
4397
+ // src/subagent-tools.ts
4398
+ import { defineTool as defineTool6 } from "@poncho-ai/sdk";
4399
+ var createSubagentTools = (manager) => [
4400
+ defineTool6({
4265
4401
  name: "spawn_subagent",
4266
4402
  description: "Spawn a subagent to work on a task in the background. Returns immediately with a subagent ID. The subagent runs independently and its result will be delivered to you as a message in the conversation when it completes.\n\nGuidelines:\n- Spawn all needed subagents in a SINGLE response (they run concurrently), then end your turn with a brief message to the user.\n- Do NOT spawn more subagents in follow-up steps. Wait for results to be delivered before deciding if more work is needed.\n- Prefer doing work yourself for simple or quick tasks. Spawn subagents for substantial, self-contained work.\n- The subagent has no memory of your conversation -- write thorough, self-contained instructions in the task.",
4267
4403
  inputSchema: {
@@ -4293,7 +4429,7 @@ var createSubagentTools = (manager) => [
4293
4429
  return { subagentId, status: "running" };
4294
4430
  }
4295
4431
  }),
4296
- defineTool5({
4432
+ defineTool6({
4297
4433
  name: "message_subagent",
4298
4434
  description: "Send a follow-up message to a completed or stopped subagent. The subagent restarts in the background and its result will be delivered to you as a message when it completes. Only works when the subagent is not currently running.",
4299
4435
  inputSchema: {
@@ -4321,7 +4457,7 @@ var createSubagentTools = (manager) => [
4321
4457
  return { subagentId: id, status: "running" };
4322
4458
  }
4323
4459
  }),
4324
- defineTool5({
4460
+ defineTool6({
4325
4461
  name: "stop_subagent",
4326
4462
  description: "Stop a running subagent. The subagent's conversation is preserved but it will stop processing. Use this to cancel work that is no longer needed.",
4327
4463
  inputSchema: {
@@ -4344,7 +4480,7 @@ var createSubagentTools = (manager) => [
4344
4480
  return { message: `Subagent "${subagentId}" has been stopped.` };
4345
4481
  }
4346
4482
  }),
4347
- defineTool5({
4483
+ defineTool6({
4348
4484
  name: "list_subagents",
4349
4485
  description: "List all subagents that have been spawned in this conversation. Returns each subagent's ID, original task, current status, and message count. Use this to look up subagent IDs before calling message_subagent or stop_subagent.",
4350
4486
  inputSchema: {
@@ -4970,6 +5106,11 @@ var AgentHarness = class _AgentHarness {
4970
5106
  if (this.isToolEnabled("delete_directory")) {
4971
5107
  this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
4972
5108
  }
5109
+ for (const tool of createSearchTools()) {
5110
+ if (this.isToolEnabled(tool.name)) {
5111
+ this.registerIfMissing(tool);
5112
+ }
5113
+ }
4973
5114
  if (this.environment === "development" && this.isToolEnabled("poncho_docs")) {
4974
5115
  this.registerIfMissing(ponchoDocsTool);
4975
5116
  }
@@ -5455,7 +5596,11 @@ var AgentHarness = class _AgentHarness {
5455
5596
  const session = new browserMod.BrowserSession(sessionId, browserCfg);
5456
5597
  this._browserSession = session;
5457
5598
  const tools = browserMod.createBrowserTools(
5458
- () => session
5599
+ () => session,
5600
+ // Backward compat: older @poncho-ai/browser versions expect a second
5601
+ // getConversationId callback. Current versions read from ToolContext
5602
+ // and ignore extra args.
5603
+ () => "__default__"
5459
5604
  );
5460
5605
  for (const tool of tools) {
5461
5606
  if (this.isToolEnabled(tool.name)) {
@@ -7782,7 +7927,7 @@ var TelemetryEmitter = class {
7782
7927
  };
7783
7928
 
7784
7929
  // src/index.ts
7785
- import { defineTool as defineTool6 } from "@poncho-ai/sdk";
7930
+ import { defineTool as defineTool7 } from "@poncho-ai/sdk";
7786
7931
  export {
7787
7932
  AgentHarness,
7788
7933
  InMemoryConversationStore,
@@ -7807,12 +7952,13 @@ export {
7807
7952
  createMemoryStore,
7808
7953
  createMemoryTools,
7809
7954
  createModelProvider,
7955
+ createSearchTools,
7810
7956
  createSkillTools,
7811
7957
  createStateStore,
7812
7958
  createSubagentTools,
7813
7959
  createUploadStore,
7814
7960
  createWriteTool,
7815
- defineTool6 as defineTool,
7961
+ defineTool7 as defineTool,
7816
7962
  deriveUploadKey,
7817
7963
  ensureAgentIdentity,
7818
7964
  estimateTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,7 @@
26
26
  "@latitude-data/telemetry": "^2.0.4",
27
27
  "@opentelemetry/api": "1.9.0",
28
28
  "ai": "^6.0.86",
29
+ "cheerio": "^1.2.0",
29
30
  "jiti": "^2.6.1",
30
31
  "mustache": "^4.2.0",
31
32
  "redis": "^5.10.0",
package/src/config.ts CHANGED
@@ -46,6 +46,8 @@ export type BuiltInToolToggles = {
46
46
  todo_add?: boolean;
47
47
  todo_update?: boolean;
48
48
  todo_remove?: boolean;
49
+ web_search?: boolean;
50
+ web_fetch?: boolean;
49
51
  };
50
52
 
51
53
  export interface MessagingChannelConfig {
package/src/harness.ts CHANGED
@@ -32,6 +32,7 @@ import { addPromptCacheBreakpoints } from "./prompt-cache.js";
32
32
  import { jsonSchemaToZod } from "./schema-converter.js";
33
33
  import type { SkillMetadata } from "./skill-context.js";
34
34
  import { createSkillTools, normalizeScriptPolicyPath } from "./skill-tools.js";
35
+ import { createSearchTools } from "./search-tools.js";
35
36
  import { createSubagentTools } from "./subagent-tools.js";
36
37
  import type { SubagentManager } from "./subagent-manager.js";
37
38
  import { LatitudeTelemetry } from "@latitude-data/telemetry";
@@ -562,7 +563,7 @@ export class AgentHarness {
562
563
  private insideTelemetryCapture = false;
563
564
  private _browserSession?: unknown;
564
565
  private _browserMod?: {
565
- createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
566
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
566
567
  BrowserSession: new (sessionId: string, config: Record<string, unknown>) => unknown;
567
568
  };
568
569
 
@@ -644,6 +645,11 @@ export class AgentHarness {
644
645
  if (this.isToolEnabled("delete_directory")) {
645
646
  this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
646
647
  }
648
+ for (const tool of createSearchTools()) {
649
+ if (this.isToolEnabled(tool.name)) {
650
+ this.registerIfMissing(tool);
651
+ }
652
+ }
647
653
  if (this.environment === "development" && this.isToolEnabled("poncho_docs")) {
648
654
  this.registerIfMissing(ponchoDocsTool);
649
655
  }
@@ -1161,7 +1167,7 @@ export class AgentHarness {
1161
1167
  private async initBrowserTools(config: PonchoConfig): Promise<void> {
1162
1168
  const spec = ["@poncho-ai", "browser"].join("/");
1163
1169
  let browserMod: {
1164
- createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
1170
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
1165
1171
  BrowserSession: new (sessionId: string, cfg?: Record<string, unknown>) => unknown;
1166
1172
  };
1167
1173
  try {
@@ -1205,6 +1211,10 @@ export class AgentHarness {
1205
1211
 
1206
1212
  const tools = browserMod.createBrowserTools(
1207
1213
  () => session,
1214
+ // Backward compat: older @poncho-ai/browser versions expect a second
1215
+ // getConversationId callback. Current versions read from ToolContext
1216
+ // and ignore extra args.
1217
+ () => "__default__",
1208
1218
  );
1209
1219
  for (const tool of tools) {
1210
1220
  if (this.isToolEnabled(tool.name)) {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./memory.js";
9
9
  export * from "./mcp.js";
10
10
  export * from "./model-factory.js";
11
11
  export * from "./schema-converter.js";
12
+ export * from "./search-tools.js";
12
13
  export * from "./skill-context.js";
13
14
  export * from "./skill-tools.js";
14
15
  export * from "./state.js";
@@ -0,0 +1,181 @@
1
+ import { load as cheerioLoad, type CheerioAPI } from "cheerio";
2
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
3
+
4
+ const SEARCH_UA =
5
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
6
+
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // web_search — Brave Search HTML scraping (no API key)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ interface SearchResult {
14
+ title: string;
15
+ url: string;
16
+ snippet: string;
17
+ }
18
+
19
+ async function braveSearch(query: string, maxResults: number): Promise<SearchResult[]> {
20
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
21
+ const res = await fetch(url, {
22
+ headers: {
23
+ "User-Agent": SEARCH_UA,
24
+ Accept: "text/html,application/xhtml+xml",
25
+ "Accept-Language": "en-US,en;q=0.9",
26
+ },
27
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
28
+ });
29
+ if (!res.ok) {
30
+ throw new Error(`Search request failed (${res.status} ${res.statusText})`);
31
+ }
32
+ const html = await res.text();
33
+ return parseBraveResults(html, maxResults);
34
+ }
35
+
36
+ function parseBraveResults(html: string, max: number): SearchResult[] {
37
+ const $ = cheerioLoad(html);
38
+ const results: SearchResult[] = [];
39
+
40
+ $('div.snippet[data-type="web"]').each((_i, el) => {
41
+ if (results.length >= max) return false;
42
+
43
+ const $el = $(el);
44
+ const anchor = $el.find(".result-content a").first();
45
+ const href = anchor.attr("href") ?? "";
46
+ if (!href.startsWith("http")) return;
47
+
48
+ const title = $el.find(".title").first().text().trim();
49
+ const snippet = $el.find(".generic-snippet .content").first().text().trim();
50
+
51
+ if (title) {
52
+ results.push({ title, url: href, snippet });
53
+ }
54
+ });
55
+
56
+ return results;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // web_fetch — fetch a URL and extract readable text via cheerio
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const DEFAULT_MAX_LENGTH = 16_000;
64
+
65
+ function extractReadableText($: CheerioAPI, maxLength: number): { title: string; content: string } {
66
+ const title = $("title").first().text().trim();
67
+
68
+ $("script, style, noscript, nav, footer, header, aside, [role='navigation'], [role='banner'], [role='contentinfo']").remove();
69
+ $("svg, iframe, form, button, input, select, textarea").remove();
70
+
71
+ let root = $("article").first();
72
+ if (!root.length) root = $("main").first();
73
+ if (!root.length) root = $("[role='main']").first();
74
+ if (!root.length) root = $("body").first();
75
+
76
+ const text = root
77
+ .text()
78
+ .replace(/[ \t]+/g, " ")
79
+ .replace(/\n{3,}/g, "\n\n")
80
+ .trim();
81
+
82
+ const content =
83
+ text.length > maxLength ? text.slice(0, maxLength) + "\n…(truncated)" : text;
84
+
85
+ return { title, content };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Tool definitions
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export const createSearchTools = (): ToolDefinition[] => [
93
+ defineTool({
94
+ name: "web_search",
95
+ description:
96
+ "Search the web and return a list of results (title, URL, snippet). " +
97
+ "Use this instead of opening a browser when you need to find information online.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ query: {
102
+ type: "string",
103
+ description: "The search query",
104
+ },
105
+ max_results: {
106
+ type: "number",
107
+ description: "Maximum number of results to return (1-10, default 5)",
108
+ },
109
+ },
110
+ required: ["query"],
111
+ additionalProperties: false,
112
+ },
113
+ handler: async (input) => {
114
+ const query = typeof input.query === "string" ? input.query.trim() : "";
115
+ if (!query) {
116
+ return { error: "A non-empty query string is required." };
117
+ }
118
+ const max = Math.min(Math.max(Number(input.max_results) || 5, 1), 10);
119
+ try {
120
+ const results = await braveSearch(query, max);
121
+ if (results.length === 0) {
122
+ return { query, results: [], note: "No results found. Try rephrasing your query." };
123
+ }
124
+ return { query, results };
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ return {
128
+ error: `Search failed: ${msg}`,
129
+ hint: "The search provider may be rate-limiting requests. Try again shortly, or use browser tools as a fallback.",
130
+ };
131
+ }
132
+ },
133
+ }),
134
+
135
+ defineTool({
136
+ name: "web_fetch",
137
+ description:
138
+ "Fetch a web page and return its text content (HTML tags stripped). " +
139
+ "Useful for reading articles, documentation, or any web page without opening a browser.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ url: {
144
+ type: "string",
145
+ description: "The URL to fetch",
146
+ },
147
+ max_length: {
148
+ type: "number",
149
+ description: `Maximum character length of returned content (default ${DEFAULT_MAX_LENGTH})`,
150
+ },
151
+ },
152
+ required: ["url"],
153
+ additionalProperties: false,
154
+ },
155
+ handler: async (input) => {
156
+ const url = typeof input.url === "string" ? input.url.trim() : "";
157
+ if (!url) {
158
+ return { error: 'A "url" string is required.' };
159
+ }
160
+ const maxLength = Math.max(Number(input.max_length) || DEFAULT_MAX_LENGTH, 1_000);
161
+
162
+ try {
163
+ const res = await fetch(url, {
164
+ headers: { "User-Agent": SEARCH_UA, Accept: "text/html,application/xhtml+xml" },
165
+ redirect: "follow",
166
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
167
+ });
168
+ if (!res.ok) {
169
+ return { url, status: res.status, error: res.statusText };
170
+ }
171
+ const html = await res.text();
172
+ const $ = cheerioLoad(html);
173
+ const { title, content } = extractReadableText($, maxLength);
174
+ return { url, status: res.status, title, content };
175
+ } catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ return { url, error: `Fetch failed: ${msg}` };
178
+ }
179
+ },
180
+ }),
181
+ ];