@oh-my-pi/pi-coding-agent 3.20.1 → 3.24.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 (123) hide show
  1. package/CHANGELOG.md +107 -8
  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 +50 -53
  6. package/examples/custom-tools/README.md +2 -17
  7. package/examples/extensions/README.md +76 -74
  8. package/examples/extensions/todo.ts +2 -5
  9. package/examples/hooks/custom-compaction.ts +2 -4
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +7 -11
  14. package/package.json +6 -6
  15. package/src/cli/args.ts +9 -6
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +16 -5
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/custom-tools/wrapper.ts +0 -1
  25. package/src/core/extensions/index.ts +1 -6
  26. package/src/core/extensions/runner.ts +1 -1
  27. package/src/core/extensions/types.ts +1 -1
  28. package/src/core/extensions/wrapper.ts +1 -8
  29. package/src/core/file-mentions.ts +5 -8
  30. package/src/core/hooks/runner.ts +2 -2
  31. package/src/core/hooks/types.ts +1 -1
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +1 -1
  35. package/src/core/sdk.ts +64 -105
  36. package/src/core/session-manager.ts +18 -22
  37. package/src/core/settings-manager.ts +66 -1
  38. package/src/core/slash-commands.ts +12 -5
  39. package/src/core/system-prompt.ts +49 -36
  40. package/src/core/title-generator.ts +2 -2
  41. package/src/core/tools/ask.ts +98 -4
  42. package/src/core/tools/bash-interceptor.ts +11 -4
  43. package/src/core/tools/bash.ts +121 -5
  44. package/src/core/tools/context.ts +7 -0
  45. package/src/core/tools/edit-diff.ts +73 -24
  46. package/src/core/tools/edit.ts +221 -34
  47. package/src/core/tools/exa/render.ts +4 -16
  48. package/src/core/tools/find.ts +149 -5
  49. package/src/core/tools/gemini-image.ts +279 -56
  50. package/src/core/tools/git.ts +17 -3
  51. package/src/core/tools/grep.ts +185 -5
  52. package/src/core/tools/index.test.ts +180 -0
  53. package/src/core/tools/index.ts +96 -242
  54. package/src/core/tools/ls.ts +133 -5
  55. package/src/core/tools/lsp/index.ts +32 -29
  56. package/src/core/tools/lsp/render.ts +21 -22
  57. package/src/core/tools/notebook.ts +112 -4
  58. package/src/core/tools/output.ts +175 -15
  59. package/src/core/tools/read.ts +127 -25
  60. package/src/core/tools/render-utils.ts +241 -0
  61. package/src/core/tools/renderers.ts +40 -828
  62. package/src/core/tools/review.ts +26 -25
  63. package/src/core/tools/rulebook.ts +11 -3
  64. package/src/core/tools/task/agents.ts +28 -7
  65. package/src/core/tools/task/discovery.ts +0 -6
  66. package/src/core/tools/task/executor.ts +264 -254
  67. package/src/core/tools/task/index.ts +48 -208
  68. package/src/core/tools/task/render.ts +26 -11
  69. package/src/core/tools/task/types.ts +7 -12
  70. package/src/core/tools/task/worker-protocol.ts +17 -0
  71. package/src/core/tools/task/worker.ts +238 -0
  72. package/src/core/tools/truncate.ts +27 -1
  73. package/src/core/tools/web-fetch.ts +25 -49
  74. package/src/core/tools/web-search/index.ts +132 -46
  75. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  76. package/src/core/tools/web-search/providers/exa.ts +2 -1
  77. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  78. package/src/core/tools/web-search/render.ts +6 -4
  79. package/src/core/tools/web-search/types.ts +13 -0
  80. package/src/core/tools/write.ts +96 -14
  81. package/src/core/voice.ts +1 -1
  82. package/src/discovery/helpers.test.ts +1 -1
  83. package/src/index.ts +5 -16
  84. package/src/main.ts +5 -5
  85. package/src/modes/interactive/components/assistant-message.ts +1 -1
  86. package/src/modes/interactive/components/custom-message.ts +1 -1
  87. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  88. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  89. package/src/modes/interactive/components/footer.ts +1 -1
  90. package/src/modes/interactive/components/hook-message.ts +1 -1
  91. package/src/modes/interactive/components/model-selector.ts +1 -1
  92. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  93. package/src/modes/interactive/components/settings-defs.ts +49 -0
  94. package/src/modes/interactive/components/status-line.ts +1 -1
  95. package/src/modes/interactive/components/tool-execution.ts +93 -538
  96. package/src/modes/interactive/interactive-mode.ts +19 -7
  97. package/src/modes/interactive/theme/theme.ts +4 -4
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +1 -1
  100. package/src/modes/rpc/rpc-types.ts +1 -1
  101. package/src/prompts/system-prompt.md +4 -0
  102. package/src/prompts/task.md +0 -7
  103. package/src/prompts/tools/gemini-image.md +5 -1
  104. package/src/prompts/tools/output.md +6 -2
  105. package/src/prompts/tools/task.md +68 -0
  106. package/src/prompts/tools/web-fetch.md +1 -0
  107. package/src/prompts/tools/web-search.md +2 -0
  108. package/src/utils/image-convert.ts +8 -2
  109. package/src/utils/image-magick.ts +247 -0
  110. package/src/utils/image-resize.ts +53 -13
  111. package/examples/custom-tools/question/index.ts +0 -84
  112. package/examples/custom-tools/subagent/README.md +0 -172
  113. package/examples/custom-tools/subagent/agents/planner.md +0 -37
  114. package/examples/custom-tools/subagent/agents/scout.md +0 -50
  115. package/examples/custom-tools/subagent/agents/worker.md +0 -24
  116. package/examples/custom-tools/subagent/agents.ts +0 -156
  117. package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
  118. package/examples/custom-tools/subagent/commands/implement.md +0 -10
  119. package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
  120. package/examples/custom-tools/subagent/index.ts +0 -1002
  121. package/examples/sdk/05-tools.ts +0 -94
  122. package/examples/sdk/12-full-control.ts +0 -95
  123. package/src/prompts/browser.md +0 -71
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Worker thread for subagent execution.
3
+ *
4
+ * This worker runs in a separate thread via Bun's Worker API. It creates a minimal
5
+ * AgentSession and forwards events back to the parent thread.
6
+ *
7
+ * ## Event Flow
8
+ *
9
+ * 1. Parent sends { type: "start", payload } with task config
10
+ * 2. Worker creates AgentSession and subscribes to events
11
+ * 3. Worker forwards AgentEvent messages via postMessage
12
+ * 4. Worker sends { type: "done", exitCode, ... } on completion
13
+ * 5. Parent can send { type: "abort" } to request cancellation
14
+ */
15
+
16
+ import type { Api, Model } from "@mariozechner/pi-ai";
17
+ import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
18
+ import type { AgentSessionEvent } from "../../agent-session";
19
+ import { parseModelPattern, parseModelString } from "../../model-resolver";
20
+ import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
21
+ import { SessionManager } from "../../session-manager";
22
+ import type { SubagentWorkerRequest, SubagentWorkerResponse, SubagentWorkerStartPayload } from "./worker-protocol";
23
+
24
+ type PostMessageFn = (message: SubagentWorkerResponse) => void;
25
+
26
+ const postMessageSafe: PostMessageFn = (message) => {
27
+ (globalThis as typeof globalThis & { postMessage: PostMessageFn }).postMessage(message);
28
+ };
29
+
30
+ interface WorkerMessageEvent<T> {
31
+ data: T;
32
+ }
33
+
34
+ /** Agent event types to forward to parent (excludes session-only events like compaction) */
35
+ const agentEventTypes = new Set<AgentEvent["type"]>([
36
+ "agent_start",
37
+ "agent_end",
38
+ "turn_start",
39
+ "turn_end",
40
+ "message_start",
41
+ "message_update",
42
+ "message_end",
43
+ "tool_execution_start",
44
+ "tool_execution_update",
45
+ "tool_execution_end",
46
+ ]);
47
+
48
+ const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent => {
49
+ return agentEventTypes.has(event.type as AgentEvent["type"]);
50
+ };
51
+
52
+ let running = false;
53
+ let abortRequested = false;
54
+ let activeSession: { abort: () => Promise<void>; dispose: () => Promise<void> } | null = null;
55
+
56
+ /**
57
+ * Resolve model string to Model object with optional thinking level.
58
+ * Supports both exact "provider/id" format and fuzzy matching ("sonnet", "opus").
59
+ */
60
+ function resolveModelOverride(
61
+ override: string | undefined,
62
+ modelRegistry: { getAvailable: () => Model<Api>[]; find: (provider: string, id: string) => Model<Api> | undefined },
63
+ ): { model?: Model<Api>; thinkingLevel?: ThinkingLevel } {
64
+ if (!override) return {};
65
+
66
+ // Try exact "provider/id" format first
67
+ const parsed = parseModelString(override);
68
+ if (parsed) {
69
+ return { model: modelRegistry.find(parsed.provider, parsed.id) };
70
+ }
71
+
72
+ // Fall back to fuzzy pattern matching
73
+ const result = parseModelPattern(override, modelRegistry.getAvailable());
74
+ return {
75
+ model: result.model,
76
+ thinkingLevel: result.thinkingLevel !== "off" ? result.thinkingLevel : undefined,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Main task execution function.
82
+ *
83
+ * Equivalent to CLI flow:
84
+ * 1. omp --mode json --non-interactive
85
+ * 2. --append-system-prompt <agent.systemPrompt>
86
+ * 3. --tools <toolNames> (if specified)
87
+ * 4. --model <model> (if specified)
88
+ * 5. --session <sessionFile> OR --no-session
89
+ * 6. --prompt <task>
90
+ *
91
+ * Environment equivalent:
92
+ * - OMP_BLOCKED_AGENT: payload.blockedAgent (prevents same-agent recursion)
93
+ * - OMP_SPAWNS: payload.spawnsEnv (controls nested spawn permissions)
94
+ */
95
+ async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
96
+ const startTime = Date.now();
97
+ let exitCode = 0;
98
+ let error: string | undefined;
99
+ let aborted = false;
100
+
101
+ try {
102
+ // Check for pre-start abort
103
+ if (abortRequested) {
104
+ aborted = true;
105
+ exitCode = 1;
106
+ return;
107
+ }
108
+
109
+ // Set working directory (CLI does this implicitly)
110
+ process.chdir(payload.cwd);
111
+
112
+ // Discover auth and models (equivalent to CLI's discoverAuthStorage/discoverModels)
113
+ const authStorage = await discoverAuthStorage();
114
+ const modelRegistry = await discoverModels(authStorage);
115
+
116
+ // Resolve model override (equivalent to CLI's parseModelPattern with --model)
117
+ const { model, thinkingLevel } = resolveModelOverride(payload.model, modelRegistry);
118
+
119
+ // Create session manager (equivalent to CLI's --session or --no-session)
120
+ const sessionManager = payload.sessionFile
121
+ ? await SessionManager.open(payload.sessionFile)
122
+ : SessionManager.inMemory(payload.cwd);
123
+
124
+ // Create agent session (equivalent to CLI's createAgentSession)
125
+ // Note: hasUI: false disables interactive features
126
+ const { session } = await createAgentSession({
127
+ cwd: payload.cwd,
128
+ authStorage,
129
+ modelRegistry,
130
+ model,
131
+ thinkingLevel,
132
+ toolNames: payload.toolNames,
133
+ // Append system prompt (equivalent to CLI's --append-system-prompt)
134
+ systemPrompt: (defaultPrompt) => `${defaultPrompt}\n\n${payload.systemPrompt}`,
135
+ sessionManager,
136
+ hasUI: false,
137
+ // Pass spawn restrictions to nested tasks
138
+ spawns: payload.spawnsEnv,
139
+ });
140
+
141
+ activeSession = session;
142
+
143
+ // Initialize extensions (equivalent to CLI's extension initialization)
144
+ // Note: Does not support --extension CLI flag or extension CLI flags
145
+ const extensionRunner = session.extensionRunner;
146
+ if (extensionRunner) {
147
+ extensionRunner.initialize({
148
+ getModel: () => session.model,
149
+ sendMessageHandler: (message, options) => {
150
+ session.sendCustomMessage(message, options).catch((e) => {
151
+ console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
152
+ });
153
+ },
154
+ appendEntryHandler: (customType, data) => {
155
+ session.sessionManager.appendCustomEntry(customType, data);
156
+ },
157
+ getActiveToolsHandler: () => session.getActiveToolNames(),
158
+ getAllToolsHandler: () => session.getAllToolNames(),
159
+ setActiveToolsHandler: (toolNamesList: string[]) => session.setActiveToolsByName(toolNamesList),
160
+ });
161
+ extensionRunner.onError((err) => {
162
+ console.error(`Extension error (${err.extensionPath}): ${err.error}`);
163
+ });
164
+ await extensionRunner.emit({ type: "session_start" });
165
+ }
166
+
167
+ // Subscribe to events and forward to parent (equivalent to --mode json output)
168
+ session.subscribe((event: AgentSessionEvent) => {
169
+ if (isAgentEvent(event)) {
170
+ postMessageSafe({ type: "event", event });
171
+ }
172
+ });
173
+
174
+ // Run the prompt (equivalent to --prompt flag)
175
+ await session.prompt(payload.task);
176
+
177
+ // Check if aborted during execution
178
+ const lastMessage = session.state.messages[session.state.messages.length - 1];
179
+ if (lastMessage?.role === "assistant" && lastMessage.stopReason === "aborted") {
180
+ aborted = true;
181
+ exitCode = 1;
182
+ }
183
+ } catch (err) {
184
+ exitCode = 1;
185
+ error = err instanceof Error ? err.stack || err.message : String(err);
186
+ } finally {
187
+ // Handle abort requested during execution
188
+ if (abortRequested) {
189
+ aborted = true;
190
+ if (exitCode === 0) exitCode = 1;
191
+ }
192
+
193
+ // Cleanup session
194
+ if (activeSession) {
195
+ try {
196
+ await activeSession.dispose();
197
+ } catch {
198
+ // Ignore cleanup errors
199
+ }
200
+ activeSession = null;
201
+ }
202
+
203
+ // Send completion message to parent
204
+ postMessageSafe({
205
+ type: "done",
206
+ exitCode,
207
+ durationMs: Date.now() - startTime,
208
+ error,
209
+ aborted,
210
+ });
211
+ }
212
+ }
213
+
214
+ /** Handle abort request from parent */
215
+ function handleAbort(): void {
216
+ abortRequested = true;
217
+ if (activeSession) {
218
+ void activeSession.abort();
219
+ }
220
+ }
221
+
222
+ // Message handler - receives start/abort commands from parent
223
+ globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorkerRequest>) => {
224
+ const message = event.data;
225
+ if (!message) return;
226
+
227
+ if (message.type === "abort") {
228
+ handleAbort();
229
+ return;
230
+ }
231
+
232
+ if (message.type === "start") {
233
+ // Only allow one task per worker
234
+ if (running) return;
235
+ running = true;
236
+ void runTask(message.payload);
237
+ }
238
+ });
@@ -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.
@@ -5,6 +5,7 @@ import { Type } from "@sinclair/typebox";
5
5
  import { parse as parseHtml } from "node-html-parser";
