@oh-my-pi/pi-coding-agent 3.20.1 → 3.21.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +69 -9
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +155 -0
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +134 -1
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +5 -0
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/main.ts +1 -1
  72. package/src/modes/interactive/components/assistant-message.ts +1 -1
  73. package/src/modes/interactive/components/custom-message.ts +1 -1
  74. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  75. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  76. package/src/modes/interactive/components/footer.ts +1 -1
  77. package/src/modes/interactive/components/hook-message.ts +1 -1
  78. package/src/modes/interactive/components/model-selector.ts +1 -1
  79. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  80. package/src/modes/interactive/components/settings-defs.ts +49 -0
  81. package/src/modes/interactive/components/status-line.ts +1 -1
  82. package/src/modes/interactive/components/tool-execution.ts +93 -538
  83. package/src/modes/interactive/interactive-mode.ts +19 -7
  84. package/src/modes/print-mode.ts +1 -1
  85. package/src/modes/rpc/rpc-client.ts +1 -1
  86. package/src/modes/rpc/rpc-types.ts +1 -1
  87. package/src/prompts/system-prompt.md +4 -0
  88. package/src/prompts/tools/gemini-image.md +5 -1
  89. package/src/prompts/tools/output.md +4 -0
  90. package/src/prompts/tools/web-fetch.md +1 -0
  91. package/src/prompts/tools/web-search.md +2 -0
  92. package/src/utils/image-convert.ts +8 -2
  93. package/src/utils/image-magick.ts +247 -0
  94. package/src/utils/image-resize.ts +53 -13
@@ -72,7 +72,9 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
72
72
  content: [
73
73
  {
74
74
  type: "text",
75
- text: `Finding recorded: ${PRIORITY_LABELS[priority]} ${title}\nLocation: ${location}\nConfidence: ${(confidence * 100).toFixed(0)}%`,
75
+ text: `Finding recorded: ${PRIORITY_LABELS[priority]} ${title}\nLocation: ${location}\nConfidence: ${(
76
+ confidence * 100
77
+ ).toFixed(0)}%`,
76
78
  },
77
79
  ],
78
80
  details: { title, body, priority, confidence, file_path, line_start, line_end },
@@ -84,7 +86,10 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
84
86
  const color = args.priority === 0 ? "error" : args.priority === 1 ? "warning" : "muted";
85
87
  const titleText = String(args.title).replace(/^\[P\d\]\s*/, "");
86
88
  return new Text(
87
- `${theme.fg("toolTitle", theme.bold("report_finding "))}${theme.fg(color, `[${priority}]`)} ${theme.fg("dim", titleText)}`,
89
+ `${theme.fg("toolTitle", theme.bold("report_finding "))}${theme.fg(color, `[${priority}]`)} ${theme.fg(
90
+ "dim",
91
+ titleText,
92
+ )}`,
88
93
  0,
89
94
  0,
90
95
  );
@@ -99,7 +104,9 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
99
104
 
100
105
  const priority = PRIORITY_LABELS[details.priority] ?? "P?";
101
106
  const color = details.priority === 0 ? "error" : details.priority === 1 ? "warning" : "muted";
102
- const location = `${details.file_path}:${details.line_start}${details.line_end !== details.line_start ? `-${details.line_end}` : ""}`;
107
+ const location = `${details.file_path}:${details.line_start}${
108
+ details.line_end !== details.line_start ? `-${details.line_end}` : ""
109
+ }`;
103
110
 
