Skip to content
5 changes: 0 additions & 5 deletions core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,6 @@ declare global {
replacement: string;
}
export interface ContinueError {
title: string;
message: string;
}
export interface CompletionOptions extends BaseCompletionOptions {
model: string;
}
Expand Down
5 changes: 0 additions & 5 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,6 @@ export interface FileEdit {
replacement: string;
}

export interface ContinueError {
title: string;
message: string;
}

export interface CompletionOptions extends BaseCompletionOptions {
model: string;
}
Expand Down
4 changes: 3 additions & 1 deletion core/indexing/ignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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.`,
);
}
Expand Down
39 changes: 39 additions & 0 deletions core/util/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
2 changes: 1 addition & 1 deletion extensions/cli/spec/otlp-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
15 changes: 15 additions & 0 deletions extensions/cli/src/stream/streamChatResponse.helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions extensions/cli/src/telemetry/telemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
43 changes: 34 additions & 9 deletions extensions/cli/src/tools/edit.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.`,
);
}
Expand All @@ -96,15 +110,21 @@ 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
const oldContent = fs.readFileSync(resolvedPath, "utf-8");

// 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;
Expand All @@ -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.`,
);
}
Expand Down Expand Up @@ -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)
}`,
Expand Down
20 changes: 20 additions & 0 deletions extensions/cli/src/tools/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
// @ts-ignore
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";

import { posthogService } from "src/telemetry/posthogService.js";

import {
getServiceSync,
MCPServiceState,
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;
}
Expand Down
2 changes: 1 addition & 1 deletion extensions/cli/src/tools/multiEdit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down
Loading
Loading