Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ


const cmdCap = await this._waitForCommandDetectionCapability(terminal)
// if (!cmdCap) throw new Error(`There was an error using the terminal: CommandDetection capability did not mount yet. Please try again in a few seconds or report this to the Void team.`)
if (!cmdCap) {
throw new Error(`Terminal CommandDetection capability did not mount within 10s. The command output cannot be detected reliably. Try again in a few seconds — if this persists, the shell integration may not be enabled.`)
}

// Prefer the structured command-detection capability when available

Expand Down
183 changes: 181 additions & 2 deletions src/vs/workbench/contrib/cortexide/browser/toolsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree
import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'
import { timeout } from '../../../../base/common/async.js'
import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js'
import { MAX_CHILDREN_URIs_PAGE, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_BG_COMMAND_TIME, MAX_TERMINAL_INACTIVE_TIME } from '../common/prompt/prompts.js'
import { DIVIDER, FINAL, MAX_CHILDREN_URIs_PAGE, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_BG_COMMAND_TIME, MAX_TERMINAL_INACTIVE_TIME, ORIGINAL } from '../common/prompt/prompts.js'
import { ICortexideSettingsService } from '../common/cortexideSettingsService.js'
import { generateUuid } from '../../../../base/common/uuid.js'
import { INotificationService } from '../../../../platform/notification/common/notification.js'
Expand Down Expand Up @@ -200,6 +200,10 @@ export class ToolsService implements IToolsService {
private readonly _browseCache = new LRUCache<string, { content: string, title?: string, url: string, metadata?: { publishedDate?: string }, timestamp: number }>(100);
private readonly _cacheTTL = 60 * 60 * 1000; // 1 hour
private readonly _offlineGate: OfflinePrivacyGate;
private _latestTodos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }> = [];
public getLatestTodos(): ReadonlyArray<{ content: string; status: 'pending' | 'in_progress' | 'completed' }> {
return this._latestTodos;
}

