@oh-my-pi/pi-coding-agent 3.20.0 → 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.
- package/CHANGELOG.md +78 -8
- package/docs/custom-tools.md +3 -3
- package/docs/extensions.md +226 -220
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +3 -3
- package/examples/custom-tools/README.md +2 -2
- package/examples/custom-tools/subagent/index.ts +1 -1
- package/examples/extensions/README.md +76 -74
- package/examples/extensions/todo.ts +2 -5
- package/examples/hooks/custom-compaction.ts +1 -1
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/12-full-control.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +13 -2
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +1 -1
- package/src/core/sdk.ts +33 -4
- package/src/core/session-manager.ts +11 -22
- package/src/core/settings-manager.ts +66 -1
- package/src/core/slash-commands.ts +12 -5
- package/src/core/system-prompt.ts +27 -3
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/ask.ts +88 -1
- package/src/core/tools/bash-interceptor.ts +7 -0
- package/src/core/tools/bash.ts +106 -0
- package/src/core/tools/edit-diff.ts +73 -24
- package/src/core/tools/edit.ts +214 -20
- package/src/core/tools/find.ts +162 -1
- package/src/core/tools/gemini-image.ts +279 -56
- package/src/core/tools/git.ts +4 -0
- package/src/core/tools/grep.ts +191 -0
- package/src/core/tools/index.ts +3 -6
- package/src/core/tools/ls.ts +142 -2
- package/src/core/tools/lsp/render.ts +34 -14
- package/src/core/tools/notebook.ts +110 -0
- package/src/core/tools/output.ts +179 -7
- package/src/core/tools/read.ts +122 -9
- package/src/core/tools/render-utils.ts +241 -0
- package/src/core/tools/renderers.ts +40 -828
- package/src/core/tools/review.ts +26 -7
- package/src/core/tools/rulebook.ts +3 -1
- package/src/core/tools/task/index.ts +18 -3
- package/src/core/tools/task/render.ts +7 -2
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/truncate.ts +27 -1
- package/src/core/tools/web-fetch.ts +23 -15
- package/src/core/tools/web-search/index.ts +130 -45
- package/src/core/tools/web-search/providers/anthropic.ts +7 -2
- package/src/core/tools/web-search/providers/exa.ts +2 -1
- package/src/core/tools/web-search/providers/perplexity.ts +6 -1
- package/src/core/tools/web-search/render.ts +5 -0
- package/src/core/tools/web-search/types.ts +13 -0
- package/src/core/tools/write.ts +90 -0
- package/src/core/voice.ts +1 -1
- package/src/lib/worktree/constants.ts +6 -6
- package/src/main.ts +1 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +49 -0
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tool-execution.ts +93 -538
- package/src/modes/interactive/interactive-mode.ts +19 -7
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/gemini-image.md +5 -1
- package/src/prompts/tools/output.md +4 -0
- package/src/prompts/tools/web-fetch.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/utils/image-convert.ts +8 -2
- package/src/utils/image-magick.ts +247 -0
- package/src/utils/image-resize.ts +53 -13
package/src/core/tools/review.ts
CHANGED
|
@@ -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: ${(
|
|
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(
|
|
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}${
|
|
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:** ${
|
|
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(
|
|
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(
|
|
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)} (${(
|
|
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: ${
|
|
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${
|
|
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(
|
|
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) {
|
|
@@ -111,8 +111,8 @@ export function renderCall(args: TaskParams, theme: Theme): Component {
|
|
|
111
111
|
return new Text(`${label} ${theme.fg("accent", task.agent)}: ${theme.fg("muted", taskPreview)}`, 0, 0);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// Multiple tasks - show count and agent names
|
|
115
|
-
const agents = args.tasks.map((t) => t.agent).join(", ");
|
|
114
|
+
// Multiple tasks - show count and descriptions (or agent names as fallback)
|
|
115
|
+
const agents = args.tasks.map((t) => t.description?.trim() || t.agent).join(", ");
|
|
116
116
|
return new Text(
|
|
117
117
|
`${label} ${theme.fg("muted", `${args.tasks.length} agents: ${truncate(agents, 50, theme.format.ellipsis)}`)}`,
|
|
118
118
|
0,
|
|
@@ -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
|
+
};
|
|
@@ -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
|
-
|
|
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:
|
|
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: "
|
|
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
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
113
|
+
if (exaKey) providers.push("exa");
|
|
103
114
|
|
|
104
|
-
// Perplexity second priority
|
|
105
115
|
const perplexityKey = await findPerplexityKey();
|
|
106
|
-
if (perplexityKey)
|
|
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
|
-
|
|
109
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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;
|