104
111
  return new Text(
105
112
  `${theme.fg("success", theme.status.success)} ${theme.fg(color, `[${priority}]`)} ${theme.fg("dim", location)}`,
@@ -141,7 +148,11 @@ export const submitReviewTool: AgentTool<typeof SubmitReviewParams, SubmitReview
141
148
  const { overall_correctness, explanation, confidence } = params;
142
149
 
143
150
  let summary = `## Review Summary\n\n`;
144
- summary += `**Verdict:** ${overall_correctness === "correct" ? `${theme.status.success} Patch is correct` : `${theme.status.error} Patch is incorrect`}\n`;
151
+ summary += `**Verdict:** ${
152
+ overall_correctness === "correct"
153
+ ? `${theme.status.success} Patch is correct`
154
+ : `${theme.status.error} Patch is incorrect`
155
+ }\n`;
145
156
  summary += `**Confidence:** ${(confidence * 100).toFixed(0)}%\n\n`;
146
157
  summary += explanation;
147
158
 
@@ -155,7 +166,10 @@ export const submitReviewTool: AgentTool<typeof SubmitReviewParams, SubmitReview
155
166
  const verdict = args.overall_correctness === "correct" ? "correct" : "incorrect";
156
167
  const color = args.overall_correctness === "correct" ? "success" : "error";
157
168
  return new Text(
158
- `${theme.fg("toolTitle", theme.bold("submit_review "))}${theme.fg(color, verdict)} ${theme.fg("dim", `(${((args.confidence as number) * 100).toFixed(0)}%)`)}`,
169
+ `${theme.fg("toolTitle", theme.bold("submit_review "))}${theme.fg(color, verdict)} ${theme.fg(
170
+ "dim",
171
+ `(${((args.confidence as number) * 100).toFixed(0)}%)`,
172
+ )}`,
159
173
  0,
160
174
  0,
161
175
  );
@@ -174,7 +188,10 @@ export const submitReviewTool: AgentTool<typeof SubmitReviewParams, SubmitReview
174
188
 
175
189
  container.addChild(
176
190
  new Text(
177
- `${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg(verdictColor, details.overall_correctness)} ${theme.fg("dim", `(${(details.confidence * 100).toFixed(0)}% confidence)`)}`,
191
+ `${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg(
192
+ verdictColor,
193
+ details.overall_correctness,
194
+ )} ${theme.fg("dim", `(${(details.confidence * 100).toFixed(0)}% confidence)`)}`,
178
195
  0,
179
196
  0,
180
197
  ),
@@ -264,7 +281,9 @@ subprocessToolRegistry.register<SubmitReviewDetails>("submit_review", {
264
281
  const verdictColor = data.overall_correctness === "correct" ? "success" : "error";
265
282
  const verdictIcon = data.overall_correctness === "correct" ? theme.status.success : theme.status.error;
266
283
  return new Text(
267
- `${theme.fg(verdictColor, verdictIcon)} Review: ${theme.fg(verdictColor, data.overall_correctness)} (${(data.confidence * 100).toFixed(0)}%)`,
284
+ `${theme.fg(verdictColor, verdictIcon)} Review: ${theme.fg(verdictColor, data.overall_correctness)} (${(
285
+ data.confidence * 100
286
+ ).toFixed(0)}%)`,
268
287
  0,
269
288
  0,
270
289
  );
@@ -37,7 +37,9 @@ export function createRulebookTool(rules: Rule[]): AgentTool<typeof rulebookSche
37
37
  return {
38
38
  name: "rulebook",
39
39
  label: "Rulebook",
40
- description: `Fetch the full content of a project rule by name. Use this when a rule listed in <available_rules> is relevant to your current task. Available: ${ruleNames.join(", ") || "(none)"}`,
40
+ description: `Fetch the full content of a project rule by name. Use this when a rule listed in <available_rules> is relevant to your current task. Available: ${
41
+ ruleNames.join(", ") || "(none)"
42
+ }`,
41
43
  parameters: rulebookSchema,
42
44
  execute: async (_toolCallId: string, { name }: { name: string }) => {
43
45
  const rule = ruleMap.get(name);
@@ -13,8 +13,8 @@
13
13
  * - Session artifacts for debugging
14
14
  */
15
15
 
16
+ import type { Usage } from "@mariozechner/pi-ai";
16
17
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
17
- import type { Usage } from "@oh-my-pi/pi-ai";
18
18
  import type { Theme } from "../../../modes/interactive/theme/theme";
19
19
  import { formatDuration } from "../render-utils";
20
20
  import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir } from "./artifacts";
@@ -175,6 +175,12 @@ async function buildDescription(cwd: string): Promise<string> {
175
175
  lines.push("");
176
176
  lines.push("Usage notes:");
177
177
  lines.push("- Always include a short description of the task in the task parameter");
178
+ lines.push(
179
+ "- Prefer plan-then-execute: put shared constraints in context, keep each task focused, and specify output format and acceptance criteria",
180
+ );
181
+ lines.push(
182
+ "- Minimize tool chatter: avoid repeating large context and use the Output tool with output ids for full logs",
183
+ );
178
184
  lines.push("- Launch multiple agents concurrently whenever possible, to maximize performance");
179
185
  lines.push(
180
186
  "- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.",
@@ -510,9 +516,18 @@ export async function createTaskTool(
510
516
 
511
517
  const skippedNote =
512
518
  skippedSelfRecursion > 0
513
- ? ` (${skippedSelfRecursion} ${blockedAgent} task${skippedSelfRecursion > 1 ? "s" : ""} skipped - self-recursion blocked)`
519
+ ? ` (${skippedSelfRecursion} ${blockedAgent} task${
520
+ skippedSelfRecursion > 1 ? "s" : ""
521
+ } skipped - self-recursion blocked)`
522
+ : "";
523
+ const outputIds = resultsWithUsage.map((r) => `${r.agent}_${r.index}`);
524
+ const outputHint =
525
+ hasOutputTool && outputIds.length > 0
526
+ ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}`
514
527
  : "";
515
- const summary = `${successCount}/${resultsWithUsage.length} succeeded${skippedNote} [${formatDuration(totalDuration)}]\n\n${summaries.join("\n\n---\n\n")}`;
528
+ const summary = `${successCount}/${resultsWithUsage.length} succeeded${skippedNote} [${formatDuration(
529
+ totalDuration,
530
+ )}]\n\n${summaries.join("\n\n---\n\n")}${outputHint}`;
516
531
 
517
532
  // Cleanup temp directory if used
518
533
  if (tempArtifactsDir) {
@@ -475,3 +475,8 @@ export function renderResult(
475
475
 
476
476
  return new Text(lines.join("\n"), 0, 0);
477
477
  }
478
+
479
+ export const taskToolRenderer = {
480
+ renderCall,
481
+ renderResult,
482
+ };
@@ -1,4 +1,4 @@
1
- import type { Usage } from "@oh-my-pi/pi-ai";
1
+ import type { Usage } from "@mariozechner/pi-ai";
2
2
  import { type Static, Type } from "@sinclair/typebox";
3
3
 
4
4
  /** Source of an agent definition */
@@ -5,7 +5,8 @@
5
5
  * - Line limit (default: 2000 lines)
6
6
  * - Byte limit (default: 50KB)
7
7
  *
8
- * Never returns partial lines (except bash tail truncation edge case).
8
+ * Never returns partial lines (except bash tail truncation edge case
9
+ * and the read tool's long-line snippet fallback).
9
10
  */
10
11
 
11
12
  export const DEFAULT_MAX_LINES = 2000;
@@ -250,6 +251,31 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
250
251
  return buf.slice(start).toString("utf-8");
251
252
  }
252
253
 
254
+ /**
255
+ * Truncate a string to fit within a byte limit (from the start).
256
+ * Handles multi-byte UTF-8 characters correctly.
257
+ */
258
+ export function truncateStringToBytesFromStart(str: string, maxBytes: number): { text: string; bytes: number } {
259
+ const buf = Buffer.from(str, "utf-8");
260
+ if (buf.length <= maxBytes) {
261
+ return { text: str, bytes: buf.length };
262
+ }
263
+
264
+ let end = maxBytes;
265
+
266
+ // Find a valid UTF-8 boundary (start of a character)
267
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) {
268
+ end--;
269
+ }
270
+
271
+ if (end <= 0) {
272
+ return { text: "", bytes: 0 };
273
+ }
274
+
275
+ const text = buf.slice(0, end).toString("utf-8");
276
+ return { text, bytes: Buffer.byteLength(text, "utf-8") };
277
+ }
278
+
253
279
  /**
254
280
  * Truncate a single line to max characters, adding [truncated] suffix.
255
281
  * Used for grep match lines.
@@ -66,8 +66,6 @@ const CONVERTIBLE_EXTENSIONS = new Set([
66
66
  ".ogg",
67
67
  ]);
68
68
 
69
- const isWindows = process.platform === "win32";
70
-
71
69
  const USER_AGENTS = [
72
70
  "curl/8.0",
73
71
  "Mozilla/5.0 (compatible; TextBot/1.0)",
@@ -211,13 +209,7 @@ function exec(
211
209
  * Check if a command exists (cross-platform)
212
210
  */
213
211
  function hasCommand(cmd: string): boolean {
214
- const checkCmd = isWindows ? "where" : "which";
215
- const result = Bun.spawnSync([checkCmd, cmd], {
216
- stdin: "ignore",
217
- stdout: "pipe",
218
- stderr: "pipe",
219
- });
220
- return result.exitCode === 0;
212
+ return Boolean(Bun.which(cmd));
221
213
  }
222
214
 
223
215
  /**
@@ -626,7 +618,18 @@ async function fetchBinary(
626
618
 
627
619
  const contentType = response.headers.get("content-type") ?? "";
628
620
  const contentDisposition = response.headers.get("content-disposition") ?? undefined;
621
+ const contentLength = response.headers.get("content-length");
622
+ if (contentLength) {
623
+ const size = Number.parseInt(contentLength, 10);
624
+ if (Number.isFinite(size) && size > MAX_BYTES) {
625
+ return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
626
+ }
627
+ }
628
+
629
629
  const buffer = Buffer.from(await response.arrayBuffer());
630
+ if (buffer.length > MAX_BYTES) {
631
+ return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
632
+ }
630
633
 
631
634
  return { buffer, contentType, contentDisposition, ok: true };
632
635
  } catch {
@@ -1959,16 +1962,16 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1959
1962
  const notes: string[] = [];
1960
1963
  const fetchedAt = new Date().toISOString();
1961
1964
 
1962
- // Step 0: Try special handlers for known sites (unless raw mode)
1965
+ // Step 0: Normalize URL (ensure scheme for special handlers)
1966
+ url = normalizeUrl(url);
1967
+ const origin = getOrigin(url);
1968
+
1969
+ // Step 1: Try special handlers for known sites (unless raw mode)
1963
1970
  if (!raw) {
1964
1971
  const specialResult = await handleSpecialUrls(url, timeout);
1965
1972
  if (specialResult) return specialResult;
1966
1973
  }
1967
1974
 
1968
- // Step 1: Normalize URL
1969
- url = normalizeUrl(url);
1970
- const origin = getOrigin(url);
1971
-
1972
1975
  // Step 2: Fetch page
1973
1976
  const response = await loadPage(url, { timeout });
1974
1977
  if (!response.ok) {
@@ -2270,7 +2273,7 @@ export interface WebFetchToolDetails {
2270
2273
  export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchema> {
2271
2274
  return {
2272
2275
  name: "web_fetch",
2273
- label: "web_fetch",
2276
+ label: "Web Fetch",
2274
2277
  description: webFetchDescription,
2275
2278
  parameters: webFetchSchema,
2276
2279
  execute: async (
@@ -2483,6 +2486,11 @@ export function renderWebFetchResult(
2483
2486
  return new Text(text, 0, 0);
2484
2487
  }
2485
2488
 
2489
+ export const webFetchToolRenderer = {
2490
+ renderCall: renderWebFetchCall,
2491
+ renderResult: renderWebFetchResult,
2492
+ };
2493
+
2486
2494
  type WebFetchParams = { url: string; timeout?: number; raw?: boolean };
2487
2495
 
2488
2496
  /** Web fetch tool as CustomTool (for TUI rendering support) */
@@ -21,11 +21,13 @@ import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchRes
21
21
  import { renderExaCall, renderExaResult } from "../exa/render";
22
22
  import type { ExaRenderDetails } from "../exa/types";
23
23
  import { formatAge } from "../render-utils";
24
+ import { findAnthropicAuth } from "./auth";
24
25
  import { searchAnthropic } from "./providers/anthropic";
25
26
  import { searchExa } from "./providers/exa";
26
27
  import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
27
28
  import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
28
29
  import type { WebSearchProvider, WebSearchResponse } from "./types";
30
+ import { WebSearchProviderError } from "./types";
29
31
 
30
32
  /** Web search parameters schema */
31
33
  export const webSearchSchema = Type.Object({
@@ -95,18 +97,78 @@ export type WebSearchParams = {
95
97
  return_related_questions?: boolean;
96
98
  };
97
99
 
98
- /** Detect provider based on available API keys (priority: exa > perplexity > anthropic) */
99
- async function detectProvider(): Promise<WebSearchProvider> {
100
- // Exa takes highest priority if key exists
100
+ /** Preferred provider set via settings (default: auto) */
101
+ let preferredProvider: WebSearchProvider | "auto" = "auto";
102
+
103
+ /** Set the preferred web search provider from settings */
104
+ export function setPreferredWebSearchProvider(provider: WebSearchProvider | "auto"): void {
105
+ preferredProvider = provider;
106
+ }
107
+
108
+ /** Determine which providers are configured (priority order) */
109
+ async function getAvailableProviders(): Promise<WebSearchProvider[]> {
110
+ const providers: WebSearchProvider[] = [];
111
+
101
112
  const exaKey = await findExaKey();
102
- if (exaKey) return "exa";
113
+ if (exaKey) providers.push("exa");
103
114
 
104
- // Perplexity second priority
105
115
  const perplexityKey = await findPerplexityKey();
106
- if (perplexityKey) return "perplexity";
116
+ if (perplexityKey) providers.push("perplexity");
117
+
118
+ const anthropicAuth = await findAnthropicAuth();
119
+ if (anthropicAuth) providers.push("anthropic");
120
+
121
+ return providers;
122
+ }
123
+
124
+ function formatProviderLabel(provider: WebSearchProvider): string {
125
+ switch (provider) {
126
+ case "exa":
127
+ return "Exa";
128
+ case "perplexity":
129
+ return "Perplexity";
130
+ case "anthropic":
131
+ return "Anthropic";
132
+ default:
133
+ return provider;
134
+ }
135
+ }
136
+
137
+ function formatProviderList(providers: WebSearchProvider[]): string {
138
+ return providers.map((provider) => formatProviderLabel(provider)).join(", ");
139
+ }
140
+
141
+ function buildNoProviderError(): string {
142
+ return "No web search provider configured. Set EXA_API_KEY, PERPLEXITY_API_KEY, ANTHROPIC_SEARCH_API_KEY, or ANTHROPIC_API_KEY.";
143
+ }
144
+
145
+ function formatProviderError(error: unknown, provider: WebSearchProvider): string {
146
+ if (error instanceof WebSearchProviderError) {
147
+ if (error.provider === "anthropic" && error.status === 404) {
148
+ return "Anthropic web search returned 404 (model or endpoint not found). Set ANTHROPIC_SEARCH_MODEL/ANTHROPIC_SEARCH_BASE_URL, or configure EXA_API_KEY or PERPLEXITY_API_KEY.";
149
+ }
150
+ if (error.status === 401 || error.status === 403) {
151
+ return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
152
+ }
153
+ return error.message;
154
+ }
155
+ if (error instanceof Error) return error.message;
156
+ return `Unknown error from ${formatProviderLabel(provider)}`;
157
+ }
158
+
159
+ async function resolveProviderChain(
160
+ requestedProvider?: WebSearchProvider | "auto",
161
+ ): Promise<{ providers: WebSearchProvider[]; allowFallback: boolean }> {
162
+ if (requestedProvider && requestedProvider !== "auto") {
163
+ return { providers: [requestedProvider], allowFallback: false };
164
+ }
165
+
166
+ if (preferredProvider !== "auto") {
167
+ return { providers: [preferredProvider], allowFallback: false };
168
+ }
107
169
 
108
- // Default to Anthropic
109
- return "anthropic";
170
+ const providers = await getAvailableProviders();
171
+ return { providers, allowFallback: true };
110
172
  }
111
173
 
112
174
  /** Truncate text for tool output */
@@ -198,48 +260,71 @@ async function executeWebSearch(
198
260
  _toolCallId: string,
199
261
  params: WebSearchParams,
200
262
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> {
201
- try {
202
- const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider();
203
-
204
- let response: WebSearchResponse;
205
- if (provider === "exa") {
206
- response = await searchExa({
207
- query: params.query,
208
- num_results: params.num_results,
209
- });
210
- } else if (provider === "anthropic") {
211
- response = await searchAnthropic({
212
- query: params.query,
213
- system_prompt: params.system_prompt,
214
- max_tokens: params.max_tokens,
215
- num_results: params.num_results,
216
- });
217
- } else {
218
- response = await searchPerplexity({
219
- query: params.query,
220
- model: params.model,
221
- system_prompt: params.system_prompt,
222
- search_recency_filter: params.search_recency_filter,
223
- search_domain_filter: params.search_domain_filter,
224
- search_context_size: params.search_context_size,
225
- return_related_questions: params.return_related_questions,
226
- num_results: params.num_results,
227
- });
228
- }
263
+ const { providers, allowFallback } = await resolveProviderChain(params.provider);
229
264
 
230
- const text = formatForLLM(response);
231
-
232
- return {
233
- content: [{ type: "text" as const, text }],
234
- details: { response },
235
- };
236
- } catch (error) {
237
- const message = error instanceof Error ? error.message : String(error);
265
+ if (providers.length === 0) {
266
+ const message = buildNoProviderError();
267
+ const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider;
238
268
  return {
239
269
  content: [{ type: "text" as const, text: `Error: ${message}` }],
240
- details: { response: { provider: "anthropic", sources: [] }, error: message },
270
+ details: { response: { provider: fallbackProvider, sources: [] }, error: message },
241
271
  };
242
272
  }
273
+
274
+ let lastError: unknown;
275
+ let lastProvider = providers[0];
276
+
277
+ for (const provider of providers) {
278
+ lastProvider = provider;
279
+ try {
280
+ let response: WebSearchResponse;
281
+ if (provider === "exa") {
282
+ response = await searchExa({
283
+ query: params.query,
284
+ num_results: params.num_results,
285
+ });
286
+ } else if (provider === "anthropic") {
287
+ response = await searchAnthropic({
288
+ query: params.query,
289
+ system_prompt: params.system_prompt,
290
+ max_tokens: params.max_tokens,
291
+ num_results: params.num_results,
292
+ });
293
+ } else {
294
+ response = await searchPerplexity({
295
+ query: params.query,
296
+ model: params.model,
297
+ system_prompt: params.system_prompt,
298
+ search_recency_filter: params.search_recency_filter,
299
+ search_domain_filter: params.search_domain_filter,
300
+ search_context_size: params.search_context_size,
301
+ return_related_questions: params.return_related_questions,
302
+ num_results: params.num_results,
303
+ });
304
+ }
305
+
306
+ const text = formatForLLM(response);
307
+
308
+ return {
309
+ content: [{ type: "text" as const, text }],
310
+ details: { response },
311
+ };
312
+ } catch (error) {
313
+ lastError = error;
314
+ if (!allowFallback) break;
315
+ }
316
+ }
317
+
318
+ const baseMessage = formatProviderError(lastError, lastProvider);
319
+ const message =
320
+ allowFallback && providers.length > 1
321
+ ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}`
322
+ : baseMessage;
323
+
324
+ return {
325
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
326
+ details: { response: { provider: lastProvider, sources: [] }, error: message },
327
+ };
243
328
  }
244
329
 
245
330
  /** Web search tool as AgentTool (for allTools export) */
@@ -14,8 +14,9 @@ import type {
14
14
  WebSearchResponse,
15
15
  WebSearchSource,
16
16
  } from "../types";
17
+ import { WebSearchProviderError } from "../types";
17
18
 
18
- const DEFAULT_MODEL = "claude-sonnet-4-5-20250514";
19
+ const DEFAULT_MODEL = "claude-haiku-4-5";
19
20
  const DEFAULT_MAX_TOKENS = 4096;
20
21
 
21
22
  export interface AnthropicSearchParams {
@@ -80,7 +81,11 @@ async function callWebSearch(
80
81
 
81
82
  if (!response.ok) {
82
83
  const errorText = await response.text();
83
- throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
84
+ throw new WebSearchProviderError(
85
+ "anthropic",
86
+ `Anthropic API error (${response.status}): ${errorText}`,
87
+ response.status,
88
+ );
84
89
  }
85
90
 
86
91
  return response.json() as Promise<AnthropicApiResponse>;
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import type { WebSearchResponse, WebSearchSource } from "../types";
11
+ import { WebSearchProviderError } from "../types";
11
12
 
12
13
  const EXA_API_URL = "https://api.exa.ai/search";
13
14
 
@@ -142,7 +143,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
142
143
 
143
144
  if (!response.ok) {
144
145
  const errorText = await response.text();
145
- throw new Error(`Exa API error (${response.status}): ${errorText}`);
146
+ throw new WebSearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
146
147
  }
147
148
 
148
149
  return response.json() as Promise<ExaSearchResponse>;
@@ -13,6 +13,7 @@ import type {
13
13
  WebSearchResponse,
14
14
  WebSearchSource,
15
15
  } from "../types";
16
+ import { WebSearchProviderError } from "../types";
16
17
 
17
18
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
18
19
 
@@ -92,7 +93,11 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi
92
93
 
93
94
  if (!response.ok) {
94
95
  const errorText = await response.text();
95
- throw new Error(`Perplexity API error (${response.status}): ${errorText}`);
96
+ throw new WebSearchProviderError(
97
+ "perplexity",
98
+ `Perplexity API error (${response.status}): ${errorText}`,
99
+ response.status,
100
+ );
96
101
  }
97
102
 
98
103
  return response.json() as Promise<PerplexityResponse>;
@@ -325,3 +325,8 @@ export function renderWebSearchCall(
325
325
  const text = `${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${provider})`)} ${theme.fg("muted", query)}`;
326
326
  return new Text(text, 0, 0);
327
327
  }
328
+
329
+ export const webSearchToolRenderer = {
330
+ renderCall: renderWebSearchCall,
331
+ renderResult: renderWebSearchResult,
332
+ };
@@ -57,6 +57,19 @@ export interface WebSearchResponse {
57
57
  requestId?: string;
58
58
  }
59
59
 
60
+ /** Provider-specific error with optional HTTP status */
61
+ export class WebSearchProviderError extends Error {
62
+ provider: WebSearchProvider;
63
+ status?: number;
64
+
65
+ constructor(provider: WebSearchProvider, message: string, status?: number) {
66
+ super(message);
67
+ this.name = "WebSearchProviderError";
68
+ this.provider = provider;
69
+ this.status = status;
70
+ }
71
+ }
72
+
60
73
  /** Auth configuration for Anthropic */
61
74
  export interface AnthropicAuthConfig {
62
75
  apiKey: string;