constructor(
@IFileService fileService: IFileService,
Expand Down Expand Up @@ -254,7 +258,7 @@ export class ToolsService implements IToolsService {
search_pathnames_only: (params: RawToolParamsObj) => {
const {
query: queryUnknown,
search_in_folder: includeUnknown,
include_pattern: includeUnknown,
page_number: pageNumberUnknown
} = params

Expand Down Expand Up @@ -496,6 +500,76 @@ export class ToolsService implements IToolsService {
return { uri };
},

multi_edit: (params: RawToolParamsObj) => {
const { uri: uriUnknown, edits: editsUnknown } = params;
const uri = validateURI(uriUnknown, workspaceContextService, true);

let editsRaw: unknown = editsUnknown;
if (typeof editsUnknown === 'string') {
try { editsRaw = JSON.parse(editsUnknown); }
catch (e) { throw new Error(`multi_edit: edits must be a JSON array. Parse error: ${e}`); }
}
if (!Array.isArray(editsRaw)) {
throw new Error(`multi_edit: edits must be an array of { old_string, new_string, replace_all? } objects, got ${typeof editsRaw}.`);
}
if (editsRaw.length === 0) {
throw new Error(`multi_edit: edits array must contain at least one entry.`);
}
if (editsRaw.length > 50) {
throw new Error(`multi_edit: edits array capped at 50 entries per call (got ${editsRaw.length}). Split into multiple calls or use rewrite_file.`);
}

const edits = editsRaw.map((e: unknown, i: number) => {
if (typeof e !== 'object' || e === null) throw new Error(`multi_edit: edits[${i}] must be an object.`);
const obj = e as Record<string, unknown>;
const oldString = validateStr(`edits[${i}].old_string`, obj.old_string ?? obj.oldString);
const newString = validateStr(`edits[${i}].new_string`, obj.new_string ?? obj.newString);
if (oldString === newString) throw new Error(`multi_edit: edits[${i}] old_string and new_string are identical (no-op).`);
if (oldString === '') throw new Error(`multi_edit: edits[${i}] old_string is empty — use rewrite_file or create_file_or_folder for new content.`);
const replaceAll = validateBoolean(obj.replace_all ?? obj.replaceAll, { default: false });
return { oldString, newString, replaceAll };
});

return { uri, edits };
},

glob_files: (params: RawToolParamsObj) => {
const { pattern: patternUnknown, limit: limitUnknown } = params;
const pattern = validateStr('pattern', patternUnknown);
if (!pattern.trim()) throw new Error('glob_files: pattern cannot be empty.');
const limitRaw = validateNumber(limitUnknown, { default: 100 });
const limit = Math.max(1, Math.min(limitRaw ?? 100, 1000));
return { pattern, limit };
},

todo_write: (params: RawToolParamsObj) => {
const { todos: todosUnknown } = params;
let todosRaw: unknown = todosUnknown;
if (typeof todosUnknown === 'string') {
try { todosRaw = JSON.parse(todosUnknown); }
catch (e) { throw new Error(`todo_write: todos must be a JSON array. Parse error: ${e}`); }
}
if (!Array.isArray(todosRaw)) {
throw new Error(`todo_write: todos must be an array of { content, status } objects, got ${typeof todosRaw}.`);
}
if (todosRaw.length > 50) {
throw new Error(`todo_write: todos array capped at 50 entries (got ${todosRaw.length}).`);
}
const validStatuses = new Set(['pending', 'in_progress', 'completed']);
let inProgressCount = 0;
const todos = todosRaw.map((t: unknown, i: number) => {
if (typeof t !== 'object' || t === null) throw new Error(`todo_write: todos[${i}] must be an object.`);
const obj = t as Record<string, unknown>;
const content = validateStr(`todos[${i}].content`, obj.content);
const status = validateStr(`todos[${i}].status`, obj.status);
if (!validStatuses.has(status)) throw new Error(`todo_write: todos[${i}].status must be one of pending/in_progress/completed, got "${status}".`);
if (status === 'in_progress') inProgressCount++;
return { content, status: status as 'pending' | 'in_progress' | 'completed' };
});
if (inProgressCount > 1) throw new Error(`todo_write: only one task may be in_progress at a time (got ${inProgressCount}).`);
return { todos };
},

attempt_completion: (params: RawToolParamsObj) => {
const { result: resultUnknown, command: commandUnknown } = params;
const result = validateStr('result', resultUnknown);
Expand Down Expand Up @@ -1566,6 +1640,88 @@ export class ToolsService implements IToolsService {
return { result: { diagnostics } };
},

multi_edit: async ({ uri, edits }) => {
await cortexideModelService.initializeModel(uri);
const { model } = await cortexideModelService.getModelSafe(uri);
if (model === null) throw new Error(`File does not exist: ${uri.fsPath}`);

const streamState = this.commandBarService.getStreamState(uri);
if (streamState === 'streaming') {
throw new Error(`Cannot edit file ${uri.fsPath}: another operation is currently streaming changes to this file.`);
}

// Pre-check: every old_string must appear at least once in the file content.
// This makes the operation atomic — if any block won't match, we don't apply ANY of them.
const content = model.getValue(EndOfLinePreference.LF);
for (let i = 0; i < edits.length; i++) {
if (!content.includes(edits[i].oldString)) {
const preview = edits[i].oldString.length > 80
? edits[i].oldString.slice(0, 80) + '…'
: edits[i].oldString;
throw new Error(`multi_edit: edits[${i}] old_string not found in ${uri.fsPath}. No edits applied. Search snippet: ${JSON.stringify(preview)}`);
}
}

// Build a single SEARCH/REPLACE-blocks string from all edits. For replaceAll=true edits,
// emit one block per occurrence so the existing apply engine handles each match.
const blocks: string[] = [];
for (const edit of edits) {
if (edit.replaceAll) {
const occurrences = content.split(edit.oldString).length - 1;
for (let n = 0; n < occurrences; n++) {
blocks.push(`${ORIGINAL}\n${edit.oldString}\n${DIVIDER}\n${edit.newString}\n${FINAL}`);
}
} else {
blocks.push(`${ORIGINAL}\n${edit.oldString}\n${DIVIDER}\n${edit.newString}\n${FINAL}`);
}
}
const searchReplaceBlocks = blocks.join('\n\n');

await editCodeService.callBeforeApplyOrEdit(uri);
editCodeService.instantlyApplySearchReplaceBlocks({ uri, searchReplaceBlocks });

const appliedCount = edits.length;
const lintErrorsPromise = Promise.resolve().then(async () => {
await timeout(2000);
const { lintErrors } = this._getLintErrors(uri);
return { lintErrors, appliedCount };
});
return { result: lintErrorsPromise };
},

glob_files: async ({ pattern, limit }) => {
const folders = workspaceContextService.getWorkspace().folders.map(f => f.uri);
const query = queryBuilder.file(folders, {
filePattern: pattern,
sortByScore: false,
});
const data = await searchService.fileSearch(query, CancellationToken.None);
const allResults = data.results.slice(0, Math.max(limit * 5, 500)); // cap pre-stat work

// Fetch stat for each result to get mtime; ignore failures (file may have moved)
const stated: Array<{ uri: URI; mtime: number; size: number }> = [];
for (const r of allResults) {
try {
const stat = await fileService.stat(r.resource);
stated.push({ uri: r.resource, mtime: stat.mtime ?? 0, size: stat.size ?? 0 });
} catch { /* file may have moved between search and stat — skip */ }
}

// Sort newest first by mtime
stated.sort((a, b) => b.mtime - a.mtime);
const truncated = stated.length > limit;
const files = stated.slice(0, limit);
return { result: { files, truncated } };
},

todo_write: async ({ todos }) => {
// Latest list replaces any prior list for this session.
// Storage in chatThreadService thread state is a follow-up; for now the
// echoed result is what the model and (eventually) the UI consume.
this._latestTodos = todos;
return { result: { acknowledged: true as const, count: todos.length } };
},

attempt_completion: async ({ result, command }) => {
// No side effects — signals the agent loop to terminate.
return { result: { acknowledged: true as const } };
Expand Down Expand Up @@ -1787,6 +1943,29 @@ export class ToolsService implements IToolsService {
return `Diagnostics for ${scope} — ${result.diagnostics.length} issue(s):\n${lines.join('\n')}`;
},

multi_edit: (params, result) => {
const lintStr = result.lintErrors && result.lintErrors.length > 0
? `\n\nLint errors after applying ${result.appliedCount} edits:\n${stringifyLintErrors(result.lintErrors)}`
: `\n\n${result.appliedCount} edit(s) applied. No new lint errors.`;
return `${params.uri.fsPath}: applied ${result.appliedCount} atomic edit(s).${lintStr}`;
},

glob_files: (params, result) => {
if (result.files.length === 0) {
return `glob_files matched 0 files for pattern "${params.pattern}".`;
}
const lines = result.files.map(f => {
const date = new Date(f.mtime).toISOString().slice(0, 19).replace('T', ' ');
return `${date} ${f.size.toString().padStart(8)} ${f.uri.fsPath}`;
});
const truncStr = result.truncated ? `\n(truncated to ${params.limit} — increase \`limit\` for more)` : '';
return `glob_files matched ${result.files.length} file(s) for "${params.pattern}", newest first:\n${lines.join('\n')}${truncStr}`;
},

todo_write: (_params, result) => {
return `Recorded ${result.count} task(s).`;
},

attempt_completion: (params, _result) => {
const commandLine = params.command ? `\n\nVerification command: \`${params.command}\`` : '';
return `Task completed.\n\n${params.result}${commandLine}`;
Expand Down
36 changes: 32 additions & 4 deletions src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,6 @@ export const builtinTools: {
}
},

// pathname_search: {
// name: 'pathname_search',
// description: `Returns all pathnames that match a given \`find\`-style query over the entire workspace. ONLY searches file names. ONLY searches the current workspace. You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`,

search_pathnames_only: {
name: 'search_pathnames_only',
description: `Returns all pathnames that match a given query (searches ONLY file names). You should use this when looking for a file with a specific name or path.`,
Expand Down Expand Up @@ -453,6 +449,38 @@ export const builtinTools: {
}
},

// --- multi-block atomic edit ---

multi_edit: {
name: 'multi_edit',
description: `Apply multiple text replacements to a single file in one atomic operation. Pre-checks every old_string is found before applying anything; if any miss, no edits are applied. Prefer this over multiple edit_file calls when changing 2+ places in the same file.`,
params: {
...uriParam('file'),
edits: { description: 'Array of edits. Each item: { "old_string": "...", "new_string": "...", "replace_all": false }. old_string MUST match the file exactly (whitespace included). Set replace_all=true to replace every occurrence; default false replaces only the first.' },
},
},

// --- glob pattern file listing (mtime-sorted) ---

glob_files: {
name: 'glob_files',
description: `Returns file paths matching a glob pattern, sorted by modification time (newest first). Use this when you want files of a certain type or in a certain area, recently-changed-first — e.g. "src/**/*.ts" or "**/test_*.py". For substring filename matching, use search_pathnames_only instead.`,
params: {
pattern: { description: 'Glob pattern. Examples: "**/*.ts", "src/**/*.{tsx,ts}", "test/**/test_*.py".' },
limit: { description: 'Optional. Max files to return (default 100, max 1000).' },
},
},

// --- model-managed task list (per-session) ---

todo_write: {
name: 'todo_write',
description: `Record or update your task list for the current session. Use this at the START of a multi-step task to plan, and after EACH step to mark progress. Replaces the entire list each call — include all tasks every time. Status values: "pending", "in_progress" (only ONE at a time), "completed".`,
params: {
todos: { description: 'Array of { "content": "task description", "status": "pending" | "in_progress" | "completed" }. Order matters — list in execution order.' },
},
},

// --- explicit completion signal ---

attempt_completion: {
Expand Down
13 changes: 13 additions & 0 deletions src/vs/workbench/contrib/cortexide/common/toolsServiceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const approvalTypeOfBuiltinToolName: Partial<{ [T in BuiltinToolName]?: '
'delete_file_or_folder': 'edits',
'rewrite_file': 'edits',
'edit_file': 'edits',
'multi_edit': 'edits',
'run_command': 'terminal',
'run_nl_command': 'terminal',
'run_persistent_command': 'terminal',
Expand Down Expand Up @@ -81,6 +82,12 @@ export type BuiltinToolCallParams = {
// --- fast grep + workspace diagnostics ---
'grep_search': { query: string; includePattern: string | null; excludePattern: string | null; isRegex: boolean; caseSensitive: boolean },
'get_diagnostics': { uri: URI | null },
// --- multi-block atomic edit ---
'multi_edit': { uri: URI, edits: Array<{ oldString: string; newString: string; replaceAll: boolean }> },
// --- glob pattern file listing (mtime-sorted) ---
'glob_files': { pattern: string; limit: number },
// --- model-managed task list (per-session) ---
'todo_write': { todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }> },
// --- explicit completion signal ---
'attempt_completion': { result: string; command: string | null },
}
Expand Down Expand Up @@ -119,6 +126,12 @@ export type BuiltinToolResultType = {
// --- fast grep + workspace diagnostics ---
'grep_search': { matches: Array<{ uri: URI; lineNumber: number; lineContent: string }>; totalMatches: number },
'get_diagnostics': { diagnostics: Array<{ uri: URI; message: string; severity: 'error' | 'warning'; startLine: number; endLine: number; source: string | null; code: string | null }> },
// --- multi-block atomic edit ---
'multi_edit': Promise<{ lintErrors: LintErrorItem[] | null; appliedCount: number }>,
// --- glob pattern file listing (mtime-sorted) ---
'glob_files': { files: Array<{ uri: URI; mtime: number; size: number }>; truncated: boolean },
// --- model-managed task list (per-session) ---
'todo_write': { acknowledged: true; count: number },
// --- explicit completion signal ---
'attempt_completion': { acknowledged: true },
}
Expand Down
Loading