hoomanjs 1.16.0 → 1.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoomanjs",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Hackable Bun-powered AI agent toolkit for building local CLI, ACP, MCP, and channel-driven workflows.",
5
5
  "author": {
6
6
  "name": "Vaibhav Pandey",
@@ -60,6 +60,7 @@
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
61
  "@mozilla/readability": "^0.6.0",
62
62
  "@strands-agents/sdk": "^1.0.0-rc.3",
63
+ "@tavily/core": "^0.7.2",
63
64
  "chromadb": "^3.4.3",
64
65
  "cli-highlight": "^2.1.11",
65
66
  "cli-spinners": "^3.4.0",
@@ -334,6 +334,7 @@ export class AcpAgent implements AgentContract {
334
334
  agent,
335
335
  mcp: { manager },
336
336
  } = await bootstrap(
337
+ "acp",
337
338
  {
338
339
  userId: bootstrapUserId,
339
340
  sessionId,
@@ -432,6 +433,7 @@ export class AcpAgent implements AgentContract {
432
433
  agent,
433
434
  mcp: { manager },
434
435
  } = await bootstrap(
436
+ "acp",
435
437
  {
436
438
  userId: bootstrapUserId,
437
439
  sessionId: params.sessionId,
@@ -22,6 +22,7 @@ const KNOWN_TOOL_KINDS = new Map<string, ToolKind>([
22
22
  ["wiki_knowledge_graph", "read"],
23
23
  ["wiki_stats", "read"],
24
24
  ["wiki_search", "search"],
25
+ ["web_search", "search"],
25
26
  ["think", "think"],
26
27
  ["run_agents", "other"],
27
28
  ["update_todos", "other"],
package/src/cli.ts CHANGED
@@ -55,7 +55,7 @@ program
55
55
  const {
56
56
  agent,
57
57
  mcp: { manager },
58
- } = await bootstrap({ sessionId }, true);
58
+ } = await bootstrap("default", { sessionId }, true);
59
59
  agent.addHook(
60
60
  BeforeToolCallEvent,
61
61
  createToolApprovalHandler({ yolo: Boolean(options.yolo) }),
@@ -86,7 +86,7 @@ program
86
86
  agent,
87
87
  mcp: { manager },
88
88
  registry,
89
- } = await bootstrap({ sessionId }, false);
89
+ } = await bootstrap("default", { sessionId }, false);
90
90
 
91
91
  try {
92
92
  await chat({
@@ -129,10 +129,10 @@ program
129
129
  agent,
130
130
  mcp: { manager },
131
131
  } = await bootstrap(
132
+ "daemon",
132
133
  {
133
134
  sessionId: session,
134
135
  userId: session,
135
- mode: "daemon",
136
136
  },
137
137
  true,
138
138
  );
@@ -139,6 +139,7 @@ export function ConfigureApp({
139
139
  ({
140
140
  name: config.name,
141
141
  llm: config.llm,
142
+ search: config.search,
142
143
  prompts: config.prompts,
143
144
  tools: config.tools,
144
145
  compaction: config.compaction,
@@ -448,6 +449,81 @@ export function ConfigureApp({
448
449
  label: `Prompts • ${enabledPrompts}/${totalPrompts} enabled`,
449
450
  value: () => setScreen({ kind: "config-prompts" }),
450
451
  },
452
+ {
453
+ label: "Tools • configure enabled tools",
454
+ value: () => setScreen({ kind: "config-tools" }),
455
+ },
456
+ {
457
+ label: `Compaction ratio • ${configData.compaction.ratio}`,
458
+ value: () =>
459
+ promptValue({
460
+ title: "Update compaction ratio",
461
+ label: "Ratio",
462
+ initialValue: String(configData.compaction.ratio),
463
+ onSubmit: async (value) => {
464
+ const ratio = parseNumber(value, "Compaction ratio", {
465
+ min: 0,
466
+ max: 1,
467
+ });
468
+ updateConfig(
469
+ {
470
+ compaction: {
471
+ ...config.compaction,
472
+ ratio,
473
+ },
474
+ },
475
+ "Updated compaction ratio.",
476
+ );
477
+ setPrompt(null);
478
+ },
479
+ }),
480
+ },
481
+ {
482
+ label: `Compaction keep • ${configData.compaction.keep}`,
483
+ value: () =>
484
+ promptValue({
485
+ title: "Update compaction keep",
486
+ label: "Keep",
487
+ initialValue: String(configData.compaction.keep),
488
+ onSubmit: async (value) => {
489
+ const keep = parseNumber(value, "Compaction keep", {
490
+ min: 0,
491
+ integer: true,
492
+ });
493
+ updateConfig(
494
+ {
495
+ compaction: {
496
+ ...config.compaction,
497
+ keep,
498
+ },
499
+ },
500
+ "Updated compaction keep.",
501
+ );
502
+ setPrompt(null);
503
+ },
504
+ }),
505
+ },
506
+ {
507
+ label: "Back",
508
+ value: () => setScreen({ kind: "home" }),
509
+ },
510
+ ];
511
+
512
+ return (
513
+ <MenuScreen
514
+ title="Configuration"
515
+ description="Edit the same values loaded from ~/.hooman/config.json."
516
+ items={items}
517
+ />
518
+ );
519
+ };
520
+
521
+ const renderToolsConfigMenu = () => {
522
+ const items: MenuItem[] = [
523
+ {
524
+ label: `Search tool • ${configData.search.enabled ? "Enabled" : "Disabled"} • ${configData.search.provider}`,
525
+ value: () => setScreen({ kind: "config-search" }),
526
+ },
451
527
  {
452
528
  label: `Todo tool • ${configData.tools.todo.enabled ? "Enabled" : "Disabled"}`,
453
529
  value: () => {
@@ -462,7 +538,7 @@ export function ConfigureApp({
462
538
  },
463
539
  `Todo tool ${configData.tools.todo.enabled ? "disabled" : "enabled"}.`,
464
540
  );
465
- setScreen({ kind: "config" });
541
+ setScreen({ kind: "config-tools" });
466
542
  },
467
543
  },
468
544
  {
@@ -479,7 +555,7 @@ export function ConfigureApp({
479
555
  },
480
556
  `Fetch tool ${configData.tools.fetch.enabled ? "disabled" : "enabled"}.`,
481
557
  );
482
- setScreen({ kind: "config" });
558
+ setScreen({ kind: "config-tools" });
483
559
  },
484
560
  },
485
561
  {
@@ -496,7 +572,7 @@ export function ConfigureApp({
496
572
  },
497
573
  `Filesystem tool ${configData.tools.filesystem.enabled ? "disabled" : "enabled"}.`,
498
574
  );
499
- setScreen({ kind: "config" });
575
+ setScreen({ kind: "config-tools" });
500
576
  },
501
577
  },
502
578
  {
@@ -513,7 +589,7 @@ export function ConfigureApp({
513
589
  },
514
590
  `Shell tool ${configData.tools.shell.enabled ? "disabled" : "enabled"}.`,
515
591
  );
516
- setScreen({ kind: "config" });
592
+ setScreen({ kind: "config-tools" });
517
593
  },
518
594
  },
519
595
  {
@@ -530,7 +606,7 @@ export function ConfigureApp({
530
606
  },
531
607
  `Sleep tool ${configData.tools.sleep.enabled ? "disabled" : "enabled"}.`,
532
608
  );
533
- setScreen({ kind: "config" });
609
+ setScreen({ kind: "config-tools" });
534
610
  },
535
611
  },
536
612
  {
@@ -555,7 +631,7 @@ export function ConfigureApp({
555
631
  },
556
632
  `MCP tools ${configData.tools.mcp.enabled ? "disabled" : "enabled"}.`,
557
633
  );
558
- setScreen({ kind: "config" });
634
+ setScreen({ kind: "config-tools" });
559
635
  },
560
636
  },
561
637
  {
@@ -572,69 +648,19 @@ export function ConfigureApp({
572
648
  },
573
649
  `Skills tools ${configData.tools.skills.enabled ? "disabled" : "enabled"}.`,
574
650
  );
575
- setScreen({ kind: "config" });
651
+ setScreen({ kind: "config-tools" });
576
652
  },
577
653
  },
578
- {
579
- label: `Compaction ratio • ${configData.compaction.ratio}`,
580
- value: () =>
581
- promptValue({
582
- title: "Update compaction ratio",
583
- label: "Ratio",
584
- initialValue: String(configData.compaction.ratio),
585
- onSubmit: async (value) => {
586
- const ratio = parseNumber(value, "Compaction ratio", {
587
- min: 0,
588
- max: 1,
589
- });
590
- updateConfig(
591
- {
592
- compaction: {
593
- ...config.compaction,
594
- ratio,
595
- },
596
- },
597
- "Updated compaction ratio.",
598
- );
599
- setPrompt(null);
600
- },
601
- }),
602
- },
603
- {
604
- label: `Compaction keep • ${configData.compaction.keep}`,
605
- value: () =>
606
- promptValue({
607
- title: "Update compaction keep",
608
- label: "Keep",
609
- initialValue: String(configData.compaction.keep),
610
- onSubmit: async (value) => {
611
- const keep = parseNumber(value, "Compaction keep", {
612
- min: 0,
613
- integer: true,
614
- });
615
- updateConfig(
616
- {
617
- compaction: {
618
- ...config.compaction,
619
- keep,
620
- },
621
- },
622
- "Updated compaction keep.",
623
- );
624
- setPrompt(null);
625
- },
626
- }),
627
- },
628
654
  {
629
655
  label: "Back",
630
- value: () => setScreen({ kind: "home" }),
656
+ value: () => setScreen({ kind: "config" }),
631
657
  },
632
658
  ];
633
659
 
634
660
  return (
635
661
  <MenuScreen
636
- title="Configuration"
637
- description="Edit the same values loaded from ~/.hooman/config.json."
662
+ title="Tools"
663
+ description="Enable, disable, and configure built-in tools."
638
664
  items={items}
639
665
  />
640
666
  );
@@ -696,7 +722,7 @@ export function ConfigureApp({
696
722
  }),
697
723
  {
698
724
  label: "Back",
699
- value: () => setScreen({ kind: "config" }),
725
+ value: () => setScreen({ kind: "config-tools" }),
700
726
  },
701
727
  ];
702
728
 
@@ -709,6 +735,113 @@ export function ConfigureApp({
709
735
  );
710
736
  };
711
737
 
738
+ const renderSearchProviderMenu = () => {
739
+ const items: MenuItem[] = [
740
+ ...(["brave", "tavily"] as const).map((provider) => ({
741
+ label:
742
+ provider === configData.search.provider
743
+ ? `${provider} • current`
744
+ : provider,
745
+ value: () => {
746
+ updateConfig(
747
+ {
748
+ search: {
749
+ ...config.search,
750
+ provider,
751
+ },
752
+ },
753
+ `Updated search provider to "${provider}".`,
754
+ );
755
+ setScreen({ kind: "config-search" });
756
+ },
757
+ })),
758
+ {
759
+ label: "Back",
760
+ value: () => setScreen({ kind: "config-search" }),
761
+ },
762
+ ];
763
+
764
+ return (
765
+ <MenuScreen
766
+ title="Search Provider"
767
+ description="Pick which web search provider to use."
768
+ items={items}
769
+ />
770
+ );
771
+ };
772
+
773
+ const renderSearchConfigMenu = () => {
774
+ const activeProvider = configData.search.provider;
775
+ const apiKey =
776
+ activeProvider === "brave"
777
+ ? configData.search.brave.apiKey
778
+ : configData.search.tavily.apiKey;
779
+ const redacted = compactJson(
780
+ maskSensitiveParamsForDisplay({ apiKey: apiKey ?? "" }),
781
+ );
782
+ const items: MenuItem[] = [
783
+ {
784
+ label: `Enabled • ${configData.search.enabled ? "On" : "Off"}`,
785
+ value: () => {
786
+ updateConfig(
787
+ {
788
+ search: {
789
+ ...config.search,
790
+ enabled: !configData.search.enabled,
791
+ },
792
+ },
793
+ `Search tool ${configData.search.enabled ? "disabled" : "enabled"}.`,
794
+ );
795
+ setScreen({ kind: "config-search" });
796
+ },
797
+ },
798
+ {
799
+ label: `Provider • ${configData.search.provider}`,
800
+ value: () => setScreen({ kind: "config-search-provider" }),
801
+ },
802
+ {
803
+ label: `${activeProvider} API key • ${truncate(redacted, 44)}`,
804
+ value: () =>
805
+ promptValue({
806
+ title: `Update ${activeProvider} API key`,
807
+ label: "API key",
808
+ initialValue: apiKey ?? "",
809
+ onSubmit: async (value) => {
810
+ const nextApiKey = value.trim();
811
+ if (!nextApiKey) {
812
+ throw new Error("API key is required.");
813
+ }
814
+ updateConfig(
815
+ {
816
+ search: {
817
+ ...config.search,
818
+ [activeProvider]: {
819
+ ...config.search[activeProvider],
820
+ apiKey: nextApiKey,
821
+ },
822
+ },
823
+ },
824
+ `Updated ${activeProvider} API key.`,
825
+ );
826
+ setPrompt(null);
827
+ },
828
+ }),
829
+ },
830
+ {
831
+ label: "Back",
832
+ value: () => setScreen({ kind: "config-tools" }),
833
+ },
834
+ ];
835
+
836
+ return (
837
+ <MenuScreen
838
+ title="Search"
839
+ description="Configure web search provider and credentials."
840
+ items={items}
841
+ />
842
+ );
843
+ };
844
+
712
845
  const renderLtmConfigMenu = () => {
713
846
  const items: MenuItem[] = [
714
847
  {
@@ -793,7 +926,7 @@ export function ConfigureApp({
793
926
  },
794
927
  {
795
928
  label: "Back",
796
- value: () => setScreen({ kind: "config" }),
929
+ value: () => setScreen({ kind: "config-tools" }),
797
930
  },
798
931
  ];
799
932
 
@@ -1171,6 +1304,12 @@ export function ConfigureApp({
1171
1304
  return renderProviderMenu();
1172
1305
  case "config-prompts":
1173
1306
  return renderPromptsConfigMenu();
1307
+ case "config-tools":
1308
+ return renderToolsConfigMenu();
1309
+ case "config-search":
1310
+ return renderSearchConfigMenu();
1311
+ case "config-search-provider":
1312
+ return renderSearchProviderMenu();
1174
1313
  case "config-ltm":
1175
1314
  return renderLtmConfigMenu();
1176
1315
  case "config-wiki":
@@ -12,8 +12,11 @@ export type ConfigureAppProps = {
12
12
  export type Screen =
13
13
  | { kind: "home" }
14
14
  | { kind: "config" }
15
+ | { kind: "config-tools" }
15
16
  | { kind: "config-provider" }
16
17
  | { kind: "config-prompts" }
18
+ | { kind: "config-search" }
19
+ | { kind: "config-search-provider" }
17
20
  | { kind: "config-ltm" }
18
21
  | { kind: "config-wiki" }
19
22
  | { kind: "mcp" }
@@ -28,6 +28,7 @@ import {
28
28
  createThinkingTools,
29
29
  createTimeTools,
30
30
  createWikiTools,
31
+ createWebSearchTools,
31
32
  } from "../tools";
32
33
  import { clearTodoState } from "../tools/todo.ts";
33
34
 
@@ -73,6 +74,7 @@ export async function create(
73
74
  ...(ltm ? createLongTermMemoryTools(ltm) : []),
74
75
  ...(config.tools.filesystem.enabled ? createFilesystemTools() : []),
75
76
  ...(config.tools.shell.enabled ? createShellTools() : []),
77
+ ...(config.search.enabled ? createWebSearchTools(config) : []),
76
78
  ...(config.tools.wiki.enabled ? createWikiTools(config) : []),
77
79
  ...(config.tools.mcp.enabled ? createMcpTools(mcp.config) : []),
78
80
  ...(config.tools.skills.enabled ? createSkillsTools(registry) : []),
@@ -26,6 +26,7 @@ export const BUILTIN_AGENT_CONFIGS: readonly AgentConfig[] = [
26
26
  "search_files",
27
27
  "get_file_info",
28
28
  "fetch",
29
+ "web_search",
29
30
  "think",
30
31
  ],
31
32
  },
@@ -40,6 +41,7 @@ export const BUILTIN_AGENT_CONFIGS: readonly AgentConfig[] = [
40
41
  "directory_tree",
41
42
  "search_files",
42
43
  "get_file_info",
44
+ "web_search",
43
45
  "think",
44
46
  ],
45
47
  },
@@ -30,6 +30,8 @@ export const INTERNAL_ALWAYS_ALLOWED = new Set([
30
30
  "wiki_search",
31
31
  "wiki_stats",
32
32
  "wiki_write_file",
33
+ // Web search
34
+ "web_search",
33
35
  // Long-term memory
34
36
  "archive_memory",
35
37
  "search_memory",
@@ -92,6 +92,23 @@ const AgentsPartialSchema = z.object({
92
92
  concurrency: z.number().int().min(1).optional(),
93
93
  });
94
94
 
95
+ const SearchProviderSchema = z.enum(["brave", "tavily"]);
96
+
97
+ const SearchPartialSchema = z.object({
98
+ enabled: z.boolean().optional(),
99
+ provider: SearchProviderSchema.optional(),
100
+ brave: z
101
+ .object({
102
+ apiKey: z.string().min(1).optional(),
103
+ })
104
+ .optional(),
105
+ tavily: z
106
+ .object({
107
+ apiKey: z.string().min(1).optional(),
108
+ })
109
+ .optional(),
110
+ });
111
+
95
112
  const ToolsPartialSchema = z.object({
96
113
  todo: ToolTogglePartialSchema.optional(),
97
114
  fetch: ToolTogglePartialSchema.optional(),
@@ -109,6 +126,7 @@ const ConfigSchema = z
109
126
  .object({
110
127
  name: z.string().min(1),
111
128
  llm: LlmSchema,
129
+ search: SearchPartialSchema.nullish(),
112
130
  prompts: PromptsPartialSchema.nullish(),
113
131
  tools: ToolsPartialSchema.nullish(),
114
132
  compaction: CompactionPartialSchema.nullish().transform((c) => ({
@@ -122,6 +140,16 @@ const ConfigSchema = z
122
140
  return {
123
141
  name: input.name,
124
142
  llm: input.llm,
143
+ search: {
144
+ enabled: input.search?.enabled ?? false,
145
+ provider: input.search?.provider ?? "brave",
146
+ brave: {
147
+ apiKey: input.search?.brave?.apiKey,
148
+ },
149
+ tavily: {
150
+ apiKey: input.search?.tavily?.apiKey,
151
+ },
152
+ },
125
153
  prompts: {
126
154
  behaviour: input.prompts?.behaviour ?? DEFAULT_PROMPTS.behaviour,
127
155
  communication:
@@ -189,6 +217,7 @@ export type CompactionConfig = ConfigData["compaction"];
189
217
  export type PromptsConfig = ConfigData["prompts"];
190
218
  export type LtmConfig = ConfigData["tools"]["ltm"];
191
219
  export type WikiConfig = ConfigData["tools"]["wiki"];
220
+ export type SearchConfig = ConfigData["search"];
192
221
  export type ToolsConfig = ConfigData["tools"];
193
222
 
194
223
  const defaultConfigData = (): ConfigData => ({
@@ -198,6 +227,12 @@ const defaultConfigData = (): ConfigData => ({
198
227
  model: "gemma4:e4b",
199
228
  params: {},
200
229
  },
230
+ search: {
231
+ enabled: false,
232
+ provider: "brave",
233
+ brave: { apiKey: undefined },
234
+ tavily: { apiKey: undefined },
235
+ },
201
236
  prompts: { ...DEFAULT_PROMPTS },
202
237
  tools: {
203
238
  todo: {
@@ -263,6 +298,14 @@ export class Config {
263
298
  return this.data.llm;
264
299
  }
265
300
 
301
+ get search(): SearchConfig {
302
+ return {
303
+ ...this.data.search,
304
+ brave: { ...this.data.search.brave },
305
+ tavily: { ...this.data.search.tavily },
306
+ };
307
+ }
308
+
266
309
  get prompts(): PromptsConfig {
267
310
  return { ...this.data.prompts };
268
311
  }
package/src/core/index.ts CHANGED
@@ -21,16 +21,18 @@ import {
21
21
  export type BootstrapMeta = {
22
22
  userId?: string;
23
23
  sessionId?: string;
24
- mode?: "default" | "daemon";
25
24
  acp?: AcpMeta;
26
25
  };
27
26
 
27
+ export type BootstrapMode = "default" | "daemon" | "acp";
28
+
28
29
  export type AcpMeta = {
29
30
  systemPrompt?: string;
30
31
  mcpServers?: NamedMcpTransport[];
31
32
  };
32
33
 
33
34
  export async function bootstrap(
35
+ mode: BootstrapMode,
34
36
  meta: BootstrapMeta,
35
37
  print: boolean = false,
36
38
  ): Promise<{
@@ -43,16 +45,12 @@ export async function bootstrap(
43
45
  const mcpConfig = createMcpConfig(mcpJsonPath());
44
46
  const mcpManager = createMcpManager(
45
47
  mcpConfig,
46
- meta.acp !== undefined,
48
+ mode === "acp",
47
49
  meta.acp?.mcpServers ?? [],
48
50
  );
49
51
  const mcp = { config: mcpConfig, manager: mcpManager };
50
52
  const registry = createSkillsRegistry(basePath());
51
- const system = await createSystemPrompt(
52
- instructionsMdPath(),
53
- config,
54
- meta.mode ?? "default",
55
- );
53
+ const system = await createSystemPrompt(instructionsMdPath(), config, mode);
56
54
  const agent = await createAgent(config, system, registry, mcp, print, {
57
55
  userId: meta?.userId ?? meta?.sessionId,
58
56
  sessionId: meta?.sessionId,
@@ -0,0 +1,38 @@
1
+ ## Web Search
2
+
3
+ You have access to a `web_search` tool for finding relevant webpages and snippets.
4
+
5
+ ### When To Use It
6
+
7
+ - Use `web_search` when you need current or external information not available in local context.
8
+ - Prefer it for discovering candidate sources before reading full page content.
9
+ - After identifying promising URLs, use `fetch` to read those pages in detail.
10
+
11
+ ### Input Contract
12
+
13
+ Use only these inputs:
14
+
15
+ - `query` (required)
16
+ - `count` (optional)
17
+ - `freshness` (optional: `day`, `week`, `month`, `year`)
18
+ - `start_date` + `end_date` (optional date range, `YYYY-MM-DD`)
19
+ - `country` (optional country code)
20
+ - `safe_search` (optional boolean)
21
+
22
+ Do not invent provider-specific parameters.
23
+
24
+ ### Examples
25
+
26
+ - General current-information search:
27
+ - `{"query":"latest TypeScript 6 release notes","count":5}`
28
+ - Recency-filtered search:
29
+ - `{"query":"browser rendering performance updates","freshness":"week","count":5}`
30
+ - Country-targeted search:
31
+ - `{"query":"renewable energy policy updates","country":"DE","count":5}`
32
+ - Search operators inside query:
33
+ - `{"query":"\"climate change\" site:ipcc.ch filetype:pdf -draft","count":5}`
34
+
35
+ ### Notes
36
+
37
+ - `web_search` returns result pages and snippets, not full article bodies.
38
+ - For complete page content, call `fetch` on selected result URLs.
@@ -14,6 +14,7 @@ const STATIC_PROMPT_FILES = [
14
14
  "thinking.md",
15
15
  "filesystem.md",
16
16
  "fetch.md",
17
+ "web-search.md",
17
18
  "shell.md",
18
19
  "sleep.md",
19
20
  "daemon.md",
@@ -30,7 +31,7 @@ const HARNESS_PROMPT_FILES = [
30
31
  { key: "guardrails", file: "guardrails.md" },
31
32
  ] as const;
32
33
 
33
- export type SystemMode = "default" | "daemon";
34
+ export type SystemMode = "default" | "daemon" | "acp";
34
35
 
35
36
  const SECTION_BREAK = "\n\n---\n\n";
36
37
 
@@ -61,6 +62,8 @@ export class System {
61
62
  return this.config.tools.ltm.enabled;
62
63
  case "fetch.md":
63
64
  return this.config.tools.fetch.enabled;
65
+ case "web-search.md":
66
+ return this.config.search.enabled;
64
67
  case "todo.md":
65
68
  return this.config.tools.todo.enabled;
66
69
  case "filesystem.md":
@@ -5,4 +5,5 @@ export { createShellTools } from "./shell.ts";
5
5
  export { createThinkingTools } from "./thinking.ts";
6
6
  export { createTimeTools } from "./time.ts";
7
7
  export { createTodoTools } from "./todo.ts";
8
+ export { createWebSearchTools } from "./web-search.ts";
8
9
  export { createWikiTools } from "./wiki.ts";
@@ -0,0 +1,278 @@
1
+ import { tool } from "@strands-agents/sdk";
2
+ import type { JSONValue, ToolContext } from "@strands-agents/sdk";
3
+ import { tavily } from "@tavily/core";
4
+ import { z } from "zod";
5
+ import type { Config } from "../config.ts";
6
+
7
+ const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
8
+ const DEFAULT_TIMEOUT_SECONDS = 20;
9
+ const DEFAULT_RESULT_COUNT = 5;
10
+ const MAX_RESULT_COUNT = 20;
11
+
12
+ const FreshnessSchema = z.enum(["day", "week", "month", "year"]);
13
+
14
+ const InputSchema = z
15
+ .object({
16
+ query: z.string().min(1).max(400),
17
+ count: z.coerce
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .max(MAX_RESULT_COUNT)
22
+ .default(DEFAULT_RESULT_COUNT),
23
+ freshness: FreshnessSchema.optional(),
24
+ start_date: z
25
+ .string()
26
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
27
+ .optional(),
28
+ end_date: z
29
+ .string()
30
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
31
+ .optional(),
32
+ country: z
33
+ .string()
34
+ .regex(/^[a-z]{2}$/i)
35
+ .optional(),
36
+ safe_search: z.boolean().optional(),
37
+ })
38
+ .superRefine((input, context) => {
39
+ const hasStartDate = Boolean(input.start_date);
40
+ const hasEndDate = Boolean(input.end_date);
41
+ if (hasStartDate !== hasEndDate) {
42
+ context.addIssue({
43
+ code: z.ZodIssueCode.custom,
44
+ message: "start_date and end_date must be provided together.",
45
+ });
46
+ }
47
+ if (hasStartDate && input.freshness) {
48
+ context.addIssue({
49
+ code: z.ZodIssueCode.custom,
50
+ message:
51
+ "Use either freshness or start_date/end_date, not both together.",
52
+ });
53
+ }
54
+ });
55
+
56
+ type WebSearchInput = z.infer<typeof InputSchema>;
57
+
58
+ type NormalizedResult = {
59
+ title: string;
60
+ url: string;
61
+ snippet: string;
62
+ };
63
+
64
+ type NormalizedOutput = {
65
+ provider: "brave" | "tavily";
66
+ query: string;
67
+ results: NormalizedResult[];
68
+ metadata: {
69
+ count: number;
70
+ freshness: WebSearchInput["freshness"] | null;
71
+ start_date: string | null;
72
+ end_date: string | null;
73
+ country: string | null;
74
+ safe_search: boolean | null;
75
+ returned_results: number;
76
+ };
77
+ };
78
+
79
+ function toJsonValue(value: unknown): JSONValue {
80
+ return JSON.parse(JSON.stringify(value)) as JSONValue;
81
+ }
82
+
83
+ function toBraveFreshness(input: WebSearchInput): string | undefined {
84
+ if (input.start_date && input.end_date) {
85
+ return `${input.start_date}to${input.end_date}`;
86
+ }
87
+ switch (input.freshness) {
88
+ case "day":
89
+ return "pd";
90
+ case "week":
91
+ return "pw";
92
+ case "month":
93
+ return "pm";
94
+ case "year":
95
+ return "py";
96
+ default:
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ function tavilyCountryCode(code: string | undefined): string | undefined {
102
+ if (!code) {
103
+ return undefined;
104
+ }
105
+ try {
106
+ const display = new Intl.DisplayNames(["en"], { type: "region" }).of(
107
+ code.toUpperCase(),
108
+ );
109
+ if (!display) {
110
+ return undefined;
111
+ }
112
+ return display.toLowerCase();
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ function cleanString(value: unknown): string {
119
+ return typeof value === "string" ? value.trim() : "";
120
+ }
121
+
122
+ function normalizeBraveResults(payload: unknown): NormalizedResult[] {
123
+ const root = payload as {
124
+ web?: { results?: Array<Record<string, unknown>> };
125
+ };
126
+ const results = root.web?.results;
127
+ if (!Array.isArray(results)) {
128
+ return [];
129
+ }
130
+ return results
131
+ .map((item) => ({
132
+ title: cleanString(item.title),
133
+ url: cleanString(item.url),
134
+ snippet: cleanString(item.description),
135
+ }))
136
+ .filter((item) => item.url.length > 0);
137
+ }
138
+
139
+ function normalizeTavilyResults(payload: unknown): NormalizedResult[] {
140
+ const root = payload as { results?: Array<Record<string, unknown>> };
141
+ if (!Array.isArray(root.results)) {
142
+ return [];
143
+ }
144
+ return root.results
145
+ .map((item) => ({
146
+ title: cleanString(item.title),
147
+ url: cleanString(item.url),
148
+ snippet: cleanString(item.content),
149
+ }))
150
+ .filter((item) => item.url.length > 0);
151
+ }
152
+
153
+ function normalizedOutput(
154
+ provider: "brave" | "tavily",
155
+ input: WebSearchInput,
156
+ results: NormalizedResult[],
157
+ ): NormalizedOutput {
158
+ return {
159
+ provider,
160
+ query: input.query,
161
+ results,
162
+ metadata: {
163
+ count: input.count,
164
+ freshness: input.freshness ?? null,
165
+ start_date: input.start_date ?? null,
166
+ end_date: input.end_date ?? null,
167
+ country: input.country?.toUpperCase() ?? null,
168
+ safe_search: input.safe_search ?? null,
169
+ returned_results: results.length,
170
+ },
171
+ };
172
+ }
173
+
174
+ async function searchBrave(
175
+ input: WebSearchInput,
176
+ apiKey: string,
177
+ signal: AbortSignal,
178
+ ): Promise<NormalizedOutput> {
179
+ const url = new URL(BRAVE_ENDPOINT);
180
+ url.searchParams.set("q", input.query);
181
+ url.searchParams.set("count", String(input.count));
182
+ if (input.country) {
183
+ url.searchParams.set("country", input.country.toUpperCase());
184
+ }
185
+ const freshness = toBraveFreshness(input);
186
+ if (freshness) {
187
+ url.searchParams.set("freshness", freshness);
188
+ }
189
+ if (input.safe_search !== undefined) {
190
+ url.searchParams.set("safesearch", input.safe_search ? "strict" : "off");
191
+ }
192
+
193
+ const response = await fetch(url, {
194
+ method: "GET",
195
+ signal,
196
+ headers: {
197
+ accept: "application/json",
198
+ "x-subscription-token": apiKey,
199
+ },
200
+ });
201
+ const body = await response.text();
202
+ if (!response.ok) {
203
+ throw new Error(
204
+ `Brave search failed (${response.status} ${response.statusText}): ${body}`,
205
+ );
206
+ }
207
+ const parsed = JSON.parse(body) as unknown;
208
+ return normalizedOutput("brave", input, normalizeBraveResults(parsed));
209
+ }
210
+
211
+ async function searchTavily(
212
+ input: WebSearchInput,
213
+ apiKey: string,
214
+ ): Promise<NormalizedOutput> {
215
+ const client = tavily({ apiKey }) as {
216
+ search: (
217
+ query: string,
218
+ options?: Record<string, unknown>,
219
+ ) => Promise<unknown>;
220
+ };
221
+ const options: Record<string, unknown> = {
222
+ max_results: input.count,
223
+ };
224
+ if (input.country) {
225
+ const mappedCountry = tavilyCountryCode(input.country);
226
+ if (mappedCountry) {
227
+ options.country = mappedCountry;
228
+ }
229
+ }
230
+ if (input.start_date && input.end_date) {
231
+ options.start_date = input.start_date;
232
+ options.end_date = input.end_date;
233
+ } else if (input.freshness) {
234
+ options.time_range = input.freshness;
235
+ }
236
+ if (input.safe_search !== undefined) {
237
+ options.safe_search = input.safe_search;
238
+ }
239
+
240
+ const response = await client.search(input.query, options);
241
+ return normalizedOutput("tavily", input, normalizeTavilyResults(response));
242
+ }
243
+
244
+ export function createWebSearchTools(config: Config) {
245
+ return [
246
+ tool({
247
+ name: "web_search",
248
+ description:
249
+ "Search the web using configured provider and return normalized results.",
250
+ inputSchema: InputSchema,
251
+ callback: async (input, context?: ToolContext) => {
252
+ const timeoutSignal = AbortSignal.timeout(
253
+ DEFAULT_TIMEOUT_SECONDS * 1000,
254
+ );
255
+ const signal = context
256
+ ? AbortSignal.any([timeoutSignal, context.agent.cancelSignal])
257
+ : timeoutSignal;
258
+ const provider = config.search.provider;
259
+ if (provider === "brave") {
260
+ const apiKey = config.search.brave.apiKey;
261
+ if (!apiKey) {
262
+ throw new Error(
263
+ "Search provider is brave but search.brave.apiKey is missing.",
264
+ );
265
+ }
266
+ return toJsonValue(await searchBrave(input, apiKey, signal));
267
+ }
268
+ const apiKey = config.search.tavily.apiKey;
269
+ if (!apiKey) {
270
+ throw new Error(
271
+ "Search provider is tavily but search.tavily.apiKey is missing.",
272
+ );
273
+ }
274
+ return toJsonValue(await searchTavily(input, apiKey));
275
+ },
276
+ }),
277
+ ];
278
+ }