Skip to content

Commit ab214e3

Browse files
uinstinctsestinj
andauthored
refactor(cli): throw tool error instead of returning and matching error strings (#8432)
* fix: prevent string error matching throwing errors - added ToolResultWithStatus * add throwing of errors in search code tool * throw errors in cli fetch tool * throw errors in writeFile tool * throw error in status tool * throw errors in listFiles tool * throw error in readfile tool * fix no matches for pattern searchCode * update tests * use continueerror writeFile --------- Co-authored-by: Nate <sestinj@gmail.com>
1 parent 1c7a816 commit ab214e3

File tree

10 files changed

+135
-98
lines changed

10 files changed

+135
-98
lines changed

extensions/cli/src/stream/handleToolCalls.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,26 +123,18 @@ export async function handleToolCalls(
123123
return true; // Signal early return needed
124124
}
125125

126-
// Convert tool results and add them to the chat history with per-result status
126+
// Convert tool results and add them to the chat history with status from execution
127127
toolResults.forEach((toolResult) => {
128128
const resultContent =
129129
typeof toolResult.content === "string" ? toolResult.content : "";
130130

131-
// Derive per-result status instead of applying batch-wide hasRejection
132-
let status: ToolStatus = "done";
133-
const lower = resultContent.toLowerCase();
134-
if (
135-
lower.includes("permission denied by user") ||
136-
lower.includes("cancelled due to previous tool rejection") ||
137-
lower.includes("canceled due to previous tool rejection")
138-
) {
139-
status = "canceled";
140-
} else if (
141-
lower.startsWith("error executing tool") ||
142-
lower.startsWith("error:")
143-
) {
144-
status = "errored" as ToolStatus;
145-
}
131+
// Use the status from the tool execution result instead of text matching
132+
const status = toolResult.status;
133+
134+
logger.debug("Tool result status", {
135+
status,
136+
toolCallId: toolResult.tool_call_id,
137+
});
146138

147139
if (useService) {
148140
chatHistorySvc.addToolResult(

extensions/cli/src/stream/streamChatResponse.helpers.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Helper functions extracted from streamChatResponse.ts to reduce file size
22

3+
import type { ToolStatus } from "core/index.js";
34
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";
45
import { ChatCompletionToolMessageParam } from "openai/resources/chat/completions.mjs";
56

@@ -27,6 +28,10 @@ import { logger } from "../util/logger.js";
2728

2829
import { StreamCallbacks } from "./streamChatResponse.types.js";
2930

31+
export interface ToolResultWithStatus extends ChatCompletionToolMessageParam {
32+
status: ToolStatus;
33+
}
34+
3035
// Helper function to handle permission denied
3136
export function handlePermissionDenied(
3237
toolCall: PreprocessedToolCall,
@@ -389,15 +394,15 @@ export async function preprocessStreamedToolCalls(
389394
* Executes preprocessed tool calls, handling permissions and results
390395
* @param preprocessedCalls - The preprocessed tool calls ready for execution
391396
* @param callbacks - Optional callbacks for notifying of events
392-
* @returns - Chat history entries with tool results
397+
* @returns - Chat history entries with tool results and status information
393398
*/
394399
export async function executeStreamedToolCalls(
395400
preprocessedCalls: PreprocessedToolCall[],
396401
callbacks?: StreamCallbacks,
397402
isHeadless?: boolean,
398403
): Promise<{
399404
hasRejection: boolean;
400-
chatHistoryEntries: ChatCompletionToolMessageParam[];
405+
chatHistoryEntries: ToolResultWithStatus[];
401406
}> {
402407
// Strategy: queue permissions (preserve order), then run approved tools in parallel.
403408
// If any permission is rejected, cancel the remaining tools in this batch.
@@ -408,7 +413,7 @@ export async function executeStreamedToolCalls(
408413
call,
409414
}));
410415

411-
const entriesByIndex = new Map<number, ChatCompletionToolMessageParam>();
416+
const entriesByIndex = new Map<number, ToolResultWithStatus>();
412417
const execPromises: Promise<void>[] = [];
413418

414419
let hasRejection = false;
@@ -439,17 +444,18 @@ export async function executeStreamedToolCalls(
439444
);
440445

441446
if (!permissionResult.approved) {
442-
// Permission denied: record and mark rejection
447+
// Permission denied: create entry with canceled status
443448
const denialReason = permissionResult.denialReason || "user";
444449
const deniedMessage =
445450
denialReason === "policy"
446451
? `Command blocked by security policy`
447452
: `Permission denied by user`;
448453

449-
const deniedEntry: ChatCompletionToolMessageParam = {
454+
const deniedEntry: ToolResultWithStatus = {
450455
role: "tool",
451456
tool_call_id: call.id,
452457
content: deniedMessage,
458+
status: "canceled",
453459
};
454460
entriesByIndex.set(index, deniedEntry);
455461
callbacks?.onToolResult?.(
@@ -484,10 +490,11 @@ export async function executeStreamedToolCalls(
484490
arguments: call.arguments,
485491
});
486492
const toolResult = await executeToolCall(call);
487-
const entry: ChatCompletionToolMessageParam = {
493+
const entry: ToolResultWithStatus = {
488494
role: "tool",
489495
tool_call_id: call.id,
490496
content: toolResult,
497+
status: "done",
491498
};
492499
entriesByIndex.set(index, entry);
493500
callbacks?.onToolResult?.(toolResult, call.name, "done");
@@ -511,6 +518,7 @@ export async function executeStreamedToolCalls(
511518
role: "tool",
512519
tool_call_id: call.id,
513520
content: errorMessage,
521+
status: "errored",
514522
});
515523
callbacks?.onToolError?.(errorMessage, call.name);
516524
// Immediate service update for UI feedback
@@ -536,6 +544,7 @@ export async function executeStreamedToolCalls(
536544
role: "tool",
537545
tool_call_id: call.id,
538546
content: errorMessage,
547+
status: "errored",
539548
});
540549
callbacks?.onToolError?.(errorMessage, call.name);
541550
// Treat permission errors like execution errors but do not stop the batch
@@ -552,9 +561,9 @@ export async function executeStreamedToolCalls(
552561
await Promise.all(execPromises);
553562

554563
// Assemble final entries in original order
555-
const chatHistoryEntries: ChatCompletionToolMessageParam[] = preprocessedCalls
564+
const chatHistoryEntries: ToolResultWithStatus[] = preprocessedCalls
556565
.map((_, index) => entriesByIndex.get(index))
557-
.filter((e): e is ChatCompletionToolMessageParam => !!e);
566+
.filter((e): e is ToolResultWithStatus => !!e);
558567

559568
return {
560569
hasRejection,

extensions/cli/src/tools/fetch.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,21 @@ describe("fetchTool", () => {
9696
expect(result).toBe("Content from page 1\n\nContent from page 2");
9797
});
9898

99-
it("should return error message when no content items returned", async () => {
99+
it("should throw error when no content items returned", async () => {
100100
mockFetchUrlContentImpl.mockResolvedValue([]);
101101

102-
const result = await fetchTool.run({ url: "https://example.com" });
103-
104-
expect(result).toBe(
105-
"Error: Could not fetch content from https://example.com",
102+
await expect(fetchTool.run({ url: "https://example.com" })).rejects.toThrow(
103+
"Could not fetch content from https://example.com",
106104
);
107105
});
108106

109-
it("should handle errors from core implementation", async () => {
107+
it("should throw errors from core implementation", async () => {
110108
const error = new Error("Network error");
111109
mockFetchUrlContentImpl.mockRejectedValue(error);
112110

113-
const result = await fetchTool.run({ url: "https://example.com" });
114-
115-
expect(result).toBe("Error: Network error");
111+
await expect(fetchTool.run({ url: "https://example.com" })).rejects.toThrow(
112+
"Error: Network error",
113+
);
116114
});
117115

118116
it("should call fetchUrlContentImpl with correct arguments", async () => {

extensions/cli/src/tools/fetch.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ContextItem } from "core/index.js";
22
import { fetchUrlContentImpl } from "core/tools/implementations/fetchUrlContent.js";
3+
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";
34

45
import { Tool } from "./types.js";
56

@@ -46,7 +47,10 @@ export const fetchTool: Tool = {
4647
console.error = originalConsoleError;
4748

4849
if (contextItems.length === 0) {
49-
return `Error: Could not fetch content from ${url}`;
50+
throw new ContinueError(
51+
ContinueErrorReason.Unspecified,
52+
`Could not fetch content from ${url}`,
53+
);
5054
}
5155

5256
// Format the results for CLI display
@@ -59,7 +63,12 @@ export const fetchTool: Tool = {
5963
})
6064
.join("\n\n");
6165
} catch (error) {
62-
return `Error: ${error instanceof Error ? error.message : String(error)}`;
66+
if (error instanceof ContinueError) {
67+
throw error;
68+
}
69+
throw new Error(
70+
`Error: ${error instanceof Error ? error.message : String(error)}`,
71+
);
6372
}
6473
},
6574
};

extensions/cli/src/tools/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export async function executeToolCall(
228228
errorReason,
229229
});
230230

231-
return `Error executing tool "${toolCall.name}": ${errorMessage}`;
231+
throw error;
232232
}
233233
}
234234

extensions/cli/src/tools/listFiles.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ export const listFilesTool: Tool = {
6161

6262
return `Files in ${args.dirpath}:\n${fileDetails.join("\n")}`;
6363
} catch (error) {
64-
return `Error listing files: ${
65-
error instanceof Error ? error.message : String(error)
66-
}`;
64+
throw new Error(
65+
`Error listing files: ${
66+
error instanceof Error ? error.message : String(error)
67+
}`,
68+
);
6769
}
6870
},
6971
};

extensions/cli/src/tools/readFile.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "fs";
22

33
import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js";
4+
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";
45

56
import { formatToolArgument } from "./formatters.js";
67
import { Tool } from "./types.js";
@@ -51,7 +52,10 @@ export const readFileTool: Tool = {
5152
}
5253

5354
if (!fs.existsSync(filepath)) {
54-
return `Error: File does not exist: ${filepath}`;
55+
throw new ContinueError(
56+
ContinueErrorReason.Unspecified,
57+
`File does not exist: ${filepath}`,
58+
);
5559
}
5660
const realPath = fs.realpathSync(filepath);
5761
const content = fs.readFileSync(realPath, "utf-8");
@@ -66,9 +70,14 @@ export const readFileTool: Tool = {
6670

6771
return `Content of ${filepath}:\n${content}`;
6872
} catch (error) {
69-
return `Error reading file: ${
70-
error instanceof Error ? error.message : String(error)
71-
}`;
73+
if (error instanceof ContinueError) {
74+
throw error;
75+
}
76+
throw new Error(
77+
`Error reading file: ${
78+
error instanceof Error ? error.message : String(error)
79+
}`,
80+
);
7281
}
7382
},
7483
};

extensions/cli/src/tools/searchCode.ts

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as child_process from "child_process";
22
import * as fs from "fs";
33
import * as util from "util";
44

5+
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";
6+
57
import { Tool } from "./types.js";
68

79
const execPromise = util.promisify(child_process.exec);
@@ -53,64 +55,66 @@ export const searchCodeTool: Tool = {
5355
path?: string;
5456
file_pattern?: string;
5557
}): Promise<string> => {
56-
try {
57-
const searchPath = args.path || process.cwd();
58-
if (!fs.existsSync(searchPath)) {
59-
return `Error: Path does not exist: ${searchPath}`;
60-
}
58+
const searchPath = args.path || process.cwd();
59+
if (!fs.existsSync(searchPath)) {
60+
throw new ContinueError(
61+
ContinueErrorReason.Unspecified,
62+
`Path does not exist: ${searchPath}`,
63+
);
64+
}
6165

62-
let command = `rg --line-number --with-filename --color never "${args.pattern}"`;
66+
let command = `rg --line-number --with-filename --color never "${args.pattern}"`;
6367

64-
if (args.file_pattern) {
65-
command += ` -g "${args.file_pattern}"`;
66-
}
68+
if (args.file_pattern) {
69+
command += ` -g "${args.file_pattern}"`;
70+
}
6771

68-
command += ` "${searchPath}"`;
69-
try {
70-
const { stdout, stderr } = await execPromise(command);
72+
command += ` "${searchPath}"`;
73+
try {
74+
const { stdout, stderr } = await execPromise(command);
7175

72-
if (stderr) {
73-
return `Warning during search: ${stderr}\n\n${stdout}`;
74-
}
76+
if (stderr) {
77+
return `Warning during search: ${stderr}\n\n${stdout}`;
78+
}
7579

76-
if (!stdout.trim()) {
77-
return `No matches found for pattern "${args.pattern}"${
78-
args.file_pattern ? ` in files matching "${args.file_pattern}"` : ""
79-
}.`;
80-
}
80+
if (!stdout.trim()) {
81+
return `No matches found for pattern "${args.pattern}"${
82+
args.file_pattern ? ` in files matching "${args.file_pattern}"` : ""
83+
}.`;
84+
}
8185

82-
// Split the results into lines and limit the number of results
83-
const lines = stdout.split("\n");
84-
const truncated = lines.length > DEFAULT_MAX_RESULTS;
85-
const limitedLines = lines.slice(0, DEFAULT_MAX_RESULTS);
86-
const resultText = limitedLines.join("\n");
86+
// Split the results into lines and limit the number of results
87+
const lines = stdout.split("\n");
88+
const truncated = lines.length > DEFAULT_MAX_RESULTS;
89+
const limitedLines = lines.slice(0, DEFAULT_MAX_RESULTS);
90+
const resultText = limitedLines.join("\n");
8791

88-
const truncationMessage = truncated
89-
? `\n\n[Results truncated: showing ${DEFAULT_MAX_RESULTS} of ${lines.length} matches]`
90-
: "";
92+
const truncationMessage = truncated
93+
? `\n\n[Results truncated: showing ${DEFAULT_MAX_RESULTS} of ${lines.length} matches]`
94+
: "";
9195

92-
return `Search results for pattern "${args.pattern}"${
96+
return `Search results for pattern "${args.pattern}"${
97+
args.file_pattern ? ` in files matching "${args.file_pattern}"` : ""
98+
}:\n\n${resultText}${truncationMessage}`;
99+
} catch (error: any) {
100+
if (error instanceof ContinueError) {
101+
throw error;
102+
}
103+
if (error.code === 1) {
104+
return `No matches found for pattern "${args.pattern}"${
93105
args.file_pattern ? ` in files matching "${args.file_pattern}"` : ""
94-
}:\n\n${resultText}${truncationMessage}`;
95-
} catch (error: any) {
96-
if (error.code === 1) {
97-
return `No matches found for pattern "${args.pattern}"${
98-
args.file_pattern ? ` in files matching "${args.file_pattern}"` : ""
99-
}.`;
100-
}
101-
if (error instanceof Error) {
102-
if (error.message.includes("command not found")) {
103-
return `Error: ripgrep is not installed.`;
104-
}
106+
}.`;
107+
}
108+
if (error instanceof Error) {
109+
if (error.message.includes("command not found")) {
110+
throw new Error(`ripgrep is not installed.`);
105111
}
106-
return `Error executing ripgrep: ${
107-
error instanceof Error ? error.message : String(error)
108-
}`;
109112
}
110-
} catch (error) {
111-
return `Error searching code: ${
112-
error instanceof Error ? error.message : String(error)
113-
}`;
113+
throw new Error(
114+
`Error executing ripgrep: ${
115+
error instanceof Error ? error.message : String(error)
116+
}`,
117+
);
114118
}
115119
},
116120
};

0 commit comments

Comments
 (0)