6
6
  import webFetchDescription from "../../prompts/tools/web-fetch.md" with { type: "text" };
7
7
  import { logger } from "../logger";
8
+ import type { ToolSession } from "./index";
8
9
 
9
10
  // =============================================================================
10
11
  // Types and Constants
@@ -66,8 +67,6 @@ const CONVERTIBLE_EXTENSIONS = new Set([
66
67
  ".ogg",
67
68
  ]);
68
69
 
69
- const isWindows = process.platform === "win32";
70
-
71
70
  const USER_AGENTS = [
72
71
  "curl/8.0",
73
72
  "Mozilla/5.0 (compatible; TextBot/1.0)",
@@ -211,13 +210,7 @@ function exec(
211
210
  * Check if a command exists (cross-platform)
212
211
  */
213
212
  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;
213
+ return Boolean(Bun.which(cmd));
221
214
  }
222
215
 
223
216
  /**
@@ -626,7 +619,18 @@ async function fetchBinary(
626
619
 
627
620
  const contentType = response.headers.get("content-type") ?? "";
628
621
  const contentDisposition = response.headers.get("content-disposition") ?? undefined;
622
+ const contentLength = response.headers.get("content-length");
623
+ if (contentLength) {
624
+ const size = Number.parseInt(contentLength, 10);
625
+ if (Number.isFinite(size) && size > MAX_BYTES) {
626
+ return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
627
+ }
628
+ }
629
+
629
630
  const buffer = Buffer.from(await response.arrayBuffer());
631
+ if (buffer.length > MAX_BYTES) {
632
+ return { buffer: Buffer.alloc(0), contentType, contentDisposition, ok: false };
633
+ }
630
634
 
631
635
  return { buffer, contentType, contentDisposition, ok: true };
632
636
  } catch {
@@ -1236,9 +1240,7 @@ async function handleStackOverflow(url: string, timeout: number): Promise<Render
1236
1240
  md += `**Score:** ${question.score} · **Answers:** ${question.answer_count}`;
1237
1241
  md += question.is_answered ? " (Answered)" : "";
1238
1242
  md += `\n**Tags:** ${question.tags.join(", ")}\n`;
1239
- md += `**Asked by:** ${question.owner.display_name} · ${
1240
- new Date(question.creation_date * 1000).toISOString().split("T")[0]
1241
- }\n\n`;
1243
+ md += `**Asked by:** ${question.owner.display_name} · ${new Date(question.creation_date * 1000).toISOString().split("T")[0]}\n\n`;
1242
1244
  md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
1243
1245
 
1244
1246
  // Fetch answers
@@ -1959,16 +1961,16 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1959
1961
  const notes: string[] = [];
1960
1962
  const fetchedAt = new Date().toISOString();
1961
1963
 
1962
- // Step 0: Try special handlers for known sites (unless raw mode)
1964
+ // Step 0: Normalize URL (ensure scheme for special handlers)
1965
+ url = normalizeUrl(url);
1966
+ const origin = getOrigin(url);
1967
+
1968
+ // Step 1: Try special handlers for known sites (unless raw mode)
1963
1969
  if (!raw) {
1964
1970
  const specialResult = await handleSpecialUrls(url, timeout);
1965
1971
  if (specialResult) return specialResult;
1966
1972
  }
1967
1973
 
1968
- // Step 1: Normalize URL
1969
- url = normalizeUrl(url);
1970
- const origin = getOrigin(url);
1971
-
1972
1974
  // Step 2: Fetch page
1973
1975
  const response = await loadPage(url, { timeout });
1974
1976
  if (!response.ok) {
@@ -2267,10 +2269,10 @@ export interface WebFetchToolDetails {
2267
2269
  notes: string[];
2268
2270
  }
2269
2271
 
2270
- export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchema> {
2272
+ export function createWebFetchTool(_session: ToolSession): AgentTool<typeof webFetchSchema> {
2271
2273
  return {
2272
2274
  name: "web_fetch",
2273
- label: "web_fetch",
2275
+ label: "Web Fetch",
2274
2276
  description: webFetchDescription,
2275
2277
  parameters: webFetchSchema,
2276
2278
  execute: async (
@@ -2313,9 +2315,6 @@ export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchem
2313
2315
  };
2314
2316
  }
2315
2317
 
2316
- /** Default web fetch tool using process.cwd() - for backwards compatibility */
2317
- export const webFetchTool = createWebFetchTool(process.cwd());
2318
-
2319
2318
  // =============================================================================
2320
2319
  // TUI Rendering
2321
2320
  // =============================================================================
@@ -2323,7 +2322,7 @@ export const webFetchTool = createWebFetchTool(process.cwd());
2323
2322
  import type { Component } from "@oh-my-pi/pi-tui";
2324
2323
  import { Text } from "@oh-my-pi/pi-tui";
2325
2324
  import { type Theme, theme } from "../../modes/interactive/theme/theme";
2326
- import type { CustomTool, CustomToolContext, RenderResultOptions } from "../custom-tools/types";
2325
+ import type { RenderResultOptions } from "../custom-tools/types";
2327
2326
 
2328
2327
  /** Truncate text to max length with ellipsis */
2329
2328
  function truncate(text: string, maxLen: number, ellipsis: string): string {
@@ -2483,30 +2482,7 @@ export function renderWebFetchResult(
2483
2482
  return new Text(text, 0, 0);
2484
2483
  }
2485
2484
 
2486
- type WebFetchParams = { url: string; timeout?: number; raw?: boolean };
2487
-
2488
- /** Web fetch tool as CustomTool (for TUI rendering support) */
2489
- export const webFetchCustomTool: CustomTool<typeof webFetchSchema, WebFetchToolDetails> = {
2490
- name: "web_fetch",
2491
- label: "Web Fetch",
2492
- description: webFetchDescription,
2493
- parameters: webFetchSchema,
2494
-
2495
- async execute(
2496
- toolCallId: string,
2497
- params: WebFetchParams,
2498
- _onUpdate,
2499
- _ctx: CustomToolContext,
2500
- _signal?: AbortSignal,
2501
- ) {
2502
- return webFetchTool.execute(toolCallId, params);
2503
- },
2504
-
2505
- renderCall(args: WebFetchParams, uiTheme: Theme) {
2506
- return renderWebFetchCall(args, uiTheme);
2507
- },
2508
-
2509
- renderResult(result, options: RenderResultOptions, uiTheme: Theme) {
2510
- return renderWebFetchResult(result, options, uiTheme);
2511
- },
2485
+ export const webFetchToolRenderer = {
2486
+ renderCall: renderWebFetchCall,
2487
+ renderResult: renderWebFetchResult,
2512
2488
  };
@@ -20,12 +20,15 @@ import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../c
20
20
  import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client";
21
21
  import { renderExaCall, renderExaResult } from "../exa/render";
22
22
  import type { ExaRenderDetails } from "../exa/types";
23
+ import type { ToolSession } from "../index";
23
24
  import { formatAge } from "../render-utils";
25
+ import { findAnthropicAuth } from "./auth";
24
26
  import { searchAnthropic } from "./providers/anthropic";
25
27
  import { searchExa } from "./providers/exa";
26
28
  import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
27
29
  import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
28
30
  import type { WebSearchProvider, WebSearchResponse } from "./types";
31
+ import { WebSearchProviderError } from "./types";
29
32
 
30
33
  /** Web search parameters schema */
31
34
  export const webSearchSchema = Type.Object({
@@ -95,18 +98,78 @@ export type WebSearchParams = {
95
98
  return_related_questions?: boolean;
96
99
  };
97
100
 
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
101
+ /** Preferred provider set via settings (default: auto) */
102
+ let preferredProvider: WebSearchProvider | "auto" = "auto";
103
+
104
+ /** Set the preferred web search provider from settings */
105
+ export function setPreferredWebSearchProvider(provider: WebSearchProvider | "auto"): void {
106
+ preferredProvider = provider;
107
+ }
108
+
109
+ /** Determine which providers are configured (priority order) */
110
+ async function getAvailableProviders(): Promise<WebSearchProvider[]> {
111
+ const providers: WebSearchProvider[] = [];
112
+
101
113
  const exaKey = await findExaKey();
102
- if (exaKey) return "exa";
114
+ if (exaKey) providers.push("exa");
103
115
 
104
- // Perplexity second priority
105
116
  const perplexityKey = await findPerplexityKey();
106
- if (perplexityKey) return "perplexity";
117
+ if (perplexityKey) providers.push("perplexity");
118
+
119
+ const anthropicAuth = await findAnthropicAuth();
120
+ if (anthropicAuth) providers.push("anthropic");
121
+
122
+ return providers;
123
+ }
124
+
125
+ function formatProviderLabel(provider: WebSearchProvider): string {
126
+ switch (provider) {
127
+ case "exa":
128
+ return "Exa";
129
+ case "perplexity":
130
+ return "Perplexity";
131
+ case "anthropic":
132
+ return "Anthropic";
133
+ default:
134
+ return provider;
135
+ }
136
+ }
137
+
138
+ function formatProviderList(providers: WebSearchProvider[]): string {
139
+ return providers.map((provider) => formatProviderLabel(provider)).join(", ");
140
+ }
141
+
142
+ function buildNoProviderError(): string {
143
+ return "No web search provider configured. Set EXA_API_KEY, PERPLEXITY_API_KEY, ANTHROPIC_SEARCH_API_KEY, or ANTHROPIC_API_KEY.";
144
+ }
145
+
146
+ function formatProviderError(error: unknown, provider: WebSearchProvider): string {
147
+ if (error instanceof WebSearchProviderError) {
148
+ if (error.provider === "anthropic" && error.status === 404) {
149
+ 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.";
150
+ }
151
+ if (error.status === 401 || error.status === 403) {
152
+ return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
153
+ }
154
+ return error.message;
155
+ }
156
+ if (error instanceof Error) return error.message;
157
+ return `Unknown error from ${formatProviderLabel(provider)}`;
158
+ }
159
+
160
+ async function resolveProviderChain(
161
+ requestedProvider?: WebSearchProvider | "auto",
162
+ ): Promise<{ providers: WebSearchProvider[]; allowFallback: boolean }> {
163
+ if (requestedProvider && requestedProvider !== "auto") {
164
+ return { providers: [requestedProvider], allowFallback: false };
165
+ }
166
+
167
+ if (preferredProvider !== "auto") {
168
+ return { providers: [preferredProvider], allowFallback: false };
169
+ }
107
170
 
108
- // Default to Anthropic
109
- return "anthropic";
171
+ const providers = await getAvailableProviders();
172
+ return { providers, allowFallback: true };
110
173
  }
111
174
 
112
175
  /** Truncate text for tool output */
@@ -198,48 +261,71 @@ async function executeWebSearch(
198
261
  _toolCallId: string,
199
262
  params: WebSearchParams,
200
263
  ): 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
- }
264
+ const { providers, allowFallback } = await resolveProviderChain(params.provider);
229
265
 
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);
266
+ if (providers.length === 0) {
267
+ const message = buildNoProviderError();
268
+ const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider;
238
269
  return {
239
270
  content: [{ type: "text" as const, text: `Error: ${message}` }],
240
- details: { response: { provider: "anthropic", sources: [] }, error: message },
271
+ details: { response: { provider: fallbackProvider, sources: [] }, error: message },
241
272
  };
242
273
  }
274
+
275
+ let lastError: unknown;
276
+ let lastProvider = providers[0];
277
+
278
+ for (const provider of providers) {
279
+ lastProvider = provider;
280
+ try {
281
+ let response: WebSearchResponse;
282
+ if (provider === "exa") {
283
+ response = await searchExa({
284
+ query: params.query,
285
+ num_results: params.num_results,
286
+ });
287
+ } else if (provider === "anthropic") {
288
+ response = await searchAnthropic({
289
+ query: params.query,
290
+ system_prompt: params.system_prompt,
291
+ max_tokens: params.max_tokens,
292
+ num_results: params.num_results,
293
+ });
294
+ } else {
295
+ response = await searchPerplexity({
296
+ query: params.query,
297
+ model: params.model,
298
+ system_prompt: params.system_prompt,
299
+ search_recency_filter: params.search_recency_filter,
300
+ search_domain_filter: params.search_domain_filter,
301
+ search_context_size: params.search_context_size,
302
+ return_related_questions: params.return_related_questions,
303
+ num_results: params.num_results,
304
+ });
305
+ }
306
+
307
+ const text = formatForLLM(response);
308
+
309
+ return {
310
+ content: [{ type: "text" as const, text }],
311
+ details: { response },
312
+ };
313
+ } catch (error) {
314
+ lastError = error;
315
+ if (!allowFallback) break;
316
+ }
317
+ }
318
+
319
+ const baseMessage = formatProviderError(lastError, lastProvider);
320
+ const message =
321
+ allowFallback && providers.length > 1
322
+ ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}`
323
+ : baseMessage;
324
+
325
+ return {
326
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
327
+ details: { response: { provider: lastProvider, sources: [] }, error: message },
328
+ };
243
329
  }
244
330
 
245
331
  /** Web search tool as AgentTool (for allTools export) */
@@ -280,7 +366,7 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, WebSearchRe
280
366
  };
281
367
 
282
368
  /** Factory function for backward compatibility */
283
- export function createWebSearchTool(_cwd: string): AgentTool<typeof webSearchSchema> {
369
+ export function createWebSearchTool(_session: ToolSession): AgentTool<typeof webSearchSchema> {
284
370
  return webSearchTool;
285
371
  }
286
372
 
@@ -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>;