diff --git a/core/config/types.ts b/core/config/types.ts index 0b76419df6d..d50478f5488 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -280,11 +280,6 @@ declare global { replacement: string; } - export interface ContinueError { - title: string; - message: string; - } - export interface CompletionOptions extends BaseCompletionOptions { model: string; } diff --git a/core/index.d.ts b/core/index.d.ts index 2d9b9b7ba7b..70d5d8d565e 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -310,11 +310,6 @@ export interface FileEdit { replacement: string; } -export interface ContinueError { - title: string; - message: string; -} - export interface CompletionOptions extends BaseCompletionOptions { model: string; } diff --git a/core/indexing/ignore.ts b/core/indexing/ignore.ts index 2b231a5b18d..96b08f6e1bc 100644 --- a/core/indexing/ignore.ts +++ b/core/indexing/ignore.ts @@ -2,6 +2,7 @@ import ignore from "ignore"; import path from "path"; import { fileURLToPath } from "url"; +import { ContinueError, ContinueErrorReason } from "../util/errors"; // Security-focused ignore patterns - these should always be excluded for security reasons export const DEFAULT_SECURITY_IGNORE_FILETYPES = [ @@ -259,7 +260,8 @@ export function isSecurityConcern(filePathOrUri: string) { export function throwIfFileIsSecurityConcern(filepath: string) { if (isSecurityConcern(filepath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FileIsSecurityConcern, `Reading or Editing ${filepath} is not allowed because it is a security concern. Do not attempt to read or edit this file in any way.`, ); } diff --git a/core/util/errors.ts b/core/util/errors.ts index f80fa6b70df..90539f8f31e 100644 --- a/core/util/errors.ts +++ b/core/util/errors.ts @@ -10,3 +10,42 @@ export function getRootCause(err: any): any { } return err; } + +export class ContinueError extends Error { + reason: ContinueErrorReason; + + constructor(reason: ContinueErrorReason, message?: string) { + super(message); + this.reason = reason; + } +} + +export enum ContinueErrorReason { + // Find and Replace validation errors + FindAndReplaceIdenticalStrings = "find_and_replace_old_and_new_strings_identical", + FindAndReplaceMissingOldString = "find_and_replace_missing_old_string", + FindAndReplaceMissingNewString = "find_and_replace_missing_new_string", + FindAndReplaceOldStringNotFound = "find_and_replace_old_string_not_found", + FindAndReplaceMultipleOccurrences = "find_and_replace_multiple_occurrences", + FindAndReplaceMissingFilepath = "find_and_replace_missing_filepath", + + // Multi-edit + MultiEditEditsArrayRequired = "multi_edit_edits_array_required", + MultiEditEditsArrayEmpty = "multi_edit_edits_array_empty", + MultiEditSubsequentEditsOnCreation = "multi_edit_subsequent_edits_on_creation", + MultiEditEmptyOldStringNotFirst = "multi_edit_empty_old_string_not_first", + + // General Edit + EditToolFileNotRead = "edit_tool_file_not_yet_read", + + // General File + FileAlreadyExists = "file_already_exists", + FileNotFound = "file_not_found", + FileWriteError = "file_write_error", + FileIsSecurityConcern = "file_is_security_concern", + ParentDirectoryNotFound = "parent_directory_not_found", + + // Other + Unspecified = "unspecified", + Unknown = "unknown", +} diff --git a/extensions/cli/spec/otlp-metrics.md b/extensions/cli/spec/otlp-metrics.md index f1398558f09..ab394af7f2e 100644 --- a/extensions/cli/spec/otlp-metrics.md +++ b/extensions/cli/spec/otlp-metrics.md @@ -188,7 +188,7 @@ All metrics and events share these standard attributes: ### ✅ Tool Result Event -**Event Name:** `continue_cli_tool_result` +**Event Name:** `tool_result` **Attributes:** diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index b82f63212cf..608cb02b6e2 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -1,5 +1,6 @@ // Helper functions extracted from streamChatResponse.ts to reduce file size +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { ChatCompletionToolMessageParam } from "openai/resources/chat/completions.mjs"; import { checkToolPermission } from "../permissions/permissionChecker.js"; @@ -354,6 +355,11 @@ export async function preprocessStreamedToolCalls( // Notify the UI about the tool start, even though it failed callbacks?.onToolStart?.(toolCall.name, toolCall.arguments); + const errorReason = + error instanceof ContinueError + ? error.reason + : ContinueErrorReason.Unknown; + const errorMessage = error instanceof Error ? error.message : String(error); @@ -368,6 +374,15 @@ export async function preprocessStreamedToolCalls( success: false, durationMs: duration, error: errorMessage, + errorReason, + // modelName, TODO + }); + posthogService.capture("tool_call_outcome", { + succeeded: false, + toolName: toolCall.name, + errorReason, + duration_ms: duration, + // model: options.modelName, TODO }); // Add error to chat history diff --git a/extensions/cli/src/telemetry/telemetryService.ts b/extensions/cli/src/telemetry/telemetryService.ts index 263f21ac7a7..e9fbca2c6a8 100644 --- a/extensions/cli/src/telemetry/telemetryService.ts +++ b/extensions/cli/src/telemetry/telemetryService.ts @@ -498,9 +498,11 @@ class TelemetryService { success: boolean; durationMs: number; error?: string; + errorReason?: string; decision?: "accept" | "reject"; source?: string; toolParameters?: string; + // modelName: string; }) { if (!this.isEnabled()) return; @@ -515,8 +517,6 @@ class TelemetryService { if (options.error) attributes.error = options.error; if (options.decision) attributes.decision = options.decision; if (options.source) attributes.source = options.source; - if (options.toolParameters) - attributes.tool_parameters = options.toolParameters; // TODO: Implement OTLP logs export logger.debug("Tool result event", attributes); diff --git a/extensions/cli/src/tools/edit.ts b/extensions/cli/src/tools/edit.ts index 545d1426987..ce183efcde4 100644 --- a/extensions/cli/src/tools/edit.ts +++ b/extensions/cli/src/tools/edit.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js"; +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { @@ -70,24 +71,37 @@ WARNINGS: // Validate arguments if (!file_path) { - throw new Error("file_path is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "file_path is required", + ); } if (!old_string) { - throw new Error("old_string is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingOldString, + "old_string is required", + ); } if (new_string === undefined) { - throw new Error("new_string is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingNewString, + "new_string is required", + ); } if (old_string === new_string) { - throw new Error("old_string and new_string must be different"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceIdenticalStrings, + "old_string and new_string must be different", + ); } const resolvedPath = fs.realpathSync(file_path); // Check if file has been read if (!readFilesSet.has(resolvedPath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.EditToolFileNotRead, `You must use the ${readFileTool.name} tool to read ${file_path} before editing it.`, ); } @@ -96,7 +110,10 @@ WARNINGS: // Check if file exists if (!fs.existsSync(resolvedPath)) { - throw new Error(`File ${file_path} does not exist`); + throw new ContinueError( + ContinueErrorReason.FileNotFound, + `File ${file_path} does not exist`, + ); } // Read current file content @@ -104,7 +121,10 @@ WARNINGS: // Check if old_string exists in the file if (!oldContent.includes(old_string)) { - throw new Error(`String not found in file: ${old_string}`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceOldStringNotFound, + `String not found in file: ${old_string}`, + ); } let newContent: string; @@ -116,7 +136,8 @@ WARNINGS: // Replace only the first occurrence const occurrences = oldContent.split(old_string).length - 1; if (occurrences > 1) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMultipleOccurrences, `String "${old_string}" appears ${occurrences} times in the file. Either provide a more specific string with surrounding context to make it unique, or use replace_all=true to replace all occurrences.`, ); } @@ -179,7 +200,11 @@ WARNINGS: return `Successfully edited ${args.resolvedPath}\nDiff:\n${diff}`; } catch (error) { - throw new Error( + if (error instanceof ContinueError) { + throw error; + } + throw new ContinueError( + ContinueErrorReason.FileWriteError, `Error: failed to edit ${args.resolvedPath}: ${ error instanceof Error ? error.message : String(error) }`, diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index cc51f0e015d..8f47c9b4a84 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -1,4 +1,8 @@ // @ts-ignore +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; + +import { posthogService } from "src/telemetry/posthogService.js"; + import { getServiceSync, MCPServiceState, @@ -212,6 +216,11 @@ export async function executeToolCall( durationMs: duration, toolParameters: JSON.stringify(toolCall.arguments), }); + posthogService.capture("tool_call_outcome", { + succeeded: true, + toolName: toolCall.name, + duration_ms: duration, + }); logger.debug("Tool execution completed", { toolName: toolCall.name, @@ -222,14 +231,25 @@ export async function executeToolCall( } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); + const errorReason = + error instanceof ContinueError + ? error.reason + : ContinueErrorReason.Unknown; telemetryService.logToolResult({ toolName: toolCall.name, success: false, durationMs: duration, error: errorMessage, + errorReason, toolParameters: JSON.stringify(toolCall.arguments), }); + posthogService.capture("tool_call_outcome", { + succeeded: false, + toolName: toolCall.name, + duration_ms: duration, + errorReason, + }); return `Error executing tool "${toolCall.name}": ${errorMessage}`; } diff --git a/extensions/cli/src/tools/multiEdit.test.ts b/extensions/cli/src/tools/multiEdit.test.ts index 597cab76722..af269a80cf9 100644 --- a/extensions/cli/src/tools/multiEdit.test.ts +++ b/extensions/cli/src/tools/multiEdit.test.ts @@ -90,7 +90,7 @@ describe("multiEditTool", () => { }; await expect(multiEditTool.preprocess!(args)).rejects.toThrow( - "edits array is required and must contain at least one edit", + "edits array must contain at least one edit", ); }); diff --git a/extensions/cli/src/tools/multiEdit.ts b/extensions/cli/src/tools/multiEdit.ts index 351a37afea7..488f21ee25c 100644 --- a/extensions/cli/src/tools/multiEdit.ts +++ b/extensions/cli/src/tools/multiEdit.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js"; +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { @@ -33,11 +34,21 @@ function validateMultiEditArgs(args: any): { const { file_path, edits } = args as MultiEditArgs; if (!file_path) { - throw new Error("file_path is required"); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingFilepath, + "file_path is required", + ); + } + if (!edits || !Array.isArray(edits)) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayRequired, + "edits array is required", + ); } - if (!edits || !Array.isArray(edits) || edits.length === 0) { - throw new Error( - "edits array is required and must contain at least one edit", + if (edits.length === 0) { + throw new ContinueError( + ContinueErrorReason.MultiEditEditsArrayEmpty, + "edits array must contain at least one edit", ); } @@ -55,13 +66,20 @@ function validateEdits(edits: EditOperation[]): void { for (let i = 0; i < edits.length; i++) { const edit = edits[i]; if (!edit.old_string && edit.old_string !== "") { - throw new Error(`Edit ${i + 1}: old_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingOldString, + `Edit ${i + 1}: old_string is required`, + ); } if (edit.new_string === undefined) { - throw new Error(`Edit ${i + 1}: new_string is required`); + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMissingNewString, + `Edit ${i + 1}: new_string is required`, + ); } if (edit.old_string === edit.new_string) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceIdenticalStrings, `Edit ${i + 1}: old_string and new_string must be different`, ); } @@ -76,18 +94,25 @@ function validateFileAccess( // For new file creation, check if parent directory exists const parentDir = path.dirname(resolvedPath); if (parentDir && !fs.existsSync(parentDir)) { - throw new Error(`Parent directory does not exist: ${parentDir}`); + throw new ContinueError( + ContinueErrorReason.ParentDirectoryNotFound, + `Parent directory does not exist: ${parentDir}`, + ); } } else { // For existing files, check if file has been read // Check with the original path first, then with absolute path if (!readFilesSet.has(resolvedPath)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.EditToolFileNotRead, `You must use the ${readFileTool.name} tool to read ${resolvedPath} before editing it.`, ); } if (!fs.existsSync(resolvedPath)) { - throw new Error(`File ${resolvedPath} does not exist`); + throw new ContinueError( + ContinueErrorReason.FileNotFound, + `File ${resolvedPath} does not exist`, + ); } } } @@ -107,7 +132,8 @@ function applyEdit( // Check if old_string exists in current content if (!content.includes(old_string)) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceOldStringNotFound, `Edit ${editIndex + 1}: String not found in file: "${old_string}"`, ); } @@ -119,7 +145,8 @@ function applyEdit( // Replace only the first occurrence, but check for uniqueness const occurrences = content.split(old_string).length - 1; if (occurrences > 1) { - throw new Error( + throw new ContinueError( + ContinueErrorReason.FindAndReplaceMultipleOccurrences, `Edit ${editIndex + 1}: String "${old_string}" appears ${occurrences} times in the file. Either provide a more specific string with surrounding context to make it unique, or use replace_all=true to replace all occurrences.`, ); } @@ -275,7 +302,11 @@ WARNINGS: const action = args.isCreatingNewFile ? "created" : "edited"; return `Successfully ${action} ${args.file_path} with ${args.editCount} edit${args.editCount === 1 ? "" : "s"}\nDiff:\n${diff}`; } catch (error) { - throw new Error( + if (error instanceof ContinueError) { + throw error; + } + throw new ContinueError( + ContinueErrorReason.FileWriteError, `Error: failed to edit ${args.file_path}: ${ error instanceof Error ? error.message : String(error) }`, diff --git a/gui/src/components/gui/ErrorStepContainer.tsx b/gui/src/components/gui/ErrorStepContainer.tsx deleted file mode 100644 index 80526132ced..00000000000 --- a/gui/src/components/gui/ErrorStepContainer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { MinusCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { ContinueError } from "core"; -import styled from "styled-components"; -import { defaultBorderRadius, vscBackground } from ".."; -import HeaderButtonWithToolTip from "./HeaderButtonWithToolTip"; - -const Div = styled.div` - padding: 8px; - background-color: #ff000011; - border-radius: ${defaultBorderRadius}; - border: 1px solid #cc0000; - margin: 8px; -`; - -interface ErrorStepContainerProps { - error: ContinueError; - onClose: () => void; - onDelete: () => void; -} - -function ErrorStepContainer(props: ErrorStepContainerProps) { - return ( -
- {props.error.message as string} --