From ac77cdfe91b47931bb31ed7fbc9e6fe778632c9b Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 25 May 2026 05:16:03 +0100 Subject: [PATCH 1/4] fix(tools): search_pathnames_only now actually honors include_pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator destructured params.search_in_folder, but the LLM-facing parameter name (per prompts.ts) is include_pattern. Every call that specified include_pattern was silently ignored — the destructured value was undefined and the validator returned includePattern: null. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/vs/workbench/contrib/cortexide/browser/toolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts index 73678c30bfe..7da6c5935b5 100644 --- a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts @@ -254,7 +254,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 From c9846bc15745bc7ce1288e987b29b59400745917 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 25 May 2026 05:17:56 +0100 Subject: [PATCH 2/4] fix(terminal): throw on missing CommandDetection instead of silent timeout If CommandDetection capability doesn't mount within 10s, the previous code skipped the resolve path entirely, leaving waitUntilDone as a dead promise. The only resolver was waitUntilInterrupt (idle timeout). Commands then appeared to complete but returned stale buffer contents read after the inactivity timeout fired. Surface a clear error so the model can diagnose and retry, instead of silently returning incorrect output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contrib/cortexide/browser/terminalToolService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/cortexide/browser/terminalToolService.ts b/src/vs/workbench/contrib/cortexide/browser/terminalToolService.ts index 760f6b3e7c6..6f8d10c8534 100644 --- a/src/vs/workbench/contrib/cortexide/browser/terminalToolService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/terminalToolService.ts @@ -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 From 9ea8606585d3076676ac3b005b5fa6ea7fb99d7c Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 25 May 2026 05:17:59 +0100 Subject: [PATCH 3/4] chore(prompts): remove dead pathname_search comment block A stale comment block for an unimplemented pathname_search tool was left above search_pathnames_only. It served no purpose and was easy to mistake for a live definition while reading the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts b/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts index b76d817f9f2..09b0d57ceaf 100644 --- a/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts @@ -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.`, From b95c0daeb2017aa62cbebf592ad1999923fd9411 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 25 May 2026 05:28:40 +0100 Subject: [PATCH 4/4] feat(tools): add multi_edit, glob_files, todo_write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new agent tools to close gaps versus Claude Code / Cursor / OpenCode: - multi_edit — atomic multi-block search/replace on a single file. Pre-checks every old_string exists in the file before applying anything; if any block would miss, no edits are written. Supports replace_all per edit. Builds on the existing applySearchReplaceBlocks engine. Approval class: 'edits'. - glob_files — file listing by glob pattern, sorted by modification time newest-first. Returns mtime + size per file. Limit clamped 1–1000. Closes the gap with Claude Code's Glob — search_pathnames_only is substring-only and unsorted. - todo_write — model-managed task list, per-session. Enforces a single in_progress task at a time. Returns acknowledgment + count; the stored array is exposed via ToolsService.getLatestTodos() for future UI rendering (separate PR). All three include validators that accept either array values or JSON-string values (LLMs sometimes serialize structured params as strings). Type contracts in toolsServiceTypes.ts, descriptions in prompts.ts, validators / impls / stringOfResult in toolsService.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contrib/cortexide/browser/toolsService.ts | 181 +++++++++++++++++- .../cortexide/common/prompt/prompts.ts | 32 ++++ .../cortexide/common/toolsServiceTypes.ts | 13 ++ 3 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts index 7da6c5935b5..bdcfad0f2e2 100644 --- a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts @@ -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' @@ -200,6 +200,10 @@ export class ToolsService implements IToolsService { private readonly _browseCache = new LRUCache(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, @@ -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; + 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; + 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); @@ -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 } }; @@ -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}`; diff --git a/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts b/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts index 09b0d57ceaf..e09b329af41 100644 --- a/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts @@ -449,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: { diff --git a/src/vs/workbench/contrib/cortexide/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/cortexide/common/toolsServiceTypes.ts index 57981c7f2a8..1df0cf2fd93 100644 --- a/src/vs/workbench/contrib/cortexide/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/cortexide/common/toolsServiceTypes.ts @@ -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', @@ -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 }, } @@ -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 }, }