From d0921f899cfe93a58ecd7307acbbd5451706eadd Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Sat, 8 Mar 2025 00:23:54 -0600 Subject: [PATCH 01/27] Added open-remote-wsl to extensions --- extensions/open-remote-wsl/README.md | 3 + .../extension-browser.webpack.config.js | 17 + .../extension.webpack.config.js | 20 + extensions/open-remote-wsl/package-lock.json | 15 + extensions/open-remote-wsl/package.json | 281 ++++++++++++++ .../open-remote-wsl/src/authResolver.ts | 121 ++++++ extensions/open-remote-wsl/src/commands.ts | 86 +++++ .../open-remote-wsl/src/common/async.ts | 28 ++ .../open-remote-wsl/src/common/disposable.ts | 42 +++ .../open-remote-wsl/src/common/event.ts | 142 +++++++ .../open-remote-wsl/src/common/files.ts | 26 ++ .../open-remote-wsl/src/common/logger.ts | 64 ++++ .../open-remote-wsl/src/common/platform.ts | 8 + .../open-remote-wsl/src/common/ports.ts | 134 +++++++ .../open-remote-wsl/src/distroTreeView.ts | 109 ++++++ extensions/open-remote-wsl/src/extension.ts | 46 +++ .../src/remoteLocationHistory.ts | 56 +++ .../open-remote-wsl/src/serverConfig.ts | 42 +++ extensions/open-remote-wsl/src/serverSetup.ts | 353 ++++++++++++++++++ .../open-remote-wsl/src/wsl/wslManager.ts | 150 ++++++++ .../open-remote-wsl/src/wsl/wslTerminal.ts | 26 ++ extensions/open-remote-wsl/tsconfig.json | 12 + 22 files changed, 1781 insertions(+) create mode 100644 extensions/open-remote-wsl/README.md create mode 100644 extensions/open-remote-wsl/extension-browser.webpack.config.js create mode 100644 extensions/open-remote-wsl/extension.webpack.config.js create mode 100644 extensions/open-remote-wsl/package-lock.json create mode 100644 extensions/open-remote-wsl/package.json create mode 100644 extensions/open-remote-wsl/src/authResolver.ts create mode 100644 extensions/open-remote-wsl/src/commands.ts create mode 100644 extensions/open-remote-wsl/src/common/async.ts create mode 100644 extensions/open-remote-wsl/src/common/disposable.ts create mode 100644 extensions/open-remote-wsl/src/common/event.ts create mode 100644 extensions/open-remote-wsl/src/common/files.ts create mode 100644 extensions/open-remote-wsl/src/common/logger.ts create mode 100644 extensions/open-remote-wsl/src/common/platform.ts create mode 100644 extensions/open-remote-wsl/src/common/ports.ts create mode 100644 extensions/open-remote-wsl/src/distroTreeView.ts create mode 100644 extensions/open-remote-wsl/src/extension.ts create mode 100644 extensions/open-remote-wsl/src/remoteLocationHistory.ts create mode 100644 extensions/open-remote-wsl/src/serverConfig.ts create mode 100644 extensions/open-remote-wsl/src/serverSetup.ts create mode 100644 extensions/open-remote-wsl/src/wsl/wslManager.ts create mode 100644 extensions/open-remote-wsl/src/wsl/wslTerminal.ts create mode 100644 extensions/open-remote-wsl/tsconfig.json diff --git a/extensions/open-remote-wsl/README.md b/extensions/open-remote-wsl/README.md new file mode 100644 index 00000000..40b6c3a9 --- /dev/null +++ b/extensions/open-remote-wsl/README.md @@ -0,0 +1,3 @@ +# Remote - WSL Support + +Inherited for Void from [Open Remote - WSL](https://github.com/jeanp413/open-remote-wsl). diff --git a/extensions/open-remote-wsl/extension-browser.webpack.config.js b/extensions/open-remote-wsl/extension-browser.webpack.config.js new file mode 100644 index 00000000..7fcc53a7 --- /dev/null +++ b/extensions/open-remote-wsl/extension-browser.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withBrowserDefaults = require('../shared.webpack.config').browser; + +module.exports = withBrowserDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + } +}); diff --git a/extensions/open-remote-wsl/extension.webpack.config.js b/extensions/open-remote-wsl/extension.webpack.config.js new file mode 100644 index 00000000..de88398e --- /dev/null +++ b/extensions/open-remote-wsl/extension.webpack.config.js @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); + +module.exports = withDefaults({ + context: __dirname, + resolve: { + mainFields: ['module', 'main'] + }, + entry: { + extension: './src/extension.ts', + } +}); diff --git a/extensions/open-remote-wsl/package-lock.json b/extensions/open-remote-wsl/package-lock.json new file mode 100644 index 00000000..090fe040 --- /dev/null +++ b/extensions/open-remote-wsl/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "open-remote-wsl", + "version": "0.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-remote-wsl", + "version": "0.0.4", + "engines": { + "vscode": "^1.70.2" + } + } + } +} diff --git a/extensions/open-remote-wsl/package.json b/extensions/open-remote-wsl/package.json new file mode 100644 index 00000000..baf507b6 --- /dev/null +++ b/extensions/open-remote-wsl/package.json @@ -0,0 +1,281 @@ +{ + "name": "open-remote-wsl", + "displayName": "Remote - WSL", + "description": "Open any folder in the Windows Subsystem for Linux (WSL).", + "version": "0.0.4", + "icon": "resources/icon.png", + "engines": { + "vscode": "^1.70.2" + }, + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [ + "resolvers", + "contribViewsRemote" + ], + "keywords": [ + "remote development", + "remote", + "wsl" + ], + "api": "none", + "activationEvents": [ + "onCommand:openremotewsl.connect", + "onCommand:openremotewsl.connectInNewWindow", + "onCommand:openremotewsl.connectUsingDistro", + "onCommand:openremotewsl.connectUsingDistroInNewWindow", + "onCommand:openremotewsl.showLog", + "onResolveRemoteAuthority:wsl", + "onView:wslTargets" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "WSL", + "properties": { + "remote.WSL.serverDownloadUrlTemplate": { + "type": "string", + "description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number", + "scope": "application", + "default": "https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz" + } + } + }, + "views": { + "remote": [ + { + "id": "wslTargets", + "name": "WSL Targets", + "group": "targets@1", + "when": "(isWindows && !isWeb)", + "remoteName": "wsl" + } + ] + }, + "commands": [ + { + "command": "openremotewsl.connect", + "title": "Connect to WSL", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectInNewWindow", + "title": "Connect to WSL in New Window", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectUsingDistro", + "title": "Connect to WSL using Distro...", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.connectUsingDistroInNewWindow", + "title": "Connect to WSL using Distro in New Window...", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.showLog", + "title": "Show Log", + "category": "Remote-WSL" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "title": "Connect in New Window", + "icon": "$(empty-window)" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "title": "Connect in Current Window" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "title": "Open in Current Window" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "title": "Open in New Window", + "icon": "$(folder-opened)" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "title": "Remove From Recent List", + "icon": "$(x)" + }, + { + "command": "openremotewsl.explorer.refresh", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "openremotewsl.explorer.addDistro", + "title": "Add a Distro", + "icon": "$(plus)" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "title": "Set as Default Distro" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "title": "Delete Distro" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "vscode-remote", + "authority": "wsl+*", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": true, + "workspaceSuffix": "WSL" + } + } + ], + "menus": { + "statusBar/remoteIndicator": [ + { + "command": "openremotewsl.connect", + "when": "(isWindows && !isWeb)", + "group": "remote_20_wsl_1general@1" + }, + { + "command": "openremotewsl.connectUsingDistro", + "when": "(isWindows && !isWeb)", + "group": "remote_20_wsl_1general@2" + }, + { + "command": "openremotewsl.showLog", + "when": "remoteName =~ /^wsl$/", + "group": "remote_20_wsl_1general@4" + } + ], + "commandPalette": [ + { + "command": "openremotewsl.connect", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectInNewWindow", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectUsingDistro", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.connectUsingDistroInNewWindow", + "when": "(isWindows && !isWeb)" + }, + { + "command": "openremotewsl.explorer.refresh", + "when": "false" + }, + { + "command": "openremotewsl.explorer.addDistro", + "when": "false" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "false" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "false" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "when": "false" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "when": "false" + } + ], + "view/title": [ + { + "command": "openremotewsl.explorer.addDistro", + "when": "view == wslTargets", + "group": "navigation" + }, + { + "command": "openremotewsl.explorer.refresh", + "when": "view == wslTargets", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "inline@1" + }, + { + "command": "openremotewsl.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "navigation@2" + }, + { + "command": "openremotewsl.explorer.emptyWindowInCurrentWindow", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "navigation@1" + }, + { + "command": "openremotewsl.explorer.setDefaultDistro", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "management@1" + }, + { + "command": "openremotewsl.explorer.deleteDistro", + "when": "viewItem =~ /^openremotewsl.explorer.distro$/", + "group": "management@2" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "inline@1" + }, + { + "command": "openremotewsl.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "navigation@2" + }, + { + "command": "openremotewsl.explorer.reopenFolderInCurrentWindow", + "when": "viewItem == openremotewsl.explorer.folder", + "group": "navigation@1" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotewsl.explorer.folder/", + "group": "navigation@3" + }, + { + "command": "openremotewsl.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotewsl.explorer.folder/", + "group": "inline@2" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "gulp compile-extension:open-remote-wsl", + "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", + "watch": "gulp watch-extension:open-remote-wsl", + "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" + } +} diff --git a/extensions/open-remote-wsl/src/authResolver.ts b/extensions/open-remote-wsl/src/authResolver.ts new file mode 100644 index 00000000..b73ea4be --- /dev/null +++ b/extensions/open-remote-wsl/src/authResolver.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { installCodeServer, ServerInstallError } from './serverSetup'; +import { WSLManager } from './wsl/wslManager'; + +export const REMOTE_WSL_AUTHORITY = 'wsl'; + +export function getRemoteAuthority(distro: string) { + return `${REMOTE_WSL_AUTHORITY}+${distro}`; +} + +class Tunnel implements vscode.Tunnel { + private _onDidDisposeEmitter = new vscode.EventEmitter(); + + readonly onDidDispose = this._onDidDisposeEmitter.event; + + constructor( + readonly remoteAddress: { port: number; host: string }, + readonly localAddress: { port: number; host: string } + ) { + // If ipv6 localhost 0:0:0:0:0:0:0:1 or [::1] replace with localhost + if (localAddress.host !== 'localhost' && localAddress.host !== '127.0.0.1') { + localAddress.host = 'localhost'; + } + } + + dispose() { + this._onDidDisposeEmitter.fire(); + } +} + +export class RemoteWSLResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable { + + private labelFormatterDisposable: vscode.Disposable | undefined; + + constructor( + private readonly wslManager: WSLManager, + private readonly logger: Log + ) { + } + + resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const [type, distroName] = authority.split('+'); + if (type !== REMOTE_WSL_AUTHORITY) { + throw new Error(`Invalid authority type for WSL resolver: ${type}`); + } + + this.logger.info(`Resolving wsl remote authority '${authority}' (attemp #${context.resolveAttempt})`); + + // It looks like default values are not loaded yet when resolving a remote, + // so let's hardcode the default values here + const remoteSSHconfig = vscode.workspace.getConfiguration('remote.WSL'); + const serverDownloadUrlTemplate = remoteSSHconfig.get('serverDownloadUrlTemplate'); + + return vscode.window.withProgress({ + title: `Setting up WSL Distro: ${distroName}`, + location: vscode.ProgressLocation.Notification, + cancellable: false + }, async () => { + try { + const installResult = await installCodeServer(this.wslManager, distroName, serverDownloadUrlTemplate, [], [], this.logger); + + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `${REMOTE_WSL_AUTHORITY}+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: `WSL: ${distroName}`, + workspaceTooltip: `Running in ${distroName}` + } + }); + + return new vscode.ResolvedAuthority('127.0.0.1', installResult.listeningOn, installResult.connectionToken); + } catch (e: unknown) { + this.logger.error(`Error resolving authority`, e); + + // Initial connection + if (context.resolveAttempt === 1) { + this.logger.show(); + + const closeRemote = 'Close Remote'; + const retry = 'Retry'; + const result = await vscode.window.showErrorMessage(`Could not establish connection to WSL distro "${distroName}"`, { modal: true }, closeRemote, retry); + if (result === closeRemote) { + await vscode.commands.executeCommand('workbench.action.remote.close'); + } else if (result === retry) { + await vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + } + + if (e instanceof ServerInstallError || !(e instanceof Error)) { + throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e)); + } else { + throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message); + } + } + }); + } + + async tunnelFactory(tunnelOptions: vscode.TunnelOptions) { + return new Tunnel( + tunnelOptions.remoteAddress, + { + host: tunnelOptions.remoteAddress.host, + port: tunnelOptions.localAddressPort ?? tunnelOptions.remoteAddress.port + } + ); + } + + dispose() { + this.labelFormatterDisposable?.dispose(); + } +} diff --git a/extensions/open-remote-wsl/src/commands.ts b/extensions/open-remote-wsl/src/commands.ts new file mode 100644 index 00000000..23732717 --- /dev/null +++ b/extensions/open-remote-wsl/src/commands.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getRemoteAuthority } from './authResolver'; +import { WSLDistro, WSLManager, WSLOnlineDistro } from './wsl/wslManager'; +import wslTerminal from './wsl/wslTerminal'; + +async function showDistrosPicker(wslManager: WSLManager, placeHolder: string): Promise { + const pickItemsPromise = wslManager.listDistros() + .then(distros => distros.map(distroData => { + return { + ...distroData, + label: `${distroData.name}`, + detail: distroData.isDefault ? 'default distro' : undefined, + }; + })); + + const picked = await vscode.window.showQuickPick(pickItemsPromise, { canPickMany: false, placeHolder }); + return picked; +} + +async function showOnlineDistrosPicker(wslManager: WSLManager, placeHolder: string): Promise { + const pickItemsPromise = Promise.all([wslManager.listOnlineDistros(), wslManager.listDistros()]) + .then(([onlineDistros, localDistros]) => { + const distroToInstall = onlineDistros.filter(d => !localDistros.some(l => l.name === d.name)); + return distroToInstall.map(distroData => { + return { + ...distroData, + label: `${distroData.friendlyName}`, + }; + }); + }); + + const picked = await vscode.window.showQuickPick(pickItemsPromise, { canPickMany: false, placeHolder }); + return picked; +} + +export async function promptOpenRemoteWSLWindow(wslManager: WSLManager, useDefault: boolean, reuseWindow: boolean) { + let distroName: string | undefined; + if (useDefault) { + const distros = await wslManager.listDistros(); + distroName = distros.find(distro => distro.isDefault)?.name; + } else { + distroName = (await showDistrosPicker(wslManager, 'Select WSL distro'))?.name; + } + + if (!distroName) { + return; + } + + openRemoteWSLWindow(distroName, reuseWindow); +} + +export async function promptInstallNewWSLDistro(wslManager: WSLManager) { + const distroName = (await showOnlineDistrosPicker(wslManager, 'Select the WSL distro to install'))?.name; + if (!distroName) { + return; + } + + wslTerminal.runCommand(`wsl.exe --install -d ${distroName}`); +} + +export function openRemoteWSLWindow(distro: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(distro), reuseWindow }); +} + +export function openRemoteWSLLocationWindow(distro: string, path: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(distro), path }), { forceNewWindow: !reuseWindow }); +} + +export async function setDefaultWSLDistro(wslManager: WSLManager, distroName: string) { + await wslManager.setDefaultDistro(distroName); +} + +export async function deleteWSLDistro(wslManager: WSLManager, distroName: string) { + const deleteAction = 'Delete'; + const resp = await vscode.window.showInformationMessage(`Are you sure you want to permanently delete the distro "${distroName}" including all its data?`, { modal: true }, deleteAction); + if (resp === deleteAction) { + await wslManager.deleteDistro(distroName); + return true; + } + return false; +} diff --git a/extensions/open-remote-wsl/src/common/async.ts b/extensions/open-remote-wsl/src/common/async.ts new file mode 100644 index 00000000..bc143643 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/async.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function timeout(millis: number): Promise { + return new Promise((resolve) => setTimeout(resolve, millis)); +} + +export interface ITask { + (): T; +} + +export async function retry(task: ITask>, delay: number, retries: number): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < retries; i++) { + try { + return await task(); + } catch (error) { + lastError = error; + + await timeout(delay); + } + } + + throw lastError; +} diff --git a/extensions/open-remote-wsl/src/common/disposable.ts b/extensions/open-remote-wsl/src/common/disposable.ts new file mode 100644 index 00000000..8c1ee853 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/disposable.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop(); + if (item) { + item.dispose(); + } + } +} + +export abstract class Disposable { + private _isDisposed = false; + + protected _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/open-remote-wsl/src/common/event.ts b/extensions/open-remote-wsl/src/common/event.ts new file mode 100644 index 00000000..0944c88d --- /dev/null +++ b/extensions/open-remote-wsl/src/common/event.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDisposable { + dispose(): void; +} + +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; +} + +/** + * Returns a promise that resolves when the event fires, or when cancellation + * is requested, whichever happens first. + */ +export function toPromise(event: Event): Promise; +export function toPromise(event: Event, signal: AbortSignal): Promise; +export function toPromise(event: Event, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => once(event, resolve)); + } + + if (signal.aborted) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const d2 = once(event, (data) => { + (signal as any).removeEventListener('abort', d1); + resolve(data); + }); + + const d1 = () => { + d2.dispose(); + (signal as any).removeEventListener('abort', d1); + resolve(undefined); + }; + + (signal as any).addEventListener('abort', d1); + }); +} + +/** + * Adds a handler that handles one event on the emitter, then disposes itself. + */ +export const once = (event: Event, listener: (data: T) => void): IDisposable => { + const disposable = event((value) => { + listener(value); + disposable.dispose(); + }); + + return disposable; +}; + +/** + * Base event emitter. Calls listeners when data is emitted. + */ +export class EventEmitter { + private listeners?: Array<(data: T) => void> | ((data: T) => void); + + /** + * Event function. + */ + public readonly event: Event = (listener, thisArgs, disposables) => { + const d = this.add(thisArgs ? listener.bind(thisArgs) : listener); + disposables?.push(d); + return d; + }; + + /** + * Gets the number of event listeners. + */ + public get size() { + if (!this.listeners) { + return 0; + } else if (typeof this.listeners === 'function') { + return 1; + } else { + return this.listeners.length; + } + } + + /** + * Emits event data. + */ + public fire(value: T) { + if (!this.listeners) { + // no-op + } else if (typeof this.listeners === 'function') { + this.listeners(value); + } else { + for (const listener of this.listeners) { + listener(value); + } + } + } + + /** + * Disposes of the emitter. + */ + public dispose() { + this.listeners = undefined; + } + + private add(listener: (data: T) => void): IDisposable { + if (!this.listeners) { + this.listeners = listener; + } else if (typeof this.listeners === 'function') { + this.listeners = [this.listeners, listener]; + } else { + this.listeners.push(listener); + } + + return { dispose: () => this.rm(listener) }; + } + + private rm(listener: (data: T) => void) { + if (!this.listeners) { + return; + } + + if (typeof this.listeners === 'function') { + if (this.listeners === listener) { + this.listeners = undefined; + } + return; + } + + const index = this.listeners.indexOf(listener); + if (index === -1) { + return; + } + + if (this.listeners.length === 2) { + this.listeners = index === 0 ? this.listeners[1] : this.listeners[0]; + } else { + this.listeners = this.listeners.slice(0, index).concat(this.listeners.slice(index + 1)); + } + } +} diff --git a/extensions/open-remote-wsl/src/common/files.ts b/extensions/open-remote-wsl/src/common/files.ts new file mode 100644 index 00000000..fd4efd8d --- /dev/null +++ b/extensions/open-remote-wsl/src/common/files.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as os from 'os'; + +const homeDir = os.homedir(); + +export async function exists(path: string) { + try { + await fs.promises.access(path); + return true; + } catch { + return false; + } +} + +export function untildify(path: string) { + return path.replace(/^~(?=$|\/|\\)/, homeDir); +} + +export function normalizeToSlash(path: string) { + return path.replace(/\\/g, '/'); +} diff --git a/extensions/open-remote-wsl/src/common/logger.ts b/extensions/open-remote-wsl/src/common/logger.ts new file mode 100644 index 00000000..0fdbde8e --- /dev/null +++ b/extensions/open-remote-wsl/src/common/logger.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type LogLevel = 'Trace' | 'Info' | 'Error'; + +export default class Log { + private output: vscode.OutputChannel; + + constructor(name: string) { + this.output = vscode.window.createOutputChannel(name); + } + + private data2String(data: any): string { + if (data instanceof Error) { + return data.stack || data.message; + } + if (data.success === false && data.message) { + return data.message; + } + return data.toString(); + } + + public trace(message: string, data?: any): void { + this.logLevel('Trace', message, data); + } + + public info(message: string, data?: any): void { + this.logLevel('Info', message, data); + } + + public error(message: string, data?: any): void { + this.logLevel('Error', message, data); + } + + public logLevel(level: LogLevel, message: string, data?: any): void { + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + if (data) { + this.output.appendLine(this.data2String(data)); + } + } + + private now(): string { + const now = new Date(); + return padLeft(now.getUTCHours() + '', 2, '0') + + ':' + padLeft(now.getMinutes() + '', 2, '0') + + ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds(); + } + + public show() { + this.output.show(); + } + + public dispose() { + this.output.dispose(); + } +} + +function padLeft(s: string, n: number, pad = ' ') { + return pad.repeat(Math.max(0, n - s.length)) + s; +} diff --git a/extensions/open-remote-wsl/src/common/platform.ts b/extensions/open-remote-wsl/src/common/platform.ts new file mode 100644 index 00000000..91673ab0 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/platform.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const isWindows = process.platform === 'win32'; +export const isMacintosh = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; diff --git a/extensions/open-remote-wsl/src/common/ports.ts b/extensions/open-remote-wsl/src/common/ports.ts new file mode 100644 index 00000000..f56df4b5 --- /dev/null +++ b/extensions/open-remote-wsl/src/common/ports.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as net from 'net'; + +/** + * Finds a random unused port assigned by the operating system. Will reject in case no free port can be found. + */ +export function findRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer({ pauseOnConnect: true }); + server.on('error', reject); + server.on('listening', () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.listen(0, '127.0.0.1'); + }); +} + +/** + * Given a start point and a max number of retries, will find a port that + * is openable. Will return 0 in case no free port can be found. + */ +export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise { + let done = false; + + return new Promise(resolve => { + const timeoutHandle = setTimeout(() => { + if (!done) { + done = true; + return resolve(0); + } + }, timeout); + + doFindFreePort(startPort, giveUpAfter, stride, (port) => { + if (!done) { + done = true; + clearTimeout(timeoutHandle); + return resolve(port); + } + }); + }); +} + +function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void { + if (giveUpAfter === 0) { + return clb(0); + } + + const client = new net.Socket(); + + // If we can connect to the port it means the port is already taken so we continue searching + client.once('connect', () => { + dispose(client); + + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + }); + + client.once('data', () => { + // this listener is required since node.js 8.x + }); + + client.once('error', (err: Error & { code?: string }) => { + dispose(client); + + // If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect + if (err.code !== 'ECONNREFUSED') { + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + } + + // Otherwise it means the port is free to use! + return clb(startPort); + }); + + client.connect(startPort, '127.0.0.1'); +} + +/** + * Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener. + */ +export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise { + let resolved = false; + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let countTried = 1; + const server = net.createServer({ pauseOnConnect: true }); + function doResolve(port: number, resolve: (port: number) => void) { + if (!resolved) { + resolved = true; + server.removeAllListeners(); + server.close(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + resolve(port); + } + } + return new Promise(resolve => { + timeoutHandle = setTimeout(() => { + doResolve(0, resolve); + }, timeout); + + server.on('listening', () => { + doResolve(startPort, resolve); + }); + server.on('error', err => { + if (err && ((err).code === 'EADDRINUSE' || (err).code === 'EACCES') && (countTried < giveUpAfter)) { + startPort++; + countTried++; + server.listen(startPort, '127.0.0.1'); + } else { + doResolve(0, resolve); + } + }); + server.on('close', () => { + doResolve(0, resolve); + }); + server.listen(startPort, '127.0.0.1'); + }); +} + +function dispose(socket: net.Socket): void { + try { + socket.removeAllListeners('connect'); + socket.removeAllListeners('error'); + socket.end(); + socket.destroy(); + socket.unref(); + } catch (error) { + console.error(error); // otherwise this error would get lost in the callback chain + } +} diff --git a/extensions/open-remote-wsl/src/distroTreeView.ts b/extensions/open-remote-wsl/src/distroTreeView.ts new file mode 100644 index 00000000..e0c4b89f --- /dev/null +++ b/extensions/open-remote-wsl/src/distroTreeView.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { RemoteLocationHistory } from './remoteLocationHistory'; +import { Disposable } from './common/disposable'; +import { openRemoteWSLWindow, openRemoteWSLLocationWindow, promptInstallNewWSLDistro, deleteWSLDistro, setDefaultWSLDistro } from './commands'; +import { WSLManager } from './wsl/wslManager'; + +class DistroItem { + constructor( + public name: string, + public isDefault: boolean, + public locations: string[] + ) { + } +} + +class DistroLocationItem { + constructor( + public path: string, + public name: string + ) { + } +} + +type DataTreeItem = DistroItem | DistroLocationItem; + +export class DistroTreeDataProvider extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private readonly locationHistory: RemoteLocationHistory, + private readonly wslManager: WSLManager + ) { + super(); + + this._register(vscode.commands.registerCommand('openremotewsl.explorer.addDistro', () => promptInstallNewWSLDistro(wslManager))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.refresh', () => this.refresh())); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.emptyWindowInNewWindow', e => this.openRemoteWSLWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.emptyWindowInCurrentWindow', e => this.openRemoteWSLWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.reopenFolderInNewWindow', e => this.openRemoteWSLocationWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.reopenFolderInCurrentWindow', e => this.openRemoteWSLocationWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.deleteFolderHistoryItem', e => this.deleteDistroLocation(e))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.setDefaultDistro', e => this.setDefaultDistro(e))); + this._register(vscode.commands.registerCommand('openremotewsl.explorer.deleteDistro', e => this.deleteDistro(e))); + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof DistroLocationItem) { + const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)'); + const treeItem = new vscode.TreeItem(label); + treeItem.description = path.posix.dirname(element.path); + treeItem.iconPath = new vscode.ThemeIcon('folder'); + treeItem.contextValue = 'openremotewsl.explorer.folder'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.name); + treeItem.description = element.isDefault ? 'default distro' : undefined; + treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('vm'); + treeItem.contextValue = 'openremotewsl.explorer.distro'; + return treeItem; + } + + async getChildren(element?: DistroItem): Promise { + if (!element) { + const distros = await this.wslManager.listDistros(); + return distros.map(distro => new DistroItem(distro.name, distro.isDefault, this.locationHistory.getHistory(distro.name))); + } + if (element instanceof DistroItem) { + return element.locations.map(location => new DistroLocationItem(location, element.name)); + } + return []; + } + + private refresh() { + this._onDidChangeTreeData.fire(); + } + + private async deleteDistroLocation(element: DistroLocationItem) { + await this.locationHistory.removeLocation(element.name, element.path); + this.refresh(); + } + + private async openRemoteWSLWindow(element: DistroItem, reuseWindow: boolean) { + openRemoteWSLWindow(element.name, reuseWindow); + } + + private async openRemoteWSLocationWindow(element: DistroLocationItem, reuseWindow: boolean) { + openRemoteWSLLocationWindow(element.name, element.path, reuseWindow); + } + + private async setDefaultDistro(element: DistroItem) { + await setDefaultWSLDistro(this.wslManager, element.name); + this.refresh(); + } + + private async deleteDistro(element: DistroItem) { + await deleteWSLDistro(this.wslManager, element.name); + this.refresh(); + } +} diff --git a/extensions/open-remote-wsl/src/extension.ts b/extensions/open-remote-wsl/src/extension.ts new file mode 100644 index 00000000..987d00f6 --- /dev/null +++ b/extensions/open-remote-wsl/src/extension.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { RemoteWSLResolver, REMOTE_WSL_AUTHORITY } from './authResolver'; +import { promptOpenRemoteWSLWindow } from './commands'; +import { DistroTreeDataProvider } from './distroTreeView'; +import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory'; +import { WSLManager } from './wsl/wslManager'; +import { isWindows } from './common/platform'; + +export async function activate(context: vscode.ExtensionContext) { + if (!isWindows) { + return; + } + + const logger = new Log('Remote - WSL'); + context.subscriptions.push(logger); + + const wslManager = new WSLManager(logger); + const remoteWSLResolver = new RemoteWSLResolver(wslManager, logger); + context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_WSL_AUTHORITY, remoteWSLResolver)); + context.subscriptions.push(remoteWSLResolver); + + const locationHistory = new RemoteLocationHistory(context); + const locationData = getRemoteWorkspaceLocationData(); + if (locationData) { + await locationHistory.addLocation(locationData[0], locationData[1]); + } + + const distroTreeDataProvider = new DistroTreeDataProvider(locationHistory, wslManager); + context.subscriptions.push(vscode.window.createTreeView('wslTargets', { treeDataProvider: distroTreeDataProvider })); + context.subscriptions.push(distroTreeDataProvider); + + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connect', () => promptOpenRemoteWSLWindow(wslManager, true, true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectInNewWindow', () => promptOpenRemoteWSLWindow(wslManager, true, false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectUsingDistro', () => promptOpenRemoteWSLWindow(wslManager, false, true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.connectUsingDistroInNewWindow', () => promptOpenRemoteWSLWindow(wslManager, false, false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotewsl.showLog', () => logger.show())); +} + +export function deactivate() { +} diff --git a/extensions/open-remote-wsl/src/remoteLocationHistory.ts b/extensions/open-remote-wsl/src/remoteLocationHistory.ts new file mode 100644 index 00000000..d3aa5f36 --- /dev/null +++ b/extensions/open-remote-wsl/src/remoteLocationHistory.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { REMOTE_WSL_AUTHORITY } from './authResolver'; + +export class RemoteLocationHistory { + private static STORAGE_KEY = 'remoteLocationHistory_v0'; + + private remoteLocationHistory: Record = {}; + + constructor(private context: vscode.ExtensionContext) { + // context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined); + this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {}; + } + + getHistory(host: string): string[] { + return this.remoteLocationHistory[host] || []; + } + + async addLocation(host: string, path: string) { + const hostLocations = this.remoteLocationHistory[host] || []; + if (!hostLocations.includes(path)) { + hostLocations.unshift(path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } + } + + async removeLocation(host: string, path: string) { + let hostLocations = this.remoteLocationHistory[host] || []; + hostLocations = hostLocations.filter(l => l !== path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } +} + +export function getRemoteWorkspaceLocationData(): [string, string] | undefined { + let location = vscode.workspace.workspaceFile; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_WSL_AUTHORITY) && location.path.endsWith('.code-workspace')) { + const [, distroName] = location.authority.split('+'); + return [distroName, location.path]; + } + + location = vscode.workspace.workspaceFolders?.[0].uri; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_WSL_AUTHORITY)) { + const [, distroName] = location.authority.split('+'); + return [distroName, location.path]; + } + + return undefined; +} diff --git a/extensions/open-remote-wsl/src/serverConfig.ts b/extensions/open-remote-wsl/src/serverConfig.ts new file mode 100644 index 00000000..572e3be3 --- /dev/null +++ b/extensions/open-remote-wsl/src/serverConfig.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +let vscodeProductJson: any; +async function getVSCodeProductJson() { + if (!vscodeProductJson) { + const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8'); + vscodeProductJson = JSON.parse(productJsonStr); + } + + return vscodeProductJson; +} + +export interface IServerConfig { + version: string; + commit: string; + quality: string; + release?: string; // void-like specific + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate?: string; // void-like specific +} + +export async function getVSCodeServerConfig(): Promise { + const productJson = await getVSCodeProductJson(); + + return { + version: vscode.version.replace('-insider', ''), + commit: productJson.commit, + quality: productJson.quality, + release: productJson.release, + serverApplicationName: productJson.serverApplicationName, + serverDataFolderName: productJson.serverDataFolderName, + serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate + }; +} diff --git a/extensions/open-remote-wsl/src/serverSetup.ts b/extensions/open-remote-wsl/src/serverSetup.ts new file mode 100644 index 00000000..60e56093 --- /dev/null +++ b/extensions/open-remote-wsl/src/serverSetup.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import Log from './common/logger'; +import { getVSCodeServerConfig } from './serverConfig'; +import { WSLManager } from './wsl/wslManager'; + +export interface ServerInstallOptions { + id: string; + quality: string; + commit: string; + version: string; + release?: string; // void specific + extensionIds: string[]; + envVariables: string[]; + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate: string; +} + +export interface ServerInstallResult { + exitCode: number; + listeningOn: number; + connectionToken: string; + logFile: string; + osReleaseId: string; + arch: string; + platform: string; + tmpDir: string; + [key: string]: any; +} + +export class ServerInstallError extends Error { + constructor(message: string) { + super(message); + } +} + +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz'; + +export async function installCodeServer(wslManager: WSLManager, distroName: string, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], logger: Log): Promise { + const scriptId = crypto.randomBytes(12).toString('hex'); + + const vscodeServerConfig = await getVSCodeServerConfig(); + const installOptions: ServerInstallOptions = { + id: scriptId, + version: vscodeServerConfig.version, + commit: vscodeServerConfig.commit, + quality: vscodeServerConfig.quality, + release: vscodeServerConfig.release, + extensionIds, + envVariables, + serverApplicationName: vscodeServerConfig.serverApplicationName, + serverDataFolderName: vscodeServerConfig.serverDataFolderName, + serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE, + }; + + const installServerScript = generateBashInstallScript(installOptions); + + // Fish shell does not support heredoc so let's workaround it using -c option, + // also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676 + const resp = await wslManager.exec('bash', ['-c', `'${installServerScript.replace(/'/g, `'\\''`)}'`], distroName); + + const endScriptRegex = new RegExp(`${scriptId}: Server installation script done`, 'm'); + const commandOutput = await Promise.race([ + resp.exitPromise.then(result => ({ stdout: resp.stdout, stderr: resp.stderr, exitCode: result.exitCode })), + new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + resp.onStdoutData(buffer => { + if (endScriptRegex.test(buffer.toString('utf8'))) { + resolve({ stdout: resp.stdout, stderr: resp.stderr, exitCode: 0 }); + } + }); + }) + ]); + + if (commandOutput.exitCode) { + logger.trace('Server install command stderr:', commandOutput.stderr); + } + logger.trace('Server install command stdout:', commandOutput.stdout); + + const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId); + if (!resultMap) { + throw new ServerInstallError(`Failed parsing install script output`); + } + + const exitCode = parseInt(resultMap.exitCode, 10); + if (exitCode !== 0) { + throw new ServerInstallError(`Couldn't install void server on remote server, install script returned non-zero exit status`); + } + + const listeningOn = parseInt(resultMap.listeningOn, 10); + + const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); + + return { + exitCode, + listeningOn, + connectionToken: resultMap.connectionToken, + logFile: resultMap.logFile, + osReleaseId: resultMap.osReleaseId, + arch: resultMap.arch, + platform: resultMap.platform, + tmpDir: resultMap.tmpDir, + ...remoteEnvVars + }; +} + +function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { + const startResultStr = `${scriptId}: start`; + const endResultStr = `${scriptId}: end`; + + const startResultIdx = str.indexOf(startResultStr); + if (startResultIdx < 0) { + return undefined; + } + + const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length); + if (endResultIdx < 0) { + return undefined; + } + + const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx); + + const resultMap: { [k: string]: string } = {}; + const resultArr = installResult.split(/\r?\n/); + for (const line of resultArr) { + const [key, value] = line.split('=='); + resultMap[key] = value; + } + + return resultMap; +} + +function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + return ` +# Server installation script + +TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}" + +DISTRO_VERSION="${version}" +DISTRO_COMMIT="${commit}" +DISTRO_QUALITY="${quality}" +DISTRO_VSCODIUM_RELEASE="${release ?? ''}" + +SERVER_APP_NAME="${serverApplicationName}" +SERVER_INITIAL_EXTENSIONS="${extensions}" +SERVER_LISTEN_FLAG="--port=0" +SERVER_DATA_DIR="$HOME/${serverDataFolderName}" +SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT" +SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME" +SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log" +SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid" +SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token" +SERVER_OS= +SERVER_ARCH= +SERVER_CONNECTION_TOKEN= +SERVER_DOWNLOAD_URL= + +LISTENING_ON= +OS_RELEASE_ID= +ARCH= +PLATFORM= + +# Mimic output from logs of remote-ssh extension +print_install_results_and_exit() { + echo "${id}: start" + echo "exitCode==$1==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + exit 0 +} + +# Check if platform is supported +PLATFORM="$(uname -s)" +case $PLATFORM in + Linux) + SERVER_OS="linux" + ;; + *) + echo "Error platform not supported: $PLATFORM" + print_install_results_and_exit 1 + ;; +esac + +# Check machine architecture +ARCH="$(uname -m)" +case $ARCH in + x86_64 | amd64) + SERVER_ARCH="x64" + ;; + armv7l | armv8l) + SERVER_ARCH="armhf" + ;; + arm64 | aarch64) + SERVER_ARCH="arm64" + ;; + *) + echo "Error architecture not supported: $ARCH" + print_install_results_and_exit 1 + ;; +esac + +# https://www.freedesktop.org/software/systemd/man/os-release.html +OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" +if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" + if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="unknown" + fi +fi + +# Create installation folder +if [[ ! -d $SERVER_DIR ]]; then + mkdir -p $SERVER_DIR + if (( $? > 0 )); then + echo "Error creating server install directory" + print_install_results_and_exit 1 + fi +fi + +SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$SERVER_OS/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VSCODIUM_RELEASE/g")" + +# Check if server script is already installed +if [[ ! -f $SERVER_SCRIPT ]]; then + if [[ "$SERVER_OS" = "dragonfly" ]] || [[ "$SERVER_OS" = "freebsd" ]]; then + echo "Error "$SERVER_OS" needs manual installation of remote extension host" + print_install_results_and_exit 1 + fi + + pushd $SERVER_DIR > /dev/null + + if [[ ! -z $(which wget) ]]; then + wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL + elif [[ ! -z $(which curl) ]]; then + curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL + else + echo "Error no tool to download server binary" + print_install_results_and_exit 1 + fi + + if (( $? > 0 )); then + echo "Error downloading server from $SERVER_DOWNLOAD_URL" + print_install_results_and_exit 1 + fi + + tar -xf vscode-server.tar.gz --strip-components 1 + if (( $? > 0 )); then + echo "Error while extracting server contents" + print_install_results_and_exit 1 + fi + + if [[ ! -f $SERVER_SCRIPT ]]; then + echo "Error server contents are corrupted" + print_install_results_and_exit 1 + fi + + rm -f vscode-server.tar.gz + + popd > /dev/null +else + echo "Server script already installed in $SERVER_SCRIPT" +fi + +# Try to find if server is already running +if [[ -f $SERVER_PIDFILE ]]; then + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" +else + SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)" +fi + +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + if [[ -f $SERVER_LOGFILE ]]; then + rm $SERVER_LOGFILE + fi + if [[ -f $SERVER_TOKENFILE ]]; then + rm $SERVER_TOKENFILE + fi + + touch $SERVER_TOKENFILE + chmod 600 $SERVER_TOKENFILE + SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE + + $SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --use-host-proxy --disable-websocket-compression --without-browser-env-var --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE & + echo $! > $SERVER_PIDFILE +else + echo "Server script is already running $SERVER_SCRIPT" +fi + +if [[ -f $SERVER_TOKENFILE ]]; then + SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +else + echo "Error server token file not found $SERVER_TOKENFILE" + print_install_results_and_exit 1 +fi + +if [[ -f $SERVER_LOGFILE ]]; then + for i in {1..5}; do + LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" + if [[ -n $LISTENING_ON ]]; then + break + fi + sleep 0.5 + done + + if [[ -z $LISTENING_ON ]]; then + echo "Error server did not start sucessfully" + print_install_results_and_exit 1 + fi +else + echo "Error server log file not found $SERVER_LOGFILE" + print_install_results_and_exit 1 +fi + +# Finish server setup and keep script running +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + echo "${id}: start" + echo "exitCode==0==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + + echo "${id}: Server installation script done" + + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" + while [[ -n $SERVER_RUNNING_PROCESS ]]; do + sleep 300; + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" + done +else + print_install_results_and_exit 0 +fi +`; +} diff --git a/extensions/open-remote-wsl/src/wsl/wslManager.ts b/extensions/open-remote-wsl/src/wsl/wslManager.ts new file mode 100644 index 00000000..f5dae1a8 --- /dev/null +++ b/extensions/open-remote-wsl/src/wsl/wslManager.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import Log from '../common/logger'; +import { EventEmitter } from '../common/event'; + +const wslBinary = 'wsl.exe'; + +export interface WSLDistro { + isDefault: boolean; + name: string; + state: string; + version: string; +} + +export interface WSLOnlineDistro { + name: string; + friendlyName: string; +} + +export class WSLManager { + constructor(private readonly logger: Log) { + } + + async listDistros() { + const resp = this._runWSLCommand(['--list', '--verbose'], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl listDistros exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl listDistros exited with code ${exitCode}`); + } + + const regex = /(?\*|\s)\s+(?[\w\.-]+)\s+(?[\w]+)\s+(?\d)/; + const distros: WSLDistro[] = []; + for (const line of stdout.split(/\r?\n/)) { + const matches = line.match(regex); + if (matches && matches.groups) { + distros.push({ + isDefault: matches.groups.default === '*', + name: matches.groups.name, + state: matches.groups.state, + version: matches.groups.version, + }); + } + } + + return distros; + } + + async listOnlineDistros() { + const resp = this._runWSLCommand(['--list', '--online'], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl listOnlineDistros exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl listOnlineDistros exited with code ${exitCode}`); + } + + let lines = stdout.split(/\r?\n/); + const idx = lines.findIndex(l => /\s*NAME\s+FRIENDLY NAME\s*/.test(l)); + lines = lines.slice(idx + 1); + + const regex = /(?[\w\.-]+)\s+(?\w.+\w)/; + const distros: WSLOnlineDistro[] = []; + for (const line of lines) { + const matches = line.match(regex); + if (matches && matches.groups) { + distros.push({ + name: matches.groups.name, + friendlyName: matches.groups.friendlyName, + }); + } + } + + return distros; + } + + async setDefaultDistro(distroName: string) { + const resp = this._runWSLCommand(['--set-default', distroName], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl setDefaultDistro exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl setDefaultDistro exited with code ${exitCode}`); + } + } + + async deleteDistro(distroName: string) { + const resp = this._runWSLCommand(['--unregister', distroName], 'utf16le'); + const { exitCode } = await resp.exitPromise; + const { stdout, stderr } = resp; + if (exitCode) { + this.logger.trace(`Command wsl deleteDistro exited with code ${exitCode}`, stdout + '\n\n' + stderr); + throw new Error(`Command wsl deleteDistro exited with code ${exitCode}`); + } + } + + async exec(cmd: string, args: string[], distro: string) { + return this._runWSLCommand(['--distribution', distro, '--', cmd, ...args], 'utf8'); + } + + private _runWSLCommand(args: string[], encoding: 'utf8' | 'utf16le') { + this.logger.trace(`Running WSL command: ${wslBinary} ${args.join(' ')}`); + + const cmd = cp.spawn(wslBinary, args, { windowsHide: true, windowsVerbatimArguments: true }); + + const stdoutDataEmitter = new EventEmitter(); + const stdoutData: Buffer[] = []; + const stderrDataEmitter = new EventEmitter(); + const stderrData: Buffer[] = []; + cmd.stdout.on('data', (data: Buffer) => { + stdoutData.push(data); + stdoutDataEmitter.fire(data); + }); + cmd.stderr.on('data', (data: Buffer) => { + stderrData.push(data); + stderrDataEmitter.fire(data); + }); + + const exitPromise = new Promise<{ exitCode: number }>((resolve, reject) => { + cmd.on('error', (err) => { + this.logger.error(`Error running WSL command: ${wslBinary} ${args.join(' ')}`, err); + reject(err); + }); + cmd.on('exit', (code, _signal) => { + resolve({ exitCode: code ?? 0 }); + }); + }); + + return { + get stdout() { + return Buffer.concat(stdoutData).toString(encoding); + }, + get stderr() { + return Buffer.concat(stderrData).toString(encoding); + }, + get onStdoutData() { + return stdoutDataEmitter.event; + }, + get onStderrData() { + return stderrDataEmitter.event; + }, + exitPromise + }; + } +} diff --git a/extensions/open-remote-wsl/src/wsl/wslTerminal.ts b/extensions/open-remote-wsl/src/wsl/wslTerminal.ts new file mode 100644 index 00000000..190141c6 --- /dev/null +++ b/extensions/open-remote-wsl/src/wsl/wslTerminal.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +class WSLTerminal { + static NAME = 'WSL'; + + private getTerminal() { + const wslTerminal = vscode.window.terminals.find(t => t.name === WSLTerminal.NAME); + if (wslTerminal) { + return wslTerminal; + } + return vscode.window.createTerminal(WSLTerminal.NAME); + } + + runCommand(command: string) { + const wslTerminal = this.getTerminal(); + wslTerminal.show(false); + wslTerminal.sendText(command, true); + } +} + +export default new WSLTerminal(); diff --git a/extensions/open-remote-wsl/tsconfig.json b/extensions/open-remote-wsl/tsconfig.json new file mode 100644 index 00000000..2e08e0d2 --- /dev/null +++ b/extensions/open-remote-wsl/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", + "../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts", + ] +} From d1fa4d3439827f98ee66c42ffce533df055caab0 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Sat, 8 Mar 2025 05:39:43 -0600 Subject: [PATCH 02/27] Added open-remote-wsl to the gulp build pipeline --- build/gulpfile.extensions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index b7f5b7d1..0a553366 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -73,6 +73,7 @@ const compilations = [ '.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json', 'extensions/open-remote-ssh/tsconfig.json', // Void added this + 'extensions/open-remote-wsl/tsconfig.json', // Void added this ]; From fe01285d58a1013b55b31e383af65e64c97487f7 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Sat, 8 Mar 2025 05:42:20 -0600 Subject: [PATCH 03/27] Added open-remote-wsl to the npm install build command --- build/npm/dirs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/build/npm/dirs.js b/build/npm/dirs.js index ed0b97d7..2ceb6f1f 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -55,6 +55,7 @@ const dirs = [ '.vscode/extensions/vscode-selfhost-import-aid', '.vscode/extensions/vscode-selfhost-test-provider', 'extensions/open-remote-ssh', // Void added this + 'extensions/open-remote-wsl', // Void added this ]; From 85009ab5b367b5f476357957a3308c5b0e371cc5 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Sat, 8 Mar 2025 05:48:15 -0600 Subject: [PATCH 04/27] Added open-remote-wsl output folder to the exclude list for hygiene checks --- build/filters.js | 1 + 1 file changed, 1 insertion(+) diff --git a/build/filters.js b/build/filters.js index f972d9c1..4d19dcb1 100644 --- a/build/filters.js +++ b/build/filters.js @@ -139,6 +139,7 @@ module.exports.indentationFilter = [ '!extensions/simple-browser/media/*.js', '!extensions/open-remote-ssh/out/*.js', // Void added this + '!extensions/open-remote-wsl/out/*.js', // Void added this ]; module.exports.copyrightFilter = [ From c58210ba80a45ca6e0abfb40fa7e551d2914d22b Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Mon, 31 Mar 2025 23:16:06 -0400 Subject: [PATCH 05/27] Updated the package.json default url to point to the void binaries repo properly --- extensions/open-remote-wsl/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/open-remote-wsl/package.json b/extensions/open-remote-wsl/package.json index baf507b6..e913bdd9 100644 --- a/extensions/open-remote-wsl/package.json +++ b/extensions/open-remote-wsl/package.json @@ -38,7 +38,7 @@ "type": "string", "description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number", "scope": "application", - "default": "https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz" + "default": "https://github.com/voideditor/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz" } } }, From 103dd68b3cf0a702f0f2e458fc87258d88cf1ef9 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Tue, 1 Apr 2025 00:00:34 -0400 Subject: [PATCH 06/27] Updated config to point at voidVersion instead of the base vscode version --- extensions/open-remote-wsl/src/serverConfig.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/open-remote-wsl/src/serverConfig.ts b/extensions/open-remote-wsl/src/serverConfig.ts index 572e3be3..83a73643 100644 --- a/extensions/open-remote-wsl/src/serverConfig.ts +++ b/extensions/open-remote-wsl/src/serverConfig.ts @@ -31,12 +31,14 @@ export async function getVSCodeServerConfig(): Promise { const productJson = await getVSCodeProductJson(); return { - version: vscode.version.replace('-insider', ''), + // version: vscode.version.replace('-insider', ''), commit: productJson.commit, quality: productJson.quality, release: productJson.release, serverApplicationName: productJson.serverApplicationName, serverDataFolderName: productJson.serverDataFolderName, - serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate + serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate, + // Void changed this + version: productJson.voidVersion }; } From eceaa6a59bd1e7461d6f282e97f2c9870bc69725 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Tue, 1 Apr 2025 00:03:02 -0400 Subject: [PATCH 07/27] Updated DEFAULT_DOWNLOAD_URL_TEMPLATE to also point at the void binaries repo --- extensions/open-remote-wsl/src/serverSetup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/open-remote-wsl/src/serverSetup.ts b/extensions/open-remote-wsl/src/serverSetup.ts index 60e56093..3d254313 100644 --- a/extensions/open-remote-wsl/src/serverSetup.ts +++ b/extensions/open-remote-wsl/src/serverSetup.ts @@ -39,7 +39,7 @@ export class ServerInstallError extends Error { } } -const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/codestoryai/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz'; +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz'; export async function installCodeServer(wslManager: WSLManager, distroName: string, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], logger: Log): Promise { const scriptId = crypto.randomBytes(12).toString('hex'); From 086e471dd52d836dc144c0cd6d9043d73b1b2b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 19 Mar 2025 13:12:04 +0100 Subject: [PATCH 08/27] disable mangling when building vscode locally (#243856) * disable mangling when building vscode locally * local build: ensure mangling runs when minification is on --- build/gulpfile.compile.js | 16 ++++++++-------- build/gulpfile.reh.js | 4 ++-- build/gulpfile.vscode.js | 18 +++++++++--------- build/gulpfile.vscode.web.js | 4 ++-- package.json | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/build/gulpfile.compile.js b/build/gulpfile.compile.js index e40b05f8..0c0a024c 100644 --- a/build/gulpfile.compile.js +++ b/build/gulpfile.compile.js @@ -24,12 +24,12 @@ function makeCompileBuildTask(disableMangle) { ); } -// Full compile, including nls and inline sources in sourcemaps, mangling, minification, for build -const compileBuildTask = task.define('compile-build', makeCompileBuildTask(false)); -gulp.task(compileBuildTask); -exports.compileBuildTask = compileBuildTask; +// Local/PR compile, including nls and inline sources in sourcemaps, minification, no mangling +const compileBuildWithoutManglingTask = task.define('compile-build-without-mangling', makeCompileBuildTask(true)); +gulp.task(compileBuildWithoutManglingTask); +exports.compileBuildWithoutManglingTask = compileBuildWithoutManglingTask; -// Full compile for PR ci, e.g no mangling -const compileBuildTaskPullRequest = task.define('compile-build-pr', makeCompileBuildTask(true)); -gulp.task(compileBuildTaskPullRequest); -exports.compileBuildTaskPullRequest = compileBuildTaskPullRequest; +// CI compile, including nls and inline sources in sourcemaps, mangling, minification, for build +const compileBuildWithManglingTask = task.define('compile-build-with-mangling', makeCompileBuildTask(false)); +gulp.task(compileBuildWithManglingTask); +exports.compileBuildWithManglingTask = compileBuildWithManglingTask; diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index b4b7f49e..125a405b 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -26,7 +26,7 @@ const gunzip = require('gulp-gunzip'); const File = require('vinyl'); const fs = require('fs'); const glob = require('glob'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithManglingTask } = require('./gulpfile.compile'); const { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask } = require('./gulpfile.extensions'); const { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } = require('./gulpfile.vscode.web'); const cp = require('child_process'); @@ -491,7 +491,7 @@ function tweakProductForServerWeb(product) { gulp.task(serverTaskCI); const serverTask = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( - compileBuildTask, + compileBuildWithManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index f34954fa..399fa243 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -30,7 +30,7 @@ const { getProductionDependencies } = require('./lib/dependencies'); const { config } = require('./lib/electron'); const createAsar = require('./lib/asar').createAsar; const minimist = require('minimist'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithoutManglingTask, compileBuildWithManglingTask } = require('./gulpfile.compile'); const { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } = require('./gulpfile.extensions'); const { promisify } = require('util'); const glob = promisify(require('glob')); @@ -166,25 +166,25 @@ const minifyVSCodeTask = task.define('minify-vscode', task.series( )); gulp.task(minifyVSCodeTask); -const core = task.define('core-ci', task.series( - gulp.task('compile-build'), +const coreCI = task.define('core-ci', task.series( + gulp.task('compile-build-with-mangling'), task.parallel( gulp.task('minify-vscode'), gulp.task('minify-vscode-reh'), gulp.task('minify-vscode-reh-web'), ) )); -gulp.task(core); +gulp.task(coreCI); -const corePr = task.define('core-ci-pr', task.series( - gulp.task('compile-build-pr'), +const coreCIPR = task.define('core-ci-pr', task.series( + gulp.task('compile-build-without-mangling'), task.parallel( gulp.task('minify-vscode'), gulp.task('minify-vscode-reh'), gulp.task('minify-vscode-reh-web'), ) )); -gulp.task(corePr); +gulp.task(coreCIPR); /** * Compute checksums for some files. @@ -502,7 +502,7 @@ BUILD_TARGETS.forEach(buildTarget => { gulp.task(vscodeTaskCI); const vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( - compileBuildTask, + minified ? compileBuildWithManglingTask : compileBuildWithoutManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, @@ -540,7 +540,7 @@ const innoSetupConfig = { gulp.task(task.define( 'vscode-translations-export', task.series( - core, + coreCI, compileAllExtensionsBuildTask, function () { const pathToMetadata = './out-build/nls.metadata.json'; diff --git a/build/gulpfile.vscode.web.js b/build/gulpfile.vscode.web.js index 02b17022..d2b49929 100644 --- a/build/gulpfile.vscode.web.js +++ b/build/gulpfile.vscode.web.js @@ -19,7 +19,7 @@ const filter = require('gulp-filter'); const { getProductionDependencies } = require('./lib/dependencies'); const vfs = require('vinyl-fs'); const packageJson = require('../package.json'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithManglingTask } = require('./gulpfile.compile'); const extensions = require('./lib/extensions'); const VinylFile = require('vinyl'); @@ -223,7 +223,7 @@ const dashed = (/** @type {string} */ str) => (str ? `-${str}` : ``); gulp.task(vscodeWebTaskCI); const vscodeWebTask = task.define(`vscode-web${dashed(minified)}`, task.series( - compileBuildTask, + compileBuildWithManglingTask, vscodeWebTaskCI )); gulp.task(vscodeWebTask); diff --git a/package.json b/package.json index f1faef9f..059e521f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint": "node build/eslint", "stylelint": "node build/stylelint", "playwright-install": "npm exec playwright install", - "compile-build": "node ./node_modules/gulp/bin/gulp.js compile-build", + "compile-build": "node ./node_modules/gulp/bin/gulp.js compile-build-with-mangling", "compile-extensions-build": "node ./node_modules/gulp/bin/gulp.js compile-extensions-build", "minify-vscode": "node ./node_modules/gulp/bin/gulp.js minify-vscode", "minify-vscode-reh": "node ./node_modules/gulp/bin/gulp.js minify-vscode-reh", From 8f8fa8548d57a3dfd59e87aa42f6369ab753cf11 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Mon, 7 Apr 2025 20:28:39 -0700 Subject: [PATCH 09/27] + --- package-lock.json | 27 +- package.json | 2 + .../contrib/void/browser/chatThreadService.ts | 23 +- .../void/browser/directoryStrService.ts | 2 + .../common/helpers/extractCodeFromResult.ts | 120 +-------- .../contrib/void/common/modelCapabilities.ts | 50 ---- .../contrib/void/common/prompt/prompts.ts | 242 +++++++++-------- .../void/common/sendLLMMessageTypes.ts | 12 +- .../contrib/void/common/toolsServiceTypes.ts | 12 - .../llmMessage/extractGrammar.ts | 247 ++++++++++++++++++ .../llmMessage/preprocessLLMMessages.ts | 236 +---------------- .../llmMessage/sendLLMMessage.impl.ts | 138 ++-------- .../llmMessage/sendLLMMessage.ts | 3 +- 13 files changed, 443 insertions(+), 671 deletions(-) create mode 100644 src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts diff --git a/package-lock.json b/package-lock.json index 0e6f871e..fde227b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "posthog-node": "^4.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "sax": "^1.4.1", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", "vscode-html-languageservice": "^5.3.1", @@ -88,6 +89,7 @@ "@types/node": "20.x", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/sax": "^1.2.7", "@types/sinon": "^10.0.2", "@types/sinon-test": "^2.4.2", "@types/trusted-types": "^1.0.6", @@ -3939,6 +3941,16 @@ "@types/node": "*" } }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -15094,10 +15106,11 @@ } }, "node_modules/nan": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", "dev": true, + "license": "MIT", "optional": true }, "node_modules/nanoid": { @@ -18807,10 +18820,10 @@ } }, "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", - "dev": true + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" }, "node_modules/scheduler": { "version": "0.25.0", diff --git a/package.json b/package.json index f1faef9f..4a7eeb4f 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "posthog-node": "^4.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "sax": "^1.4.1", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", "vscode-html-languageservice": "^5.3.1", @@ -149,6 +150,7 @@ "@types/node": "20.x", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/sax": "^1.2.7", "@types/sinon": "^10.0.2", "@types/sinon-test": "^2.4.2", "@types/trusted-types": "^1.0.6", diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 4a2257a3..29bf7796 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -11,13 +11,13 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, chat_systemMessage, voidTools } from '../common/prompt/prompts.js'; +import { chat_userMessageContent, chat_systemMessage, } from '../common/prompt/prompts.js'; import { getErrorMessage, LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; +import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { ToolName, ToolCallParams, ToolResultType, toolNamesThatRequireApproval, InternalToolInfo } from '../common/toolsServiceTypes.js'; +import { ToolName, ToolCallParams, ToolResultType, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js'; import { IToolsService } from './toolsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; @@ -63,6 +63,9 @@ A checkpoint appears before every LLM message, and before every user message (be const toLLMChatMessages = (chatMessages: ChatMessage[]): LLMChatMessage[] => { const llmChatMessages: LLMChatMessage[] = [] + + // merge tools into user message + for (const c of chatMessages) { if (c.role === 'user') { llmChatMessages.push({ role: c.role, content: c.content }) @@ -551,18 +554,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { - private _tools = (chatMode: ChatMode) => { - const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined - : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) - : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] - : undefined - - const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) - return tools - } - - - private readonly errMsgs = { rejected: 'Tool call was rejected by the user.', errWhenStringifying: (error: any) => `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` @@ -704,7 +695,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { // above just defines helpers, below starts the actual function const { chatMode } = this._settingsService.state.globalSettings // should not change as we loop even if user changes it, so it goes here - const tools = this._tools(chatMode) // clear any previous error this._setStreamState(threadId, { error: undefined }, 'set') @@ -736,7 +726,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { const llmCancelToken = this._llmMessageService.sendLLMMessage({ messagesType: 'chatMessages', messages, - tools: tools, modelSelection, modelSelectionOptions, logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, diff --git a/src/vs/workbench/contrib/void/browser/directoryStrService.ts b/src/vs/workbench/contrib/void/browser/directoryStrService.ts index 4cdb8f00..e15b8cc6 100644 --- a/src/vs/workbench/contrib/void/browser/directoryStrService.ts +++ b/src/vs/workbench/contrib/void/browser/directoryStrService.ts @@ -287,6 +287,8 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { let str: string = ''; let cutOff = false; const folders = this.workspaceContextService.getWorkspace().folders; + if (folders.length === 0) + return { str: '(NO WORKSPACE OPEN)', wasCutOff: false }; for (let i = 0; i < folders.length; i += 1) { if (i > 0) str += '\n'; diff --git a/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts b/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts index f09ee3ae..3a131461 100644 --- a/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts +++ b/src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts @@ -3,9 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { OnText } from '../sendLLMMessageTypes.js' import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js' - class SurroundingsRemover { readonly originalS: string i: number @@ -174,7 +172,7 @@ export type ExtractedSearchReplaceBlock = { // JS substring swaps indices, so "ab".substr(1,0) will NOT be '', it will be 'a'! const voidSubstr = (str: string, start: number, end: number) => end < start ? '' : str.substring(start, end) -const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => { +export const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => { // for each prefix for (let i = anyPrefix.length; i >= 1; i--) { // i >= 1 because must not be empty string const prefix = anyPrefix.slice(0, i) @@ -250,122 +248,6 @@ export const extractSearchReplaceBlocks = (str: string) => { -// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true -export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => { - let latestAddIdx = 0 // exclusive index in fullText_ - let foundTag1 = false - let foundTag2 = false - - let fullTextSoFar = '' - let fullReasoningSoFar = '' - - let onText_ = onText - onText = (params) => { - onText_(params) - } - - const newOnText: OnText = ({ fullText: fullText_, ...p }) => { - // until found the first think tag, keep adding to fullText - if (!foundTag1) { - const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) - if (endsWithTag1) { - // console.log('endswith1', { fullTextSoFar, fullReasoningSoFar, fullText_ }) - // wait until we get the full tag or know more - return - } - // if found the first tag - const tag1Index = fullText_.indexOf(thinkTags[0]) - if (tag1Index !== -1) { - // console.log('tag1Index !==1', { tag1Index, fullTextSoFar, fullReasoningSoFar, thinkTags, fullText_ }) - foundTag1 = true - // Add text before the tag to fullTextSoFar - fullTextSoFar += fullText_.substring(0, tag1Index) - // Update latestAddIdx to after the first tag - latestAddIdx = tag1Index + thinkTags[0].length - onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) - return - } - - // console.log('adding to text A', { fullTextSoFar, fullReasoningSoFar }) - // add the text to fullText - fullTextSoFar = fullText_ - latestAddIdx = fullText_.length - onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) - return - } - - // at this point, we found - - // until found the second think tag, keep adding to fullReasoning - if (!foundTag2) { - const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1]) - if (endsWithTag2) { - // console.log('endsWith2', { fullTextSoFar, fullReasoningSoFar }) - // wait until we get the full tag or know more - return - } - - // if found the second tag - const tag2Index = fullText_.indexOf(thinkTags[1], latestAddIdx) - if (tag2Index !== -1) { - // console.log('tag2Index !== -1', { fullTextSoFar, fullReasoningSoFar }) - foundTag2 = true - // Add everything between first and second tag to reasoning - fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index) - // Update latestAddIdx to after the second tag - latestAddIdx = tag2Index + thinkTags[1].length - onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) - return - } - - // add the text to fullReasoning (content after first tag but before second tag) - // console.log('adding to text B', { fullTextSoFar, fullReasoningSoFar }) - - // If we have more text than we've processed, add it to reasoning - if (fullText_.length > latestAddIdx) { - fullReasoningSoFar += fullText_.substring(latestAddIdx) - latestAddIdx = fullText_.length - } - - onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) - return - } - - // at this point, we found - content after the second tag is normal text - // console.log('adding to text C', { fullTextSoFar, fullReasoningSoFar }) - - // Add any new text after the closing tag to fullTextSoFar - if (fullText_.length > latestAddIdx) { - fullTextSoFar += fullText_.substring(latestAddIdx) - latestAddIdx = fullText_.length - } - - onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) - } - - return newOnText -} - - -export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => { - const tag1Idx = fullText_.indexOf(thinkTags[0]) - const tag2Idx = fullText_.indexOf(thinkTags[1]) - if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning - if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning - - const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx) - const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity) - return { fullText, fullReasoning } -} - - - - - - - - - diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index e037114d..a8ade5f7 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -81,7 +81,6 @@ type ModelOptions = { cache_write?: number; } supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated'; - supportsTools: false | 'anthropic-style' | 'openai-style'; supportsFIM: boolean; reasoningCapabilities: false | { @@ -122,7 +121,6 @@ const modelOptionsDefaults: ModelOptions = { maxOutputTokens: 4_096, cost: { input: 0, output: 0 }, supportsSystemMessage: false, - supportsTools: false, supportsFIM: false, reasoningCapabilities: false, } @@ -137,42 +135,36 @@ const openSourceModelOptions_assumingOAICompat = { 'deepseekR1': { supportsFIM: false, supportsSystemMessage: false, - supportsTools: false, reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'deepseekCoderV3': { supportsFIM: false, supportsSystemMessage: false, // unstable - supportsTools: false, reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'deepseekCoderV2': { supportsFIM: false, supportsSystemMessage: false, // unstable - supportsTools: false, reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'codestral': { supportsFIM: true, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'openhands-lm-32b': { // https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, // built on qwen 2.5 32B instruct contextWindow: 128_000, maxOutputTokens: 4_096 }, 'phi4': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: false, reasoningCapabilities: false, contextWindow: 16_000, maxOutputTokens: 4_096, }, @@ -180,7 +172,6 @@ const openSourceModelOptions_assumingOAICompat = { 'gemma': { // https://news.ycombinator.com/item?id=43451406 supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: false, reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, @@ -188,14 +179,12 @@ const openSourceModelOptions_assumingOAICompat = { 'llama4-scout': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 10_000_000, maxOutputTokens: 4_096, }, 'llama4-maverick': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 10_000_000, maxOutputTokens: 4_096, }, @@ -204,28 +193,24 @@ const openSourceModelOptions_assumingOAICompat = { 'llama3': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'llama3.1': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'llama3.2': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'llama3.3': { supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, @@ -233,14 +218,12 @@ const openSourceModelOptions_assumingOAICompat = { 'qwen2.5coder': { supportsFIM: true, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, contextWindow: 32_000, maxOutputTokens: 4_096, }, 'qwq': { supportsFIM: false, // no FIM, yes reasoning supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, contextWindow: 128_000, maxOutputTokens: 8_192, }, @@ -248,7 +231,6 @@ const openSourceModelOptions_assumingOAICompat = { 'starcoder2': { supportsFIM: true, supportsSystemMessage: false, - supportsTools: false, reasoningCapabilities: false, contextWindow: 128_000, maxOutputTokens: 8_192, @@ -256,7 +238,6 @@ const openSourceModelOptions_assumingOAICompat = { 'codegemma:2b': { supportsFIM: true, supportsSystemMessage: false, - supportsTools: false, reasoningCapabilities: false, contextWindow: 128_000, maxOutputTokens: 8_192, @@ -334,7 +315,6 @@ const anthropicModelOptions = { cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', - supportsTools: 'anthropic-style', reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: true, @@ -349,7 +329,6 @@ const anthropicModelOptions = { cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', - supportsTools: 'anthropic-style', reasoningCapabilities: false, }, 'claude-3-5-haiku-20241022': { @@ -358,7 +337,6 @@ const anthropicModelOptions = { cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 }, supportsFIM: false, supportsSystemMessage: 'separated', - supportsTools: 'anthropic-style', reasoningCapabilities: false, }, 'claude-3-opus-20240229': { @@ -367,7 +345,6 @@ const anthropicModelOptions = { cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 }, supportsFIM: false, supportsSystemMessage: 'separated', - supportsTools: 'anthropic-style', reasoningCapabilities: false, }, 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in @@ -375,7 +352,6 @@ const anthropicModelOptions = { maxOutputTokens: 4_096, supportsFIM: false, supportsSystemMessage: 'separated', - supportsTools: 'anthropic-style', reasoningCapabilities: false, } } as const satisfies { [s: string]: ModelOptions } @@ -413,7 +389,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing maxOutputTokens: 100_000, cost: { input: 15.00, cache_read: 7.50, output: 60.00, }, supportsFIM: false, - supportsTools: false, supportsSystemMessage: 'developer-role', reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, // it doesn't actually output reasoning, but our logic is fine with it }, @@ -422,7 +397,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing maxOutputTokens: 100_000, cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, supportsFIM: false, - supportsTools: false, supportsSystemMessage: 'developer-role', reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, }, @@ -431,7 +405,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing maxOutputTokens: 16_384, cost: { input: 2.50, cache_read: 1.25, output: 10.00, }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', reasoningCapabilities: false, }, @@ -440,7 +413,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing maxOutputTokens: 65_536, cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, supportsFIM: false, - supportsTools: false, supportsSystemMessage: false, // does not support any system reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, }, @@ -449,7 +421,6 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing maxOutputTokens: 16_384, cost: { input: 0.15, cache_read: 0.075, output: 0.60, }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', // ?? reasoningCapabilities: false, }, @@ -477,7 +448,6 @@ const xAIModelOptions = { cost: { input: 2.00, output: 10.00 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, } as const satisfies { [s: string]: ModelOptions } @@ -502,7 +472,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 0, output: 0 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini reasoningCapabilities: false, }, 'gemini-2.0-flash': { @@ -511,7 +480,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 0.10, output: 0.40 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini reasoningCapabilities: false, }, 'gemini-2.0-flash-lite-preview-02-05': { @@ -520,7 +488,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 0.075, output: 0.30 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'gemini-1.5-flash': { @@ -529,7 +496,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 0.075, output: 0.30 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'gemini-1.5-pro': { @@ -538,7 +504,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 1.25, output: 5.00 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'gemini-1.5-flash-8b': { @@ -547,7 +512,6 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing cost: { input: 0.0375, output: 0.15 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, } as const satisfies { [s: string]: ModelOptions } @@ -593,7 +557,6 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq cost: { input: 0.59, output: 0.79 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'llama-3.1-8b-instant': { @@ -602,7 +565,6 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq cost: { input: 0.05, output: 0.08 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'qwen-2.5-coder-32b': { @@ -611,7 +573,6 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq cost: { input: 0.79, output: 0.79 }, supportsFIM: false, // unfortunately looks like no FIM support on groq supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'qwen-qwq-32b': { // https://huggingface.co/Qwen/QwQ-32B @@ -620,7 +581,6 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq cost: { input: 0.29, output: 0.39 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false, openSourceThinkTags: ['', ''] }, // we're using reasoning_format:parsed so really don't need to know openSourceThinkTags }, } as const satisfies { [s: string]: ModelOptions } @@ -670,7 +630,6 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0, output: 0 }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', reasoningCapabilities: false, }, @@ -679,7 +638,6 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0, output: 0 }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', reasoningCapabilities: false, }, @@ -688,7 +646,6 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0, output: 0 }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', reasoningCapabilities: false, }, @@ -697,7 +654,6 @@ const openRouterModelOptions_assumingOpenAICompat = { maxOutputTokens: null, cost: { input: 0, output: 0 }, supportsFIM: false, - supportsTools: 'openai-style', supportsSystemMessage: 'system-role', reasoningCapabilities: false, }, @@ -713,7 +669,6 @@ const openRouterModelOptions_assumingOpenAICompat = { cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: { // same as anthropic, see above supportsReasoning: true, canTurnOffReasoning: false, @@ -728,7 +683,6 @@ const openRouterModelOptions_assumingOpenAICompat = { cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, // stupidly, openrouter separates thinking from non-thinking }, 'anthropic/claude-3.5-sonnet': { @@ -737,7 +691,6 @@ const openRouterModelOptions_assumingOpenAICompat = { cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'system-role', - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'mistralai/codestral-2501': { @@ -745,21 +698,18 @@ const openRouterModelOptions_assumingOpenAICompat = { contextWindow: 256_000, maxOutputTokens: null, cost: { input: 0.3, output: 0.9 }, - supportsTools: 'openai-style', reasoningCapabilities: false, }, 'qwen/qwen-2.5-coder-32b-instruct': { ...openSourceModelOptions_assumingOAICompat['qwen2.5coder'], contextWindow: 33_000, maxOutputTokens: null, - supportsTools: false, // openrouter qwen doesn't seem to support tools...? cost: { input: 0.07, output: 0.16 }, }, 'qwen/qwq-32b': { ...openSourceModelOptions_assumingOAICompat['qwq'], contextWindow: 33_000, maxOutputTokens: null, - supportsTools: false, // openrouter qwen doesn't seem to support tools...? cost: { input: 0.07, output: 0.16 }, } } as const satisfies { [s: string]: ModelOptions } diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index c80a0a3b..e84c9437 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -6,7 +6,7 @@ import { os } from '../helpers/systemInfo.js'; import { StagingSelectionItem } from '../chatThreadServiceTypes.js'; import { ChatMode } from '../voidSettingsTypes.js'; -import { InternalToolInfo } from '../toolsServiceTypes.js'; +import { ToolName, toolNamesThatRequireApproval } from '../toolsServiceTypes.js'; import { IVoidModelService } from '../voidModelService.js'; import { EndOfLinePreference } from '../../../../../editor/common/model.js'; @@ -22,7 +22,7 @@ const changesExampleContent = `\ // {{change 3}} // ... existing code ...` -const editToolDescription = `\ +const editToolDescriptionExample = `\ ${tripleTick[0]} ${changesExampleContent} ${tripleTick[1]}` @@ -34,23 +34,32 @@ ${tripleTick[1]}` - - // ======================================================== tools ======================================================== + +export type InternalToolInfo = { + name: string, + description: string, + params: { + [paramName: string]: { description: string } + }, +} + + + const paginationHelper = { desc: `Very large results may be paginated (a note will always be included if pagination took place). Pagination fails gracefully if out of bounds or invalid page number.`, param: { pageNumber: { type: 'number', description: 'The page number (default is the first page = 1).' }, } } as const const uriParam = (object: string) => ({ - uri: { type: 'string', description: `The FULL path to the ${object}.` } + uri: { description: `The FULL path to the ${object} from the root of the file system.` } }) const searchParams = { - searchInFolder: { type: 'string', description: 'Only search files in this given folder. Leave as empty to search all available files.' }, - isRegex: { type: 'string', description: 'Whether to treat the query as a regular expression. Default is "false".' }, + searchInFolder: { description: 'Only search files in this given folder. Leave as empty to search all available files.' }, + isRegex: { description: 'Whether to treat the query as a regular expression. Default is "false".' }, } as const @@ -62,8 +71,8 @@ export const voidTools = { description: `Returns file contents of a given URI. ${paginationHelper.desc}`, params: { ...uriParam('file'), - startLine: { type: 'string', description: 'Line to start reading from. Default is "null", treated as 1.' }, - endLine: { type: 'string', description: 'Line to stop reading from (inclusive). Default is "null", treated as Infinity.' }, + startLine: { description: 'Line to start reading from. Default is "null", treated as 1.' }, + endLine: { description: 'Line to stop reading from (inclusive). Default is "null", treated as Infinity.' }, ...paginationHelper.param, }, }, @@ -89,7 +98,7 @@ export const voidTools = { 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. ${paginationHelper.desc}`, params: { - query: { type: 'string', description: undefined }, + query: { description: `Your query for the search.` }, ...searchParams, ...paginationHelper.param, }, @@ -97,9 +106,9 @@ export const voidTools = { search_files: { name: 'search_files', - description: `Returns all pathnames that match a given \`grep\`-style query (searches ONLY file contents). The query can be any regex. This is often followed by the \`read_file\` tool to view the full file contents of results. ${paginationHelper.desc}`, + description: `Returns all pathnames that match a given query (searches ONLY file contents). The query can be any substring or glob. This is often followed by the \`read_file\` tool to view the full file contents of results. ${paginationHelper.desc}`, params: { - query: { type: 'string', description: undefined }, + query: { description: `Your query for the search.` }, ...searchParams, ...paginationHelper.param, }, @@ -120,7 +129,7 @@ export const voidTools = { description: `Delete a file or folder at the given path. Fails gracefully if the file or folder does not exist.`, params: { ...uriParam('file or folder'), - params: { type: 'string', description: 'Return -r here to delete recursively (if applicable). Default is the empty string.' } + params: { description: 'Return -r here to delete recursively (if applicable). Default is the empty string.' } }, }, @@ -130,12 +139,12 @@ export const voidTools = { params: { ...uriParam('file'), changeDescription: { - type: 'string', description: `\ + description: `\ - Your changeDescription should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. - NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. - Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. - You must output your description in triple backticks. -Here's an example of a good description:\n${editToolDescription}.` +Here's an example of a good description:\n${editToolDescriptionExample}.` } }, }, @@ -144,9 +153,9 @@ Here's an example of a good description:\n${editToolDescription}.` name: 'run_terminal_command', description: `Executes a terminal command.`, params: { - command: { type: 'string', description: 'The terminal command to execute. Typically you should pipe to cat to avoid pagination.' }, - waitForCompletion: { type: 'string', description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` }, - terminalId: { type: 'string', description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' }, + command: { description: 'The terminal command to execute. Typically you should pipe to cat to avoid pagination.' }, + waitForCompletion: { description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` }, + terminalId: { description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' }, }, }, @@ -157,7 +166,61 @@ Here's an example of a good description:\n${editToolDescription}.` } satisfies { [name: string]: InternalToolInfo } +export const availableTools = (chatMode: ChatMode) => { + const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined + : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) + : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] + : undefined + const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) + return tools +} + +const availableToolsStr = (tools: InternalToolInfo[]) => { + return `${tools.map((t, i) => { + const params = Object.keys(t.params).map(paramName => `<${paramName}>\n${t.params[paramName].description}\n`).join('\n') + return `\ +${i}. ${t.name}: ${t.description} +<${t.name}>${!params ? '' : `\n${params}`} +` + }).join('\n\n')}` +} + +const systemToolsPrompt = (chatMode: ChatMode) => { + const tools = availableTools(chatMode) + if (!tools || tools.length === 0) return '' + + return `\ +You are allowed to call tools in your response. +Tool calling guidelines: +${chatMode === 'agent' ? `\ +- Only call tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools. +- ALWAYS use tools to take actions. For example, if you would like to edit a file, you MUST use a tool. +- You will OFTEN need to gather context before making a change. Do not immediately make a change unless you have ALL relevant context. +- ALWAYS have maximal certainty in a change BEFORE you make it. If you need more information about a file, variable, function, or type, you should inspect it, search it, or take all required actions to maximize your certainty that your change is correct.` + : chatMode === 'gather' ? `\ +- Your primary use of tools should be to gather information to help the user understand the codebase and answer their query. +- You should extensively read files, types, content, etc and gather relevant context.` + : chatMode === 'normal' ? '' + : ''} +- If you think you should use tools, you do not need to ask for permission. +- NEVER refer to a tool by name when speaking with the user (NEVER say something like "I'm going to use \`tool_name\`"). Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc. Also do not refer to "pages" of results, just say you're getting more results. +- Some tools only work if the user has a workspace open.${chatMode === 'agent' ? ` +- NEVER modify a file outside the user's workspace(s) without permission from the user.` : ''}\ + +Available tools: +${availableToolsStr(tools)} + +Tool calling details: ${''/* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */} +- To call a tool, just write its name followed by any parameters in XML format. For example: + + value1 + value2 + +- You must write all tool calls at the END of your response. The beginning of your response should be your normal response followed by tool calls at the END. +- You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. +- Tool that you call will be executed immediately, and you will have access to the results in your next response.` +} // ======================================================== chat (normal, gather, agent) ======================================================== @@ -172,30 +235,9 @@ ${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to : ''} You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of files that the user has specifically selected, \`SELECTIONS\`. Please assist the user with their query. The user's query is never invalid. -${/* system info */''} -The user's system information is as follows: -- ${os} -- Open workspace(s): ${workspaceFolders.join(', ') || 'NO WORKSPACE OPEN'} -- Open tab(s): ${openedURIs.join(', ') || 'NO OPENED EDITORS'} -- Active tab: ${activeURI} -${(mode === 'agent') && runningTerminalIds.length !== 0 ? ` -- Existing terminal IDs: ${runningTerminalIds.join(', ')}` : ''} ${/* tool use */ mode === 'agent' || mode === 'gather' ? `\ -You will be given tools you can call. -${mode === 'agent' ? `\ -- Only use tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools. -- ALWAYS use tools to take actions. For example, if you would like to edit a file, you MUST use a tool. -- You will OFTEN need to gather context before making a change. Do not immediately make a change unless you have ALL relevant context. -- ALWAYS have maximal certainty in a change BEFORE you make it. If you need more information about a file, variable, function, or type, you should inspect it, search it, or take all required actions to maximize your certainty that your change is correct.` - : mode === 'gather' ? `\ -- Your primary use of tools should be to gather information to help the user understand the codebase and answer their query. -- You should extensively read files, types, etc and gather relevant context.` - : ''} -- If you think you should use tools, you do not need to ask for permission. Feel free to call tools whenever you'd like. You can use them to understand the codebase, ${mode === 'agent' ? 'run terminal commands, edit files, ' : 'gather relevant files and information, '}etc. -- NEVER refer to a tool by name when speaking with the user (NEVER say something like "I'm going to use \`tool_name\`"). Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc. Also do not refer to "pages" of results, just say you're getting more results. -- Some tools only work if the user has a workspace open.${mode === 'agent' ? ` -- NEVER modify a file outside the user's workspace(s) without permission from the user.` : ''} +${systemToolsPrompt(mode)} \ `: `\ You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it. @@ -218,6 +260,8 @@ If you write a code block that's related to a specific file, please use the same - The remaining contents of the file should proceed as usual. \ `} + + ${/* misc */''} Misc: - Do not make things up. @@ -225,80 +269,22 @@ Misc: - NEVER re-write the entire file. - Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}. - Today's date is ${new Date().toDateString()} -The user's codebase is structured as follows:\n${directoryStr} + +${/* system info */''} +The user's system information is as follows: +- ${os} +- Open workspace(s): ${workspaceFolders.join(', ') || 'NO WORKSPACE OPEN'} +- Open tab(s): ${openedURIs.join(', ') || 'NO OPENED EDITORS'} +- Active tab: ${activeURI} +${(mode === 'agent') && runningTerminalIds.length !== 0 ? ` +- Existing terminal IDs: ${runningTerminalIds.join(', ')}` : ''} +- The user's codebase is structured as follows:\n${directoryStr} + + \ -` -// agent mode doesn't know about 1st line paths yet -// - If you wrote triple ticks and ___, then include the file's full path in the first line of the triple ticks. This is only for display purposes to the user, and it's preferred but optional. Never do this in a tool parameter, or if there's ambiguity about the full path. +`.trim().replace('\t', ' ') -// type FileSelnLocal = { fileURI: URI, language: string, content: string } -// const stringifyFileSelection = ({ fileURI, language, content }: FileSelnLocal) => { -// return `\ -// ${fileURI.fsPath} -// ${tripleTick[0]}${language} -// ${content} -// ${tripleTick[1]} -// ` -// } -// const stringifyCodeSelection = ({ uri, language, range }: StagingSelectionItem & { type: 'CodeSelection' }) => { -// return `\ - -// ${tripleTick[0]}${language} -// ${selectionStr} -// ${tripleTick[1]} -// ` -// } - -// const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.' -// const stringifyFileSelections = async (fileSelections: FileSelection[], voidModelService: IVoidModelService) => { -// if (fileSelections.length === 0) return null -// const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => { -// const { model } = await voidModelService.getModelSafe(sel.fileURI) -// const content = model?.getValue(EndOfLinePreference.LF) ?? failToReadStr -// return { ...sel, content } -// })) -// return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n') -// } - - - - -// export const chat_selectionsString = async ( -// prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, -// voidModelService: IVoidModelService, -// ) => { - -// // ADD IN FILES AT TOP -// const allSelections = [...currSelns || [], ...prevSelns || []] - -// if (allSelections.length === 0) return null - -// for (const selection of allSelections) { -// if (selection.type === 'Selection') { -// codeSelections.push(selection) -// } -// else if (selection.type === 'File') { -// const fileSelection = selection -// const path = fileSelection.fileURI.fsPath -// if (!filesURIs.has(path)) { -// filesURIs.add(path) -// fileSelections.push(fileSelection) -// } -// } -// } - -// const filesStr = await stringifyFileSelections(fileSelections, voidModelService) -// const selnsStr = stringifyCodeSelections(codeSelections) - -// const fileContents = [filesStr, selnsStr].filter(Boolean).join('\n') -// return fileContents || null -// } - -// export const chat_lastUserMessageWithFilesAdded = (userMessage: string, selectionsString: string | null) => { -// if (userMessage) return `${userMessage}${selectionsString ? `\n${selectionsString}` : ''}` -// else return userMessage -// } export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null, opts: { type: 'references' } | { type: 'fullCode', voidModelService: IVoidModelService } @@ -560,6 +546,40 @@ ${tripleTick[1]}).` + +// const toAnthropicTool = (toolInfo: InternalToolInfo) => { +// const { name, description, params } = toolInfo +// return { +// name: name, +// description: description, +// input_schema: { +// type: 'object', +// properties: params, +// // required: Object.keys(params), +// }, +// } satisfies Anthropic.Messages.Tool +// } + + +// const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { +// const { name, description, params } = toolInfo +// return { +// type: 'function', +// function: { +// name: name, +// // strict: true, // strict mode - https://platform.openai.com/docs/guides/function-calling?api-mode=chat +// description: description, +// parameters: { +// type: 'object', +// properties: params, +// // required: Object.keys(params), // in strict mode, all params are required and additionalProperties is false +// // additionalProperties: false, +// }, +// } +// } satisfies OpenAI.Chat.Completions.ChatCompletionTool +// } + + /* // ======================================================== ai search/replace ======================================================== diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index 82df3d26..8378cbd0 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ToolName, InternalToolInfo } from './toolsServiceTypes.js' +import { ToolName } from './toolsServiceTypes.js' import { ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -37,12 +37,6 @@ export type LLMChatMessage = { role: 'assistant', content: string; // text content anthropicReasoning: AnthropicReasoning[] | null; -} | { - role: 'tool'; - content: string; // result - name: string; - params: string; - id: string; } @@ -54,7 +48,7 @@ export type ToolCallType = { export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any }) -export type OnText = (p: { fullText: string; fullReasoning: string; fullToolName: string; fullToolParams: string; }) => void +export type OnText = (p: { fullText: string; fullReasoning: string; }) => void export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCalls?: ToolCallType[]; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id export type OnError = (p: { message: string; fullError: Error | null }) => void export type OnAbort = () => void @@ -70,11 +64,9 @@ export type LLMFIMMessage = { type SendLLMType = { messagesType: 'chatMessages'; messages: LLMChatMessage[]; - tools?: InternalToolInfo[]; } | { messagesType: 'FIMMessage'; messages: LLMFIMMessage; - tools?: undefined; } // service types diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index 980ea587..ce0c4b68 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -3,7 +3,6 @@ import { voidTools } from './prompt/prompts.js'; - export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number } // Partial of IFileStat @@ -14,17 +13,6 @@ export type ShallowDirectoryItem = { isSymbolicLink: boolean; } -// we do this using Anthropic's style and convert to OpenAI style later -export type InternalToolInfo = { - name: string, - description: string, - params: { - [paramName: string]: { type: string, description: string | undefined } // name -> type - }, -} - - - export type ToolName = keyof typeof voidTools export const toolNames = Object.keys(voidTools) as ToolName[] diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts new file mode 100644 index 00000000..67430034 --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -0,0 +1,247 @@ +import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' +import { InternalToolInfo } from '../../common/prompt/prompts.js' +import { OnText } from '../../common/sendLLMMessageTypes.js' +import sax from 'sax' + + +// =========================================== reasoning =========================================== + +// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true +export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => { + let latestAddIdx = 0 // exclusive index in fullText_ + let foundTag1 = false + let foundTag2 = false + + let fullTextSoFar = '' + let fullReasoningSoFar = '' + + let onText_ = onText + onText = (params) => { + onText_(params) + } + + const newOnText: OnText = ({ fullText: fullText_, ...p }) => { + // until found the first think tag, keep adding to fullText + if (!foundTag1) { + const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) + if (endsWithTag1) { + // console.log('endswith1', { fullTextSoFar, fullReasoningSoFar, fullText_ }) + // wait until we get the full tag or know more + return + } + // if found the first tag + const tag1Index = fullText_.indexOf(thinkTags[0]) + if (tag1Index !== -1) { + // console.log('tag1Index !==1', { tag1Index, fullTextSoFar, fullReasoningSoFar, thinkTags, fullText_ }) + foundTag1 = true + // Add text before the tag to fullTextSoFar + fullTextSoFar += fullText_.substring(0, tag1Index) + // Update latestAddIdx to after the first tag + latestAddIdx = tag1Index + thinkTags[0].length + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + return + } + + // console.log('adding to text A', { fullTextSoFar, fullReasoningSoFar }) + // add the text to fullText + fullTextSoFar = fullText_ + latestAddIdx = fullText_.length + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + return + } + + // at this point, we found + + // until found the second think tag, keep adding to fullReasoning + if (!foundTag2) { + const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1]) + if (endsWithTag2) { + // console.log('endsWith2', { fullTextSoFar, fullReasoningSoFar }) + // wait until we get the full tag or know more + return + } + + // if found the second tag + const tag2Index = fullText_.indexOf(thinkTags[1], latestAddIdx) + if (tag2Index !== -1) { + // console.log('tag2Index !== -1', { fullTextSoFar, fullReasoningSoFar }) + foundTag2 = true + // Add everything between first and second tag to reasoning + fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index) + // Update latestAddIdx to after the second tag + latestAddIdx = tag2Index + thinkTags[1].length + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + return + } + + // add the text to fullReasoning (content after first tag but before second tag) + // console.log('adding to text B', { fullTextSoFar, fullReasoningSoFar }) + + // If we have more text than we've processed, add it to reasoning + if (fullText_.length > latestAddIdx) { + fullReasoningSoFar += fullText_.substring(latestAddIdx) + latestAddIdx = fullText_.length + } + + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + return + } + + // at this point, we found - content after the second tag is normal text + // console.log('adding to text C', { fullTextSoFar, fullReasoningSoFar }) + + // Add any new text after the closing tag to fullTextSoFar + if (fullText_.length > latestAddIdx) { + fullTextSoFar += fullText_.substring(latestAddIdx) + latestAddIdx = fullText_.length + } + + onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) + } + + return newOnText +} + + +export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => { + const tag1Idx = fullText_.indexOf(thinkTags[0]) + const tag2Idx = fullText_.indexOf(thinkTags[1]) + if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning + if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning + + const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx) + const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity) + return { fullText, fullReasoning } +} + + +// =========================================== tools =========================================== + +type ToolsState = { + level: 'normal', +} | { + level: 'tool', + toolName: string, + currentToolCall: ToolCall, +} | { + level: 'param', + toolName: string, + paramName: string, + currentToolCall: ToolCall, +} + +export const extractToolsOnTextWrapper = (onText: OnText, availableTools: InternalToolInfo[]) => { + const toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } = {} + for (const t of availableTools) { toolOfToolName[t.name] = t } + + // detect , etc + let fullText = ''; + let trueFullText = '' + const currentToolCalls: ToolCall[] = []; // the answer + + let state: ToolsState = { level: 'normal' } + + const parser = sax.parser(false); + + + // when see open tag + parser.onopentag = (node) => { + const rawNewText = trueFullText.substring(parser.startTagPosition, parser.position) + console.log('raw new text a', rawNewText) + console.log('OPEN!', node.name) + const tagName = node.name; + if (state.level === 'normal') { + if (tagName in toolOfToolName) { // valid toolName + state = { + level: 'tool', + toolName: tagName, + currentToolCall: { name: tagName, parameters: {} } + } + } + else { + fullText += rawNewText // count as plaintext + } + } + else if (state.level === 'tool') { + if (tagName in (toolOfToolName[state.toolName]?.params ?? {})) { // valid param + state = { + level: 'param', + toolName: state.toolName, + paramName: tagName, + currentToolCall: state.currentToolCall, + } + } + else { + // would normally be rawNewText, but we ignore all text inside tools + } + } + else if (state.level === 'param') { + fullText += rawNewText // count as plaintext + } + }; + + parser.ontext = (text) => { + console.log('TEXT!', text) + if (state.level === 'normal') { + fullText += text + } + // start param + else if (state.level === 'tool') { + // ignore all text in a tool, all text should go in the param tags inside it + } + else if (state.level === 'param') { + state.currentToolCall.parameters[state.currentToolCall.name] += text + } + } + + parser.onclosetag = (tagName) => { + const rawNewText = trueFullText.substring(parser.startTagPosition, parser.position) + console.log('raw new text b', rawNewText) + console.log('CLOSE!', tagName) + if (state.level === 'normal') { + fullText += rawNewText + } + else if (state.level === 'tool') { + if (tagName === state.toolName) { // closed the tool + currentToolCalls.push(state.currentToolCall) + state = { + level: 'normal', + } + } + else { // add as text + fullText += rawNewText + } + } + else if (state.level === 'param') { + if (tagName === state.paramName) { // closed the param + state = { + level: 'tool', + toolName: state.toolName, + currentToolCall: state.currentToolCall, + } + } + } + + }; + + const newOnText: OnText = (params) => { + const newText = params.fullText.substring(fullText.length); + console.log('newText', newText) + trueFullText = params.fullText + parser.write(newText) + + console.log('state',) + onText({ + ...params, + fullText, + toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined + }); + }; + + return newOnText; +} + + + + + diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts index f14fb285..f3c532ac 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -23,17 +23,10 @@ type InternalLLMChatMessage = { } | { role: 'assistant', content: string | (AnthropicReasoning | { type: 'text'; text: string })[]; -} | { - role: 'tool'; - content: string; // result - name: string; - params: string; - id: string; } const EMPTY_MESSAGE = '(empty message)' -const EMPTY_TOOL_CONTENT = '(empty content)' const prepareMessages_normalize = ({ messages: messages_ }: { messages: LLMChatMessage[] }): { messages: LLMChatMessage[] } => { const messages = deepClone(messages_) @@ -145,7 +138,7 @@ const prepareMessages_fitIntoContext = ({ messages, contextWindow, maxOutputToke // no matter whether the model supports a system message or not (or what format it supports), add it in some way -const prepareMessages_systemMessage = ({ +const prepareMessages_addSystemInstructions = ({ messages, aiInstructions, supportsSystemMessage, @@ -202,194 +195,8 @@ const prepareMessages_systemMessage = ({ return { messages: newMessages, separateSystemMessageStr } } - - - - -// convert messages as if about to send to openai -/* -reference - https://platform.openai.com/docs/guides/function-calling#function-calling-steps -openai MESSAGE (role=assistant): -"tool_calls":[{ - "type": "function", - "id": "call_12345xyz", - "function": { - "name": "get_weather", - "arguments": "{\"latitude\":48.8566,\"longitude\":2.3522}" -}] - -openai RESPONSE (role=user): -{ "role": "tool", - "tool_call_id": tool_call.id, - "content": str(result) } - -also see -openai on prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting -openai on developer system message - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command -*/ - -type PrepareMessagesToolsOpenAI = ( - Exclude | { - role: 'assistant', - content: string | (AnthropicReasoning | { type: 'text'; text: string })[]; - tool_calls?: { - type: 'function'; - id: string; - function: { - name: string; - arguments: string; - } - }[] - } | { - role: 'tool', - tool_call_id: string; - content: string; - } -)[] -const prepareMessages_tools_openai = ({ messages }: { messages: InternalLLMChatMessage[], }) => { - - const newMessages: PrepareMessagesToolsOpenAI = []; - - for (let i = 0; i < messages.length; i += 1) { - const currMsg = messages[i] - - if (currMsg.role !== 'tool') { - newMessages.push(currMsg) - continue - } - - // edit previous assistant message to have called the tool - const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined - if (prevMsg?.role === 'assistant') { - prevMsg.tool_calls = [{ - type: 'function', - id: currMsg.id, - function: { - name: currMsg.name, - arguments: JSON.stringify(currMsg.params) - } - }] - } - - // add the tool - newMessages.push({ - role: 'tool', - tool_call_id: currMsg.id, - content: currMsg.content || EMPTY_TOOL_CONTENT, - }) - } - return { messages: newMessages } - -} - - -// convert messages as if about to send to anthropic -/* -https://docs.anthropic.com/en/docs/build-with-claude/tool-use#tool-use-examples -anthropic MESSAGE (role=assistant): -"content": [{ - "type": "text", - "text": "I need to call the get_weather function, and the user wants SF, which is likely San Francisco, CA." -}, { - "type": "tool_use", - "id": "toolu_01A09q90qw90lq917835lq9", - "name": "get_weather", - "input": { "location": "San Francisco, CA", "unit": "celsius" } -}] -anthropic RESPONSE (role=user): -"content": [{ - "type": "tool_result", - "tool_use_id": "toolu_01A09q90qw90lq917835lq9", - "content": "15 degrees" -}] -*/ - -type PrepareMessagesToolsAnthropic = ( - Exclude | { - role: 'assistant', - content: string | ( - | AnthropicReasoning - | { - type: 'text'; - text: string; - } - | { - type: 'tool_use'; - name: string; - input: Record; - id: string; - })[] - } | { - role: 'user', - content: string | ({ - type: 'text'; - text: string; - } | { - type: 'tool_result'; - tool_use_id: string; - content: string; - })[] - } -)[] -/* -Converts: - -assistant: ...content -tool: (id, name, params) --> -assistant: ...content, call(name, id, params) -user: ...content, result(id, content) -*/ -const prepareMessages_tools_anthropic = ({ messages }: { messages: InternalLLMChatMessage[], }) => { - const newMessages: PrepareMessagesToolsAnthropic = messages; - - - for (let i = 0; i < newMessages.length; i += 1) { - const currMsg = newMessages[i] - - if (currMsg.role !== 'tool') continue - - const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined - - if (prevMsg?.role === 'assistant') { - if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }] - prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: parseObject(currMsg.params) }) - } - - // turn each tool into a user message with tool results at the end - newMessages[i] = { - role: 'user', - content: [ - ...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content || EMPTY_TOOL_CONTENT }] as const, - ] - } - } - return { messages: newMessages } -} - - - - -type PrepareMessagesTools = PrepareMessagesToolsAnthropic | PrepareMessagesToolsOpenAI - -const prepareMessages_tools = ({ messages, supportsTools }: { messages: InternalLLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }): { messages: PrepareMessagesTools } => { - if (!supportsTools) { - return { messages: messages } - } - else if (supportsTools === 'anthropic-style') { - return prepareMessages_tools_anthropic({ messages }) - } - else if (supportsTools === 'openai-style') { - return prepareMessages_tools_openai({ messages }) - } - else { - throw new Error(`supportsTools type not recognized`) - } -} - - // remove rawAnthropicAssistantContent, and make content equal to it if supportsAnthropicContent -const prepareMessages_anthropicContent = ({ messages, supportsAnthropicReasoningSignature }: { messages: LLMChatMessage[], supportsAnthropicReasoningSignature: boolean }) => { +const prepareMessages_anthropicReasoning = ({ messages, supportsAnthropicReasoningSignature }: { messages: LLMChatMessage[], supportsAnthropicReasoningSignature: boolean }) => { const newMessages: InternalLLMChatMessage[] = [] for (const m of messages) { if (m.role !== 'assistant') { @@ -414,38 +221,18 @@ const prepareMessages_anthropicContent = ({ messages, supportsAnthropicReasoning // do this at end -const prepareMessages_noEmptyMessage = ({ messages }: { messages: PrepareMessagesTools }): { messages: PrepareMessagesTools } => { +const prepareMessages_noEmptyMessage = ({ messages }: { messages: InternalLLMChatMessage[] }): { messages: InternalLLMChatMessage[] } => { for (const currMsg of messages) { - - // don't do this for tools - if (currMsg.role === 'tool') continue - - // don't do this for assistant or user messages that have tool_calls or tool_results - const oai = currMsg as PrepareMessagesToolsOpenAI[0] - if (oai.role === 'assistant') { - if (oai.tool_calls) continue - } - const anth = currMsg as PrepareMessagesToolsAnthropic[0] - if (anth.role === 'assistant' || anth.role === 'user') { - if (typeof anth.content !== 'string') { - const hasContent = anth.content.find(c => c.type === 'tool_use' || c.type === 'tool_result') - if (hasContent) continue - } - } - - - if (typeof currMsg.content === 'string') { + // if content is a string, replace string with empty msg + if (typeof currMsg.content === 'string') currMsg.content = currMsg.content || EMPTY_MESSAGE - } else { + // if content is an array, replace any empty text entries with empty msg, and make sure there's at least 1 entry for (const c of currMsg.content) { if (c.type === 'text') c.text = c.text || EMPTY_MESSAGE - else if (c.type === 'tool_use') { } - else if (c.type === 'tool_result') { } } if (currMsg.content.length === 0) currMsg.content = [{ type: 'text', text: EMPTY_MESSAGE }] } - } return { messages } } @@ -458,7 +245,6 @@ export const prepareMessages = ({ messages, aiInstructions, supportsSystemMessage, - supportsTools, supportsAnthropicReasoningSignature, contextWindow, maxOutputTokens, @@ -466,7 +252,6 @@ export const prepareMessages = ({ messages: LLMChatMessage[], aiInstructions: string, supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', - supportsTools: false | 'anthropic-style' | 'openai-style', supportsAnthropicReasoningSignature: boolean, contextWindow: number, maxOutputTokens: number | null | undefined, @@ -475,13 +260,12 @@ export const prepareMessages = ({ const { messages: messages0 } = prepareMessages_normalize({ messages }) const { messages: messages1 } = prepareMessages_fitIntoContext({ messages: messages0, contextWindow, maxOutputTokens }) - const { messages: messages2 } = prepareMessages_anthropicContent({ messages: messages1, supportsAnthropicReasoningSignature }) - const { messages: messages3, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages2, aiInstructions, supportsSystemMessage }) - const { messages: messages4 } = prepareMessages_tools({ messages: messages3, supportsTools }) - const { messages: messages5 } = prepareMessages_noEmptyMessage({ messages: messages4 }) + const { messages: messages2 } = prepareMessages_anthropicReasoning({ messages: messages1, supportsAnthropicReasoningSignature }) + const { messages: messages3, separateSystemMessageStr } = prepareMessages_addSystemInstructions({ messages: messages2, aiInstructions, supportsSystemMessage }) + const { messages: messages4 } = prepareMessages_noEmptyMessage({ messages: messages3 }) return { - messages: messages5 as any, + messages: messages4 as any, separateSystemMessageStr } as const } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index 05610f6a..7d6274c2 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -7,12 +7,11 @@ import Anthropic from '@anthropic-ai/sdk'; import { Ollama } from 'ollama'; import OpenAI, { ClientOptions } from 'openai'; -import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper } from '../../common/helpers/extractCodeFromResult.js'; import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText } from '../../common/sendLLMMessageTypes.js'; import { defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; -import { InternalToolInfo, ToolName, isAToolName } from '../../common/toolsServiceTypes.js'; +import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; type InternalCommonMessageParams = { @@ -27,7 +26,7 @@ type InternalCommonMessageParams = { _setAborter: (aborter: () => void) => void; } -type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; tools?: InternalToolInfo[] } +type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; } type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; } export type ListParams_Internal = ModelListParams @@ -35,34 +34,6 @@ export type ListParams_Internal = ModelListParams const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayInfoOfProviderName(providerName).title} API key.` // ------------ OPENAI-COMPATIBLE (HELPERS) ------------ -const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { - const { name, description, params } = toolInfo - return { - type: 'function', - function: { - name: name, - strict: true, // strict mode - https://platform.openai.com/docs/guides/function-calling?api-mode=chat - description: description, - parameters: { - type: 'object', - properties: params, - required: Object.keys(params), // in strict mode, all params are required and additionalProperties is false - additionalProperties: false, - }, - } - } satisfies OpenAI.Chat.Completions.ChatCompletionTool -} - -type ToolCallOfIndex = { [index: string]: { name: string, paramsStr: string, id: string } } // type used to stream tool calls as they come in -type ToolCallsFrom_ReturnType = { name: ToolName, id: string, paramsStr: string }[] // return type of toolCallsFrom_ - -const toolCallsFrom_OpenAICompat = (toolCallOfIndex: ToolCallOfIndex): ToolCallsFrom_ReturnType => { - return Object.keys(toolCallOfIndex).map(index => { - const tool = toolCallOfIndex[index] - return isAToolName(tool.name) ? { name: tool.name, id: tool.id, paramsStr: tool.paramsStr } : null - }).filter(t => !!t) -} - const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => { const commonPayloadOpts: ClientOptions = { @@ -152,11 +123,10 @@ const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError -const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { +const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions }: SendChatParams_Internal) => { const { modelName, supportsSystemMessage, - supportsTools, contextWindow, maxOutputTokens, reasoningCapabilities, @@ -169,22 +139,17 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage const reasoningInfo = getSendableReasoningInfo('Chat', providerName, modelName_, modelSelectionOptions) // user's modelName_ here const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {} - // tools - const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAICompatibleTool(tool)) : undefined - const toolsObj = tools ? { tools: tools, tool_choice: 'auto', parallel_tool_calls: false, } as const : {} - // max tokens const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens // instance - const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: false, contextWindow, maxOutputTokens: maxTokens }) + const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsAnthropicReasoningSignature: false, contextWindow, maxOutputTokens: maxTokens }) const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }) const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelName, messages: messages, stream: true, // max_completion_tokens: maxTokens, - ...toolsObj, } // open source models - manually parse think tokens @@ -194,30 +159,18 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage onText = extractReasoningOnTextWrapper(onText, openSourceThinkTags) } + if () + onText = extractToolsOnTextWrapper(onText,) + let fullReasoningSoFar = '' let fullTextSoFar = '' - let fullToolName = '' - let fullToolParams = '' - - const toolCallOfIndex: ToolCallOfIndex = {} openai.chat.completions .create(options) .then(async response => { _setAborter(() => response.controller.abort()) // when receive text for await (const chunk of response) { - // tool call - for (const tool of chunk.choices[0]?.delta?.tool_calls ?? []) { - const index = tool.index - if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', paramsStr: '', id: '' } - toolCallOfIndex[index].name += tool.function?.name ?? '' - toolCallOfIndex[index].paramsStr += tool.function?.arguments ?? ''; - toolCallOfIndex[index].id += tool.id ?? '' - - fullToolName += tool.function?.name ?? '' - fullToolParams += tool.function?.arguments ?? '' - } // message const newText = chunk.choices[0]?.delta?.content ?? '' fullTextSoFar += newText @@ -230,19 +183,18 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage fullReasoningSoFar += newReasoning } - onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, fullToolName, fullToolParams }) + onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) } // on final - const toolCalls = toolCallsFrom_OpenAICompat(toolCallOfIndex) - if (!fullTextSoFar && !fullReasoningSoFar && toolCalls.length === 0) { + if (!fullTextSoFar && !fullReasoningSoFar) { onError({ message: 'Void: Response from model was empty.', fullError: null }) } else { if (manuallyParseReasoning) { const { fullText, fullReasoning } = extractReasoningOnFinalMessage(fullTextSoFar, openSourceThinkTags) - onFinalMessage({ fullText, fullReasoning, toolCalls, anthropicReasoning: null }); + onFinalMessage({ fullText, fullReasoning, anthropicReasoning: null }); } else { - onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, toolCalls, anthropicReasoning: null }); + onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null }); } } }) @@ -292,33 +244,11 @@ const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, // ------------ ANTHROPIC ------------ -const toAnthropicTool = (toolInfo: InternalToolInfo) => { - const { name, description, params } = toolInfo - return { - name: name, - description: description, - input_schema: { - type: 'object', - properties: params, - required: Object.keys(params), - }, - } satisfies Anthropic.Messages.Tool -} - -const toolCallsFrom_Anthropic = (content: Anthropic.Messages.ContentBlock[]): ToolCallsFrom_ReturnType => { - return content.map(c => { - if (c.type !== 'tool_use') return null - if (!isAToolName(c.name)) return null - return c.type === 'tool_use' ? { name: c.name, paramsStr: JSON.stringify(c.input), id: c.id } : null - }).filter(t => !!t) -} - -const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { +const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, aiInstructions }: SendChatParams_Internal) => { const { modelName, supportsSystemMessage, contextWindow, - supportsTools, maxOutputTokens, reasoningCapabilities, } = getModelCapabilities(providerName, modelName_) @@ -330,18 +260,11 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM const reasoningInfo = getSendableReasoningInfo('Chat', providerName, modelName_, modelSelectionOptions) // user's modelName_ here const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {} - // tools - const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined - const toolsObj: Partial = tools ? { - tools: tools, - tool_choice: { type: 'auto', disable_parallel_tool_use: true } // one tool at a time - } : {} - // anthropic-specific - max tokens const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens // instance - const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: true, contextWindow, maxOutputTokens: maxTokens }) + const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsAnthropicReasoningSignature: true, contextWindow, maxOutputTokens: maxTokens }) const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true @@ -352,7 +275,6 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM messages: messages, model: modelName, max_tokens: maxTokens ?? 4_096, // anthropic requires this - ...toolsObj, ...includeInPayload, }) @@ -370,22 +292,22 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM if (e.content_block.type === 'text') { if (fullText) fullText += '\n\n' // starting a 2nd text block fullText += e.content_block.text - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } else if (e.content_block.type === 'thinking') { if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block fullReasoning += e.content_block.thinking - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } else if (e.content_block.type === 'redacted_thinking') { console.log('delta', e.content_block.type) if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block fullReasoning += '[redacted_thinking]' - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } else if (e.content_block.type === 'tool_use') { fullToolName += e.content_block.name ?? '' // anthropic gives us the tool name in the start block - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } } @@ -393,24 +315,23 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM else if (e.type === 'content_block_delta') { if (e.delta.type === 'text_delta') { fullText += e.delta.text - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } else if (e.delta.type === 'thinking_delta') { fullReasoning += e.delta.thinking - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } else if (e.delta.type === 'input_json_delta') { // tool use fullToolParams += e.delta.partial_json ?? '' // anthropic gives us the partial delta (string) here - https://docs.anthropic.com/en/api/messages-streaming - onText({ fullText, fullReasoning, fullToolName, fullToolParams }) + onText({ fullText, fullReasoning, }) } } }) // on done - (or when error/fail) - this is called AFTER last streamEvent stream.on('finalMessage', (response) => { - const toolCalls = toolCallsFrom_Anthropic(response.content) const anthropicReasoning = response.content.filter(c => c.type === 'thinking' || c.type === 'redacted_thinking') - onFinalMessage({ fullText, fullReasoning, toolCalls, anthropicReasoning }) + onFinalMessage({ fullText, fullReasoning, anthropicReasoning }) }) // on error stream.on('error', (error) => { @@ -420,23 +341,6 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM _setAborter(() => stream.controller.abort()) } -// // in future, can do tool_use streaming in anthropic, but it's pretty fast even without streaming... -// const toolCallOfIndex: { [index: string]: { name: string, args: string } } = {} -// stream.on('streamEvent', e => { -// if (e.type === 'content_block_start') { -// if (e.content_block.type !== 'tool_use') return -// const index = e.index -// if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', args: '' } -// toolCallOfIndex[index].name += e.content_block.name ?? '' -// toolCallOfIndex[index].args += e.content_block.input ?? '' -// } -// else if (e.type === 'content_block_delta') { -// if (e.delta.type !== 'input_json_delta') return -// toolCallOfIndex[e.index].args += e.delta.partial_json -// } -// }) - - // ------------ OLLAMA ------------ const newOllamaSDK = ({ endpoint }: { endpoint: string }) => { // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index 87938d62..c5e928c5 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -21,7 +21,6 @@ export const sendLLMMessage = ({ settingsOfProvider, modelSelection, modelSelectionOptions, - tools, }: SendLLMMessageParams, metricsService: IMetricsService @@ -108,7 +107,7 @@ export const sendLLMMessage = ({ } const { sendFIM, sendChat } = implementation if (messagesType === 'chatMessages') { - sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, aiInstructions, tools }) + sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, aiInstructions }) return } if (messagesType === 'FIMMessage') { From 1c5adb96d3c7a90a06669cd52da4605da70a04a8 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Mon, 7 Apr 2025 22:25:07 -0700 Subject: [PATCH 10/27] tool calls via plaintext initial draft --- .../contrib/void/browser/chatThreadService.ts | 100 ++++++++---------- .../contrib/void/browser/editCodeService.ts | 2 + .../react/src/sidebar-tsx/SidebarChat.tsx | 2 +- .../contrib/void/browser/toolsService.ts | 67 ++++-------- .../void/common/chatThreadServiceTypes.ts | 12 +-- .../contrib/void/common/prompt/prompts.ts | 8 +- .../void/common/sendLLMMessageTypes.ts | 21 ++-- .../void/common/voidSettingsService.ts | 2 +- .../llmMessage/extractGrammar.ts | 32 +++--- .../llmMessage/sendLLMMessage.impl.ts | 16 ++- .../llmMessage/sendLLMMessage.ts | 3 +- 11 files changed, 118 insertions(+), 147 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 29bf7796..6a78483b 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { chat_userMessageContent, chat_systemMessage, } from '../common/prompt/prompts.js'; -import { getErrorMessage, LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; +import { getErrorMessage, LLMChatMessage, ParsedToolCallObj, ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; @@ -67,14 +67,18 @@ const toLLMChatMessages = (chatMessages: ChatMessage[]): LLMChatMessage[] => { // merge tools into user message for (const c of chatMessages) { - if (c.role === 'user') { - llmChatMessages.push({ role: c.role, content: c.content }) - } - else if (c.role === 'assistant') + if (c.role === 'assistant') llmChatMessages.push({ role: c.role, content: c.content, anthropicReasoning: c.anthropicReasoning }) - else if (c.role === 'tool') - llmChatMessages.push({ role: c.role, id: c.id, name: c.name, params: c.paramsStr, content: c.content }) - else if (c.role === 'decorative_canceled_tool') { // pass + // merge all tool/user messages into one big user message + else if (c.role === 'user' || c.role === 'tool') { + if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') { + llmChatMessages.push({ role: 'user', content: c.content }) + } + else { + llmChatMessages[llmChatMessages.length - 1].content += '\n\n' + c.content + } + } + else if (c.role === 'interrupted_streaming_tool') { // pass } else if (c.role === 'checkpoint') { // pass } @@ -144,8 +148,7 @@ export type ThreadStreamState = { streamingToken?: string; messageSoFar?: string; reasoningSoFar?: string; - toolNameSoFar?: string; - toolParamsSoFar?: string; + toolCallSoFar?: ParsedToolCallObj; } } @@ -473,8 +476,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { role: 'tool', type: 'running_now', name: lastMsg.name, - paramsStr: lastMsg.paramsStr, - id: lastMsg.id, params: lastMsg.params, content: '(value not received yet...)', // this typically shouldn't ever get read result: null @@ -497,29 +498,13 @@ class ChatThreadService extends Disposable implements IChatThreadService { } else return - const { name, paramsStr, id } = lastMsg + const { name } = lastMsg const errorMessage = this.errMsgs.rejected - this._updateLatestToolTo(threadId, { role: 'tool', type: 'rejected', params: params, name: name, paramsStr: paramsStr, id, content: errorMessage, result: null }) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null }) this._setStreamState(threadId, {}, 'set') } - // private _rejectLatestStreamingTool(threadId: string) { - // const thread = this.state.allThreads[threadId] - // if (!thread) return // should never happen - - // const lastMessage = thread.messages[thread.messages.length - 1] - // if (lastMessage.role !== 'tool') return - // const { name, paramsStr, id, result } = lastMessage - // if (result.type !== 'running_now') return - // const { params } = result - - // const errorMessage = this.errMsgs.rejected - // this._swapOutLatestStreamingToolWithResult(threadId, { role: 'tool', name: name, paramsStr: paramsStr, id, content: errorMessage, result: { type: 'rejected', params: params }, }) - // this._setStreamState(threadId, {}, 'set') - - // } - stopRunning(threadId: string) { const thread = this.state.allThreads[threadId] if (!thread) return // should never happen @@ -536,16 +521,16 @@ class ChatThreadService extends Disposable implements IChatThreadService { // abort the stream first so it doesn't change any state const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' - const toolInProgress = this.streamState[threadId]?.toolNameSoFar - console.log('toolInProgress', toolInProgress) + const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar + console.log('toolInProgress', toolCallSoFar) const llmCancelToken = this.streamState[threadId]?.streamingToken if (llmCancelToken !== undefined) { this._llmMessageService.abort(llmCancelToken) } this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - if (toolInProgress) { - this._addMessageToThread(threadId, { role: 'decorative_canceled_tool', name: toolInProgress }) + if (toolCallSoFar) { + this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) } } @@ -616,26 +601,24 @@ class ChatThreadService extends Disposable implements IChatThreadService { // returns true when the tool call is waiting for user approval const handleToolCall = async ( - tool: ToolCallType, - opts?: { preapproved: true, toolParams: ToolCallParams[ToolName] }, + toolName: ToolName, + opts: { preapproved: true, validatedParams: ToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: ParsedToolParamsObj }, ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { - const toolName: ToolName = tool.name - const toolParamsStr = tool.paramsStr - const toolId = tool.id // compute these below let toolParams: ToolCallParams[ToolName] let toolResult: ToolResultType[typeof toolName] let toolResultStr: string - if (!opts?.preapproved) { // skip this if pre-approved + if (!opts.preapproved) { // skip this if pre-approved // 1. validate tool params try { - const params = await this._toolsService.validateParams[toolName](toolParamsStr) + + const params = await this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) toolParams = params } catch (error) { const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', params: null, result: null, name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, }) + this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', params: null, result: null, name: toolName, content: errorMessage, }) return {} } // once validated, add checkpoint for edit @@ -646,14 +629,14 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (requiresApproval) { const autoApprove = this._settingsService.state.globalSettings.autoApprove // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) - this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(never)', result: null, name: toolName, paramsStr: toolParamsStr, params: toolParams, id: toolId }) + this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(never)', result: null, name: toolName, params: toolParams }) if (!autoApprove) { return { awaitingUserApproval: true } } } } else { - toolParams = opts.toolParams + toolParams = opts.validatedParams } // 3. call the tool @@ -674,7 +657,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { return { interrupted: true } } const errorMessage = getErrorMessage(error) - this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, }) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) return {} } @@ -683,12 +666,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) } catch (error) { const errorMessage = this.errMsgs.errWhenStringifying(error) - this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, }) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) return {} } // 5. add to history and keep going - this._updateLatestToolTo(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, paramsStr: toolParamsStr, id: toolId, content: toolResultStr, }) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, }) return {} }; @@ -706,7 +689,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // before enter loop, call tool if (callThisToolFirst) { - const { interrupted } = await handleToolCall(callThisToolFirst, { preapproved: true, toolParams: callThisToolFirst.params }) + const { interrupted } = await handleToolCall(callThisToolFirst.name, { preapproved: true, validatedParams: callThisToolFirst.params }) if (interrupted) return } @@ -717,27 +700,28 @@ class ChatThreadService extends Disposable implements IChatThreadService { isRunningWhenEnd = undefined nMessagesSent += 1 - let resMessageIsDonePromise: (toolCalls?: ToolCallType[] | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval) - const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) + let resMessageIsDonePromise: (toolCall?: ParsedToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval) + const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) // send llm message this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge') const messages = await getLatestMessages() const llmCancelToken = this._llmMessageService.sendLLMMessage({ messagesType: 'chatMessages', + chatMode, messages, modelSelection, modelSelectionOptions, logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, - onText: ({ fullText, fullReasoning, fullToolName, fullToolParams }) => { - this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolNameSoFar: fullToolName, toolParamsSoFar: fullToolParams }, 'merge') + onText: ({ fullText, fullReasoning, toolCall }) => { + this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge') }, - onFinalMessage: async ({ fullText, toolCalls, fullReasoning, anthropicReasoning }) => { + onFinalMessage: async ({ fullText, toolCall, fullReasoning, anthropicReasoning }) => { this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) // added to history and no longer streaming this, so clear messages so far and streamingToken (but do not stop isRunning) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolNameSoFar: undefined, toolParamsSoFar: undefined }, 'merge') + this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') // resolve with tool calls - resMessageIsDonePromise(toolCalls) + resMessageIsDonePromise(toolCall) }, onError: (error) => { const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' @@ -763,14 +747,14 @@ class ChatThreadService extends Disposable implements IChatThreadService { break } this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message - const toolCalls = await messageIsDonePromise // wait for message to complete + const toolCall = await messageIsDonePromise // wait for message to complete if (aborted) { return } this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done // call tool if there is one - const tool: ToolCallType | undefined = toolCalls?.[0] + const tool: ParsedToolCallObj | undefined = toolCall if (tool) { - const { awaitingUserApproval, interrupted } = await handleToolCall(tool) + const { awaitingUserApproval, interrupted } = await handleToolCall(tool.name, { preapproved: false, unvalidatedToolParams: tool.rawParams }) // stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools. // just detect tool interruption which is the same as chat interruption right now diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index 2b4d2eae..7806cfca 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -1400,6 +1400,7 @@ class EditCodeService extends Disposable implements IEditCodeService { messages, modelSelection, modelSelectionOptions, + chatMode: null, // not chat onText: (params) => { const { fullText: fullText_ } = params const newText_ = fullText_.substring(fullTextSoFar.length, Infinity) @@ -1617,6 +1618,7 @@ class EditCodeService extends Disposable implements IEditCodeService { messages, modelSelection, modelSelectionOptions, + chatMode: null, // not chat onText: (params) => { const { fullText } = params // blocks are [done done done ... {writingFinal|writingOriginal}] diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index bad7832e..f36435d7 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -1921,7 +1921,7 @@ const ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, mes return null } - else if (role === 'decorative_canceled_tool') { + else if (role === 'interrupted_streaming_tool') { return
diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index 1936be08..2a50aeca 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -16,6 +16,7 @@ import { IVoidCommandBarService } from './voidCommandBarService.js' import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from './directoryStrService.js' import { IMarkerService } from '../../../../platform/markers/common/markers.js' import { timeout } from '../../../../base/common/async.js' +import { ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js' // tool use for AI @@ -23,7 +24,7 @@ import { timeout } from '../../../../base/common/async.js' -type ValidateParams = { [T in ToolName]: (p: string) => Promise } +type ValidateParams = { [T in ToolName]: (p: ParsedToolParamsObj) => Promise } type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T], interruptTool?: () => void }> } type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Awaited) => string } @@ -38,25 +39,6 @@ export const TERMINAL_TIMEOUT_TIME = 15 export const TERMINAL_BG_WAIT_TIME = 1 - - - -const validateJSON = (s: string): { [s: string]: unknown } => { - try { - const o = JSON.parse(s) - if (typeof o !== 'object') throw new Error() - - if ('result' in o) { // openrouter sometimes wraps the result with { 'result': ... } - return o.result - } - - return o - } - catch (e) { - throw new Error(`Invalid LLM output format: Tool parameter was not a string of a valid JSON: "${s}".`) - } -} - const isFalsy = (u: unknown) => { return !u || u === 'null' || u === 'undefined' } @@ -172,9 +154,8 @@ export class ToolsService implements IToolsService { const queryBuilder = instantiationService.createInstance(QueryBuilder); this.validateParams = { - read_file: async (params: string) => { - const o = validateJSON(params) - const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = o + read_file: async (params: ParsedToolParamsObj) => { + const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = params const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) @@ -184,27 +165,24 @@ export class ToolsService implements IToolsService { return { uri, startLine, endLine, pageNumber } }, - ls_dir: async (params: string) => { - const o = validateJSON(params) - const { uri: uriStr, pageNumber: pageNumberUnknown } = o + ls_dir: async (params: ParsedToolParamsObj) => { + const { uri: uriStr, pageNumber: pageNumberUnknown } = params const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) return { rootURI: uri, pageNumber } }, - get_dir_structure: async (params: string) => { - const o = validateJSON(params) - const { uri: uriStr, } = o + get_dir_structure: async (params: ParsedToolParamsObj) => { + const { uri: uriStr, } = params const uri = validateURI(uriStr) return { rootURI: uri } }, - search_pathnames_only: async (params: string) => { - const o = validateJSON(params) + search_pathnames_only: async (params: ParsedToolParamsObj) => { const { query: queryUnknown, include: includeUnknown, pageNumber: pageNumberUnknown - } = o + } = params const queryStr = validateStr('query', queryUnknown) const pageNumber = validatePageNum(pageNumberUnknown) @@ -213,14 +191,13 @@ export class ToolsService implements IToolsService { return { queryStr, include, pageNumber } }, - search_files: async (params: string) => { - const o = validateJSON(params) + search_files: async (params: ParsedToolParamsObj) => { const { query: queryUnknown, searchInFolder: searchInFolderUnknown, isRegex: isRegexUnknown, pageNumber: pageNumberUnknown - } = o + } = params const queryStr = validateStr('query', queryUnknown) const pageNumber = validatePageNum(pageNumberUnknown) @@ -233,18 +210,16 @@ export class ToolsService implements IToolsService { // --- - create_file_or_folder: async (params: string) => { - const o = validateJSON(params) - const { uri: uriUnknown } = o + create_file_or_folder: async (params: ParsedToolParamsObj) => { + const { uri: uriUnknown } = params const uri = validateURI(uriUnknown) const uriStr = validateStr('uri', uriUnknown) const isFolder = checkIfIsFolder(uriStr) return { uri, isFolder } }, - delete_file_or_folder: async (params: string) => { - const o = validateJSON(params) - const { uri: uriUnknown, params: paramsStr } = o + delete_file_or_folder: async (params: ParsedToolParamsObj) => { + const { uri: uriUnknown, params: paramsStr } = params const uri = validateURI(uriUnknown) const isRecursive = validateRecursiveParamStr(paramsStr) const uriStr = validateStr('uri', uriUnknown) @@ -252,17 +227,15 @@ export class ToolsService implements IToolsService { return { uri, isRecursive, isFolder } }, - edit_file: async (params: string) => { - const o = validateJSON(params) - const { uri: uriStr, changeDescription: changeDescriptionUnknown } = o + edit_file: async (params: ParsedToolParamsObj) => { + const { uri: uriStr, changeDescription: changeDescriptionUnknown } = params const uri = validateURI(uriStr) const changeDescription = validateStr('changeDescription', changeDescriptionUnknown) return { uri, changeDescription } }, - run_terminal_command: async (s: string) => { - const o = validateJSON(s) - const { command: commandUnknown, terminalId: terminalIdUnknown, waitForCompletion: waitForCompletionUnknown } = o + run_terminal_command: async (params: ParsedToolParamsObj) => { + const { command: commandUnknown, terminalId: terminalIdUnknown, waitForCompletion: waitForCompletionUnknown } = params const command = validateStr('command', commandUnknown) const proposedTerminalId = validateProposedTerminalId(terminalIdUnknown) const waitForCompletion = validateBoolean(waitForCompletionUnknown, { default: true }) diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index 915a3e7d..7cf2ec5d 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -10,8 +10,6 @@ import { ToolName, ToolCallParams, ToolResultType } from './toolsServiceTypes.js export type ToolMessage = { role: 'tool'; - paramsStr: string; // internal use - id: string; // apis require this tool use id content: string; // give this result to LLM (string of value) } & ( // in order of events: @@ -27,18 +25,10 @@ export type ToolMessage = { ) // user rejected export type DecorativeCanceledTool = { - role: 'decorative_canceled_tool'; + role: 'interrupted_streaming_tool'; name: string; } -// export type ToolRequestApproval = { -// role: 'tool_request'; -// name: T; // internal use -// params: ToolCallParams[T]; // internal use -// paramsStr: string; // internal use - this is what the LLM outputted, not necessarily JSON.stringify(params) -// id: string; // proposed tool's id -// } - // checkpoints export type CheckpointEntry = { diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index e84c9437..218aee01 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -212,15 +212,17 @@ Available tools: ${availableToolsStr(tools)} Tool calling details: ${''/* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */} +- Tool calling is optional. - To call a tool, just write its name followed by any parameters in XML format. For example: value1 value2 -- You must write all tool calls at the END of your response. The beginning of your response should be your normal response followed by tool calls at the END. -- You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. -- Tool that you call will be executed immediately, and you will have access to the results in your next response.` +- You must write your tool call at the END of your response. The beginning of your response should be your normal response followed by the tool call at the END. +- You are only allowed to output one tool call per response. +- The tool call will be executed immediately, and you will have access to the results in your next response.` } +// - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. // ======================================================== chat (normal, gather, agent) ======================================================== diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index 8378cbd0..881e6201 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import { ToolName } from './toolsServiceTypes.js' -import { ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' +import { ChatMode, ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' export const errorDetails = (fullError: Error | null): string | null => { @@ -40,16 +40,21 @@ export type LLMChatMessage = { } -export type ToolCallType = { - name: ToolName; - paramsStr: string; - id: string; +export type ParsedToolParamsObj = { + [paramName: string]: string; } +export type ParsedToolCallObj = { + name: ToolName; + rawParams: ParsedToolParamsObj; + doneParams: string[]; + isDone: boolean; +}; + export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any }) -export type OnText = (p: { fullText: string; fullReasoning: string; }) => void -export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCalls?: ToolCallType[]; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id +export type OnText = (p: { fullText: string; fullReasoning: string; toolCall?: ParsedToolCallObj }) => void +export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCall?: ParsedToolCallObj; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id export type OnError = (p: { message: string; fullError: Error | null }) => void export type OnAbort = () => void export type AbortRef = { current: (() => void) | null } @@ -64,9 +69,11 @@ export type LLMFIMMessage = { type SendLLMType = { messagesType: 'chatMessages'; messages: LLMChatMessage[]; + chatMode: ChatMode | null; } | { messagesType: 'FIMMessage'; messages: LLMFIMMessage; + chatMode?: undefined; } // service types diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 47558e71..28fb0fb7 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -95,7 +95,7 @@ const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], opt export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: (o: ModelSelection, opts: { chatMode: ChatMode }) => boolean; emptyMessage: null | { message: string, priority: 'always' | 'fallback' } } } = { 'Autocomplete': { filter: (o) => getModelCapabilities(o.providerName, o.modelName).supportsFIM, emptyMessage: { message: 'No models support FIM', priority: 'always' } }, - 'Chat': { filter: (o, { chatMode }) => chatMode === 'normal' ? true : !!getModelCapabilities(o.providerName, o.modelName).supportsTools, emptyMessage: { message: 'No models support tool use', priority: 'fallback' } }, + 'Chat': { filter: o => true, emptyMessage: null, }, 'Ctrl+K': { filter: o => true, emptyMessage: null, }, 'Apply': { filter: o => true, emptyMessage: null, }, } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index 67430034..35a1ed2f 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -1,10 +1,11 @@ import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' import { InternalToolInfo } from '../../common/prompt/prompts.js' -import { OnText } from '../../common/sendLLMMessageTypes.js' +import { OnText, ParsedToolCallObj } from '../../common/sendLLMMessageTypes.js' import sax from 'sax' +import { ToolName } from '../../common/toolsServiceTypes.js' -// =========================================== reasoning =========================================== +// =============== reasoning =============== // could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => { @@ -115,19 +116,19 @@ export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [st } -// =========================================== tools =========================================== +// =============== tools =============== type ToolsState = { level: 'normal', } | { level: 'tool', toolName: string, - currentToolCall: ToolCall, + currentToolCall: ParsedToolCallObj, } | { level: 'param', toolName: string, paramName: string, - currentToolCall: ToolCall, + currentToolCall: ParsedToolCallObj, } export const extractToolsOnTextWrapper = (onText: OnText, availableTools: InternalToolInfo[]) => { @@ -137,16 +138,20 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern // detect , etc let fullText = ''; let trueFullText = '' - const currentToolCalls: ToolCall[] = []; // the answer + const currentToolCalls: ParsedToolCallObj[] = []; // the answer let state: ToolsState = { level: 'normal' } + + const getRawNewText = () => { + return trueFullText.substring(parser.startTagPosition, parser.position + 1) + } const parser = sax.parser(false); // when see open tag parser.onopentag = (node) => { - const rawNewText = trueFullText.substring(parser.startTagPosition, parser.position) + const rawNewText = getRawNewText() console.log('raw new text a', rawNewText) console.log('OPEN!', node.name) const tagName = node.name; @@ -155,7 +160,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern state = { level: 'tool', toolName: tagName, - currentToolCall: { name: tagName, parameters: {} } + currentToolCall: { name: tagName as ToolName, rawParams: {}, doneParams: [], isDone: false } } } else { @@ -190,12 +195,12 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern // ignore all text in a tool, all text should go in the param tags inside it } else if (state.level === 'param') { - state.currentToolCall.parameters[state.currentToolCall.name] += text + state.currentToolCall.rawParams[state.currentToolCall.name] += text } } parser.onclosetag = (tagName) => { - const rawNewText = trueFullText.substring(parser.startTagPosition, parser.position) + const rawNewText = getRawNewText() console.log('raw new text b', rawNewText) console.log('CLOSE!', tagName) if (state.level === 'normal') { @@ -203,6 +208,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern } else if (state.level === 'tool') { if (tagName === state.toolName) { // closed the tool + state.currentToolCall.isDone = true currentToolCalls.push(state.currentToolCall) state = { level: 'normal', @@ -214,6 +220,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern } else if (state.level === 'param') { if (tagName === state.paramName) { // closed the param + state.currentToolCall.doneParams.push(state.paramName) state = { level: 'tool', toolName: state.toolName, @@ -226,15 +233,14 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern const newOnText: OnText = (params) => { const newText = params.fullText.substring(fullText.length); - console.log('newText', newText) + console.log('newText', state.level, newText) trueFullText = params.fullText parser.write(newText) - console.log('state',) onText({ ...params, fullText, - toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined + toolCall: currentToolCalls.length > 0 ? currentToolCalls[0] : undefined }); }; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index 7d6274c2..b0d5544c 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -8,10 +8,11 @@ import { Ollama } from 'ollama'; import OpenAI, { ClientOptions } from 'openai'; import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText } from '../../common/sendLLMMessageTypes.js'; -import { defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; +import { ChatMode, defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; +import { availableTools } from '../../common/prompt/prompts.js'; type InternalCommonMessageParams = { @@ -26,7 +27,7 @@ type InternalCommonMessageParams = { _setAborter: (aborter: () => void) => void; } -type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; } +type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; chatMode: ChatMode | null; } type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; } export type ListParams_Internal = ModelListParams @@ -123,7 +124,7 @@ const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError -const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions }: SendChatParams_Internal) => { +const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions, chatMode }: SendChatParams_Internal) => { const { modelName, supportsSystemMessage, @@ -159,8 +160,13 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage onText = extractReasoningOnTextWrapper(onText, openSourceThinkTags) } - if () - onText = extractToolsOnTextWrapper(onText,) + // manually parse out tool results + if (chatMode) { + const tools = availableTools(chatMode) + if (tools) { + onText = extractToolsOnTextWrapper(onText, tools) + } + } let fullReasoningSoFar = '' let fullTextSoFar = '' diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index c5e928c5..88bb1ad7 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -21,6 +21,7 @@ export const sendLLMMessage = ({ settingsOfProvider, modelSelection, modelSelectionOptions, + chatMode, }: SendLLMMessageParams, metricsService: IMetricsService @@ -107,7 +108,7 @@ export const sendLLMMessage = ({ } const { sendFIM, sendChat } = implementation if (messagesType === 'chatMessages') { - sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, aiInstructions }) + sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName, _setAborter, providerName, aiInstructions, chatMode }) return } if (messagesType === 'FIMMessage') { From 6f693c4d0a6f86c32965cec10ace19921d81ee96 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Mon, 7 Apr 2025 23:05:03 -0700 Subject: [PATCH 11/27] progress --- .../contrib/void/browser/chatThreadService.ts | 14 ++++---- .../react/src/sidebar-tsx/SidebarChat.tsx | 10 +++--- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 2 +- .../contrib/void/browser/toolsService.ts | 3 +- .../void/common/chatThreadServiceTypes.ts | 3 +- .../contrib/void/common/prompt/prompts.ts | 14 ++++++-- .../void/common/sendLLMMessageTypes.ts | 8 ++--- .../contrib/void/common/toolsServiceTypes.ts | 11 +----- .../llmMessage/extractGrammar.ts | 36 ++++++++++++------- .../llmMessage/sendLLMMessage.impl.ts | 13 +++---- 10 files changed, 65 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 6a78483b..5d79d68a 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -11,13 +11,13 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, chat_systemMessage, } from '../common/prompt/prompts.js'; -import { getErrorMessage, LLMChatMessage, ParsedToolCallObj, ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js'; +import { chat_userMessageContent, chat_systemMessage, ToolName, } from '../common/prompt/prompts.js'; +import { getErrorMessage, LLMChatMessage, RawToolCallObj, ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { ToolName, ToolCallParams, ToolResultType, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js'; +import { ToolCallParams, ToolResultType, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js'; import { IToolsService } from './toolsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; @@ -148,7 +148,7 @@ export type ThreadStreamState = { streamingToken?: string; messageSoFar?: string; reasoningSoFar?: string; - toolCallSoFar?: ParsedToolCallObj; + toolCallSoFar?: RawToolCallObj; } } @@ -700,8 +700,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { isRunningWhenEnd = undefined nMessagesSent += 1 - let resMessageIsDonePromise: (toolCall?: ParsedToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval) - const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) + let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval) + const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) // send llm message this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge') @@ -752,7 +752,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done // call tool if there is one - const tool: ParsedToolCallObj | undefined = toolCall + const tool: RawToolCallObj | undefined = toolCall if (tool) { const { awaitingUserApproval, interrupted } = await handleToolCall(tool.name, { preapproved: false, unvalidatedToolParams: tool.rawParams }) diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index f36435d7..28c59250 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -23,9 +23,10 @@ import { WarningBox } from '../void-settings-tsx/WarningBox.js'; import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../common/modelCapabilities.js'; import { AlertTriangle, Ban, ChevronRight, Dot, Pencil, Undo, Undo2, X } from 'lucide-react'; import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js'; -import { ToolCallParams, ToolName, toolNames, ToolNameWithApproval } from '../../../../common/toolsServiceTypes.js'; +import { ToolCallParams } from '../../../../common/toolsServiceTypes.js'; import { ApplyButtonsHTML, CopyButton, JumpToFileButton, JumpToTerminalButton, StatusIndicatorHTML, useApplyButtonState } from '../markdown/ApplyBlockHoverButtons.js'; import { IsRunningType } from '../../../chatThreadService.js'; +import { ToolName, toolNames } from '../../../../common/prompt/prompts.js'; @@ -2007,9 +2008,8 @@ export const SidebarChat = () => { const messageSoFar = currThreadStreamState?.messageSoFar const reasoningSoFar = currThreadStreamState?.reasoningSoFar - const toolNameSoFar = currThreadStreamState?.toolNameSoFar - const toolParamsSoFar = currThreadStreamState?.toolParamsSoFar - const toolIsGenerating = !!toolNameSoFar && toolNameSoFar === 'edit_file' // show loading for slow tools (right now just edit) + const toolCallSoFar = currThreadStreamState?.toolCallSoFar + const toolIsGenerating = !!toolCallSoFar && toolCallSoFar.name === 'edit_file' // show loading for slow tools (right now just edit) // ----- SIDEBAR CHAT state (local) ----- @@ -2101,7 +2101,7 @@ export const SidebarChat = () => { /> : null - const generatingToolTitle = toolNameSoFar && toolNames.includes(toolNameSoFar as ToolName) ? titleOfToolName[toolNameSoFar as ToolName]?.proposed : toolNameSoFar + const generatingToolTitle = toolCallSoFar && toolNames.includes(toolCallSoFar.name as ToolName) ? titleOfToolName[toolCallSoFar.name as ToolName]?.proposed : toolCallSoFar?.name const messagesHTML = { // secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? ''); // } - const numMessages = pastThread.messages.filter((msg) => msg.role !== 'tool_request').length; + const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length; return (
  • diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index 2a50aeca..d7823531 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -8,7 +8,7 @@ import { QueryBuilder } from '../../../services/search/common/queryBuilder.js' import { ISearchService } from '../../../services/search/common/search.js' import { IEditCodeService } from './editCodeServiceInterface.js' import { ITerminalToolService } from './terminalToolService.js' -import { ToolCallParams, ToolName, ToolResultType } from '../common/toolsServiceTypes.js' +import { ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js' import { IVoidModelService } from '../common/voidModelService.js' import { EndOfLinePreference } from '../../../../editor/common/model.js' import { basename } from '../../../../base/common/path.js' @@ -17,6 +17,7 @@ import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree import { IMarkerService } from '../../../../platform/markers/common/markers.js' import { timeout } from '../../../../base/common/async.js' import { ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js' +import { ToolName } from '../common/prompt/prompts.js' // tool use for AI diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index 7cf2ec5d..9a358c55 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -5,8 +5,9 @@ import { URI } from '../../../../base/common/uri.js'; import { VoidFileSnapshot } from './editCodeServiceTypes.js'; +import { ToolName } from './prompt/prompts.js'; import { AnthropicReasoning } from './sendLLMMessageTypes.js'; -import { ToolName, ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; +import { ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; export type ToolMessage = { role: 'tool'; diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index 218aee01..61ee6524 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -6,7 +6,7 @@ import { os } from '../helpers/systemInfo.js'; import { StagingSelectionItem } from '../chatThreadServiceTypes.js'; import { ChatMode } from '../voidSettingsTypes.js'; -import { ToolName, toolNamesThatRequireApproval } from '../toolsServiceTypes.js'; +import { toolNamesThatRequireApproval } from '../toolsServiceTypes.js'; import { IVoidModelService } from '../voidModelService.js'; import { EndOfLinePreference } from '../../../../../editor/common/model.js'; @@ -153,7 +153,7 @@ Here's an example of a good description:\n${editToolDescriptionExample}.` name: 'run_terminal_command', description: `Executes a terminal command.`, params: { - command: { description: 'The terminal command to execute. Typically you should pipe to cat to avoid pagination.' }, + command: { description: 'The terminal command to execute.' }, waitForCompletion: { description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` }, terminalId: { description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' }, }, @@ -166,6 +166,16 @@ Here's an example of a good description:\n${editToolDescriptionExample}.` } satisfies { [name: string]: InternalToolInfo } +export type ToolName = keyof typeof voidTools +export const toolNames = Object.keys(voidTools) as ToolName[] + +const toolNamesSet = new Set(toolNames) + +export const isAToolName = (toolName: string): toolName is ToolName => { + const isAToolName = toolNamesSet.has(toolName) + return isAToolName +} + export const availableTools = (chatMode: ChatMode) => { const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index 881e6201..49fe38fc 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ToolName } from './toolsServiceTypes.js' +import { ToolName } from './prompt/prompts.js' import { ChatMode, ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -43,7 +43,7 @@ export type LLMChatMessage = { export type ParsedToolParamsObj = { [paramName: string]: string; } -export type ParsedToolCallObj = { +export type RawToolCallObj = { name: ToolName; rawParams: ParsedToolParamsObj; doneParams: string[]; @@ -53,8 +53,8 @@ export type ParsedToolCallObj = { export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any }) -export type OnText = (p: { fullText: string; fullReasoning: string; toolCall?: ParsedToolCallObj }) => void -export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCall?: ParsedToolCallObj; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id +export type OnText = (p: { fullText: string; fullReasoning: string; toolCall?: RawToolCallObj }) => void +export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCall?: RawToolCallObj; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id export type OnError = (p: { message: string; fullError: Error | null }) => void export type OnAbort = () => void export type AbortRef = { current: (() => void) | null } diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index ce0c4b68..383dab54 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -1,5 +1,5 @@ import { URI } from '../../../../base/common/uri.js' -import { voidTools } from './prompt/prompts.js'; +import { ToolName } from './prompt/prompts.js'; @@ -14,15 +14,6 @@ export type ShallowDirectoryItem = { } -export type ToolName = keyof typeof voidTools -export const toolNames = Object.keys(voidTools) as ToolName[] - -const toolNamesSet = new Set(toolNames) -export const isAToolName = (toolName: string): toolName is ToolName => { - const isAToolName = toolNamesSet.has(toolName) - return isAToolName -} - const toolNamesWithApproval = ['create_file_or_folder', 'delete_file_or_folder', 'edit_file', 'run_terminal_command'] as const satisfies readonly ToolName[] export type ToolNameWithApproval = typeof toolNamesWithApproval[number] diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index 35a1ed2f..33ecc5b2 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -1,8 +1,8 @@ import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' -import { InternalToolInfo } from '../../common/prompt/prompts.js' -import { OnText, ParsedToolCallObj } from '../../common/sendLLMMessageTypes.js' +import { availableTools, InternalToolInfo, ToolName } from '../../common/prompt/prompts.js' +import { OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js' +import { ChatMode } from '../../common/voidSettingsTypes.js' import sax from 'sax' -import { ToolName } from '../../common/toolsServiceTypes.js' // =============== reasoning =============== @@ -123,22 +123,25 @@ type ToolsState = { } | { level: 'tool', toolName: string, - currentToolCall: ParsedToolCallObj, + currentToolCall: RawToolCallObj, } | { level: 'param', toolName: string, paramName: string, - currentToolCall: ParsedToolCallObj, + currentToolCall: RawToolCallObj, } -export const extractToolsOnTextWrapper = (onText: OnText, availableTools: InternalToolInfo[]) => { +export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => { + const tools = availableTools(chatMode) + if (!tools) return onText + const toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } = {} - for (const t of availableTools) { toolOfToolName[t.name] = t } + for (const t of tools) { toolOfToolName[t.name] = t } // detect , etc let fullText = ''; let trueFullText = '' - const currentToolCalls: ParsedToolCallObj[] = []; // the answer + const currentToolCalls: RawToolCallObj[] = []; // the answer let state: ToolsState = { level: 'normal' } @@ -146,7 +149,9 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern const getRawNewText = () => { return trueFullText.substring(parser.startTagPosition, parser.position + 1) } - const parser = sax.parser(false); + const parser = sax.parser(false, { + lowercase: true, + }); // when see open tag @@ -186,7 +191,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern }; parser.ontext = (text) => { - console.log('TEXT!', text) + console.log('TEXT!', JSON.stringify(text)) if (state.level === 'normal') { fullText += text } @@ -227,16 +232,23 @@ export const extractToolsOnTextWrapper = (onText: OnText, availableTools: Intern currentToolCall: state.currentToolCall, } } + else { + fullText += rawNewText + } } }; + let prevFullTextLen = 0 const newOnText: OnText = (params) => { - const newText = params.fullText.substring(fullText.length); - console.log('newText', state.level, newText) + const newText = params.fullText.substring(prevFullTextLen) + prevFullTextLen = params.fullText.length trueFullText = params.fullText + + console.log('newText', newText.length) parser.write(newText) + console.log('calling ontext...') onText({ ...params, fullText, diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index b0d5544c..c8af7ff4 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -12,7 +12,6 @@ import { ChatMode, defaultProviderSettings, displayInfoOfProviderName, ModelSele import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; -import { availableTools } from '../../common/prompt/prompts.js'; type InternalCommonMessageParams = { @@ -162,10 +161,7 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage // manually parse out tool results if (chatMode) { - const tools = availableTools(chatMode) - if (tools) { - onText = extractToolsOnTextWrapper(onText, tools) - } + onText = extractToolsOnTextWrapper(onText, chatMode) } let fullReasoningSoFar = '' @@ -250,7 +246,7 @@ const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, // ------------ ANTHROPIC ------------ -const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, aiInstructions }: SendChatParams_Internal) => { +const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, aiInstructions, chatMode }: SendChatParams_Internal) => { const { modelName, supportsSystemMessage, @@ -284,6 +280,11 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM ...includeInPayload, }) + // manually parse out tool results + if (chatMode) { + onText = extractToolsOnTextWrapper(onText, chatMode) + } + // when receive text let fullText = '' let fullReasoning = '' From 39bf2283cce893e6f18840bf33fb81ac9d7abdd9 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 02:03:25 -0700 Subject: [PATCH 12/27] GPTify sax library --- .../void/electron-main/llmMessage/sax.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts new file mode 100644 index 00000000..9fce5a2c --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts @@ -0,0 +1,130 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// Define options for the parser. +export interface SaxParserOptions { + lowercase?: boolean; +} + +// Define the structure for a parsed node. +export interface SaxNode { + name: string; + attributes: { [key: string]: string }; +} + +// Define the interface for the SAX-like parser. +export interface SaxParser { + // Event handlers that can be set by the consumer. + onopentag: ((node: SaxNode) => void) | null; + ontext: ((text: string) => void) | null; + onclosetag: ((tagName: string) => void) | null; + // Properties to track current positions (used for raw text extraction). + startTagPosition: number; + position: number; + // Processes a new chunk of text. + write(chunk: string): void; +} + +/** + * Creates a minimal, event-driven SAX-like parser. + * + * @param options An object of type `SaxParserOptions`. Passing `{ lowercase: true }` will force all tag names to be lower-cased. + * @returns A parser object implementing the `SaxParser` interface. + */ +export function createSaxParser(options: SaxParserOptions = {}): SaxParser { + // Buffer to hold any leftover text (part of an incomplete tag). + let buffer: string = ''; + // Global counter to track the total processed characters. + let globalPos: number = 0; + + const parser: SaxParser = { + onopentag: null, + ontext: null, + onclosetag: null, + startTagPosition: 0, + position: 0, + + write(chunk: string): void { + // Set the starting position before processing the new chunk. + this.startTagPosition = globalPos; + buffer += chunk; + globalPos += chunk.length; + // Set the current position to the end of the processed chunk. + this.position = globalPos - 1; + + let cursor: number = 0; + while (cursor < buffer.length) { + // Look for the next opening '<' character. + const ltIndex = buffer.indexOf('<', cursor); + if (ltIndex === -1) { + // No more tags found. Emit any remaining text as a text node. + if (cursor < buffer.length && this.ontext) { + this.ontext(buffer.substring(cursor)); + } + // Clear the buffer once all content is processed. + buffer = ''; + break; + } + + // Emit any text that appears before the tag. + if (ltIndex > cursor && this.ontext) { + this.ontext(buffer.substring(cursor, ltIndex)); + } + + // Look for the closing '>' character. + const gtIndex = buffer.indexOf('>', ltIndex); + if (gtIndex === -1) { + // Incomplete tag detected—retain the remaining content in the buffer. + buffer = buffer.substring(ltIndex); + break; + } + + // Extract the tag content (excluding the '<' and '>'). + let tagContent = buffer.substring(ltIndex + 1, gtIndex).trim(); + if (!tagContent) { + cursor = gtIndex + 1; + continue; + } + + // Check if this is a closing tag (starts with '/'). + if (tagContent[0] === '/') { + let tagName = tagContent.substring(1).trim(); + if (options.lowercase && tagName) { + tagName = tagName.toLowerCase(); + } + if (this.onclosetag) { + this.onclosetag(tagName); + } + } else { + // Check for self-closing tags (ending with '/'). + let selfClosing = false; + if (tagContent[tagContent.length - 1] === '/') { + selfClosing = true; + tagContent = tagContent.slice(0, -1).trim(); + } + // Determine the tag name (first word before whitespace). + const spaceIndex = tagContent.indexOf(' '); + let tagName = (spaceIndex !== -1 ? tagContent.substring(0, spaceIndex) : tagContent).trim(); + if (options.lowercase && tagName) { + tagName = tagName.toLowerCase(); + } + // Call onopentag with a minimal node object. + if (this.onopentag) { + const node: SaxNode = { name: tagName, attributes: {} }; + this.onopentag(node); + } + // If the tag is self-closing, immediately emit the closing tag event. + if (selfClosing && this.onclosetag) { + this.onclosetag(tagName); + } + } + // Move the cursor past the current tag. + cursor = gtIndex + 1; + } + }, + }; + + return parser; +} From 4e197f46590dfe42958f0298996be13968c4e412 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 02:04:08 -0700 Subject: [PATCH 13/27] debug --- .../contrib/void/browser/chatThreadService.ts | 8 +- .../contrib/void/browser/toolsService.ts | 1 - .../contrib/void/common/prompt/prompts.ts | 17 ++- .../void/common/sendLLMMessageTypes.ts | 2 +- .../llmMessage/extractGrammar.ts | 120 ++++++++++++------ .../void/electron-main/llmMessage/sax.ts | 8 +- .../llmMessage/sendLLMMessage.impl.ts | 21 +-- 7 files changed, 113 insertions(+), 64 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 5d79d68a..f2b28552 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -586,6 +586,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const runningTerminalIds = this._terminalToolService.listTerminalIds() const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, runningTerminalIds, chatMode }) + // console.log('SYSTEM MESSAGE', systemMessage) // all messages so far in the chat history (including tools) const messages: LLMChatMessage[] = [ { role: 'system', content: systemMessage, }, @@ -613,6 +614,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (!opts.preapproved) { // skip this if pre-approved // 1. validate tool params try { + console.log('VALIDATING PARAMS!!!', opts.unvalidatedToolParams) const params = await this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) toolParams = params @@ -716,12 +718,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { onText: ({ fullText, fullReasoning, toolCall }) => { this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge') }, - onFinalMessage: async ({ fullText, toolCall, fullReasoning, anthropicReasoning }) => { + onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => { this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) - // added to history and no longer streaming this, so clear messages so far and streamingToken (but do not stop isRunning) this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') - // resolve with tool calls - resMessageIsDonePromise(toolCall) + resMessageIsDonePromise(toolCall) // resolve with tool calls }, onError: (error) => { const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index d7823531..e088d556 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -157,7 +157,6 @@ export class ToolsService implements IToolsService { this.validateParams = { read_file: async (params: ParsedToolParamsObj) => { const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = params - const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index 61ee6524..6e71724b 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -188,9 +188,11 @@ export const availableTools = (chatMode: ChatMode) => { const availableToolsStr = (tools: InternalToolInfo[]) => { return `${tools.map((t, i) => { - const params = Object.keys(t.params).map(paramName => `<${paramName}>\n${t.params[paramName].description}\n`).join('\n') + const params = Object.keys(t.params).map(paramName => ` <${paramName}>\n${t.params[paramName].description}\n `).join('\n') return `\ -${i}. ${t.name}: ${t.description} +${i}. ${t.name} +Description: ${t.description} +Format: <${t.name}>${!params ? '' : `\n${params}`} ` }).join('\n\n')}` @@ -225,11 +227,16 @@ Tool calling details: ${''/* We expect tools to come at the end - not a hard li - Tool calling is optional. - To call a tool, just write its name followed by any parameters in XML format. For example: - value1 - value2 + +value1 + + +value2 + -- You must write your tool call at the END of your response. The beginning of your response should be your normal response followed by the tool call at the END. +- You must write your tool call at the END of your response. The beginning of your response should be normal text, explanations, etc (if you decide to write anything), followed by the tool call at the END. - You are only allowed to output one tool call per response. +- You may omit optional parameters. - The tool call will be executed immediately, and you will have access to the results in your next response.` } // - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index 49fe38fc..de65b21d 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -41,7 +41,7 @@ export type LLMChatMessage = { export type ParsedToolParamsObj = { - [paramName: string]: string; + [paramName: string]: string | undefined; } export type RawToolCallObj = { name: ToolName; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index 33ecc5b2..8fb20d3d 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -1,14 +1,21 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' import { availableTools, InternalToolInfo, ToolName } from '../../common/prompt/prompts.js' -import { OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js' +import { OnFinalMessage, OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js' import { ChatMode } from '../../common/voidSettingsTypes.js' -import sax from 'sax' +import { createSaxParser } from './sax.js' // =============== reasoning =============== // could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true -export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => { +export const extractReasoningOnTextWrapper = ( + onText: OnText, onFinalMessage: OnFinalMessage, thinkTags: [string, string] +): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => { let latestAddIdx = 0 // exclusive index in fullText_ let foundTag1 = false let foundTag2 = false @@ -100,19 +107,26 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar }) } - return newOnText -} + const getOnFinalMessageParams = () => { + const fullText_ = fullTextSoFar + const tag1Idx = fullText_.indexOf(thinkTags[0]) + const tag2Idx = fullText_.indexOf(thinkTags[1]) + if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning + if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning -export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => { - const tag1Idx = fullText_.indexOf(thinkTags[0]) - const tag2Idx = fullText_.indexOf(thinkTags[1]) - if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning - if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning + const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx) + const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity) - const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx) - const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity) - return { fullText, fullReasoning } + return { fullText, fullReasoning } + } + + const newOnFinalMessage: OnFinalMessage = (params) => { + const { fullText, fullReasoning } = getOnFinalMessageParams() + onFinalMessage({ ...params, fullText, fullReasoning }) + } + + return { newOnText, newOnFinalMessage } } @@ -131,9 +145,11 @@ type ToolsState = { currentToolCall: RawToolCallObj, } -export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => { +export const extractToolsOnTextWrapper = ( + onText: OnText, onFinalMessage: OnFinalMessage, chatMode: ChatMode +): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => { const tools = availableTools(chatMode) - if (!tools) return onText + if (!tools) return { newOnText: onText, newOnFinalMessage: onFinalMessage } const toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } = {} for (const t of tools) { toolOfToolName[t.name] = t } @@ -149,17 +165,15 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => const getRawNewText = () => { return trueFullText.substring(parser.startTagPosition, parser.position + 1) } - const parser = sax.parser(false, { - lowercase: true, - }); - + const parser = createSaxParser({ lowercase: true }) // when see open tag parser.onopentag = (node) => { const rawNewText = getRawNewText() - console.log('raw new text a', rawNewText) - console.log('OPEN!', node.name) const tagName = node.name; + console.log('OPENING', tagName) + console.log('state0:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName }) + if (state.level === 'normal') { if (tagName in toolOfToolName) { // valid toolName state = { @@ -170,6 +184,8 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => } else { fullText += rawNewText // count as plaintext + console.log('adding raw a', rawNewText) + } } else if (state.level === 'tool') { @@ -185,31 +201,25 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => // would normally be rawNewText, but we ignore all text inside tools } } - else if (state.level === 'param') { + else if (state.level === 'param') { // cannot double nest fullText += rawNewText // count as plaintext - } - }; + console.log('adding raw b', rawNewText) - parser.ontext = (text) => { - console.log('TEXT!', JSON.stringify(text)) - if (state.level === 'normal') { - fullText += text } - // start param - else if (state.level === 'tool') { - // ignore all text in a tool, all text should go in the param tags inside it - } - else if (state.level === 'param') { - state.currentToolCall.rawParams[state.currentToolCall.name] += text - } - } + + console.log('state1:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName }) + + }; parser.onclosetag = (tagName) => { const rawNewText = getRawNewText() - console.log('raw new text b', rawNewText) - console.log('CLOSE!', tagName) + console.log('CLOSING', tagName) + console.log('state0:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName }) + + if (state.level === 'normal') { fullText += rawNewText + console.log('adding raw A', rawNewText) } else if (state.level === 'tool') { if (tagName === state.toolName) { // closed the tool @@ -221,6 +231,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => } else { // add as text fullText += rawNewText + console.log('adding raw B', rawNewText) } } else if (state.level === 'param') { @@ -234,21 +245,40 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => } else { fullText += rawNewText + console.log('adding raw C', rawNewText) + } } + console.log('state1:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName }) + }; + + parser.ontext = (text) => { + if (state.level === 'normal') { + fullText += text + } + // start param + else if (state.level === 'tool') { + // ignore all text in a tool, all text should go in the param tags inside it + } + else if (state.level === 'param') { + if (!(state.paramName in state.currentToolCall.rawParams)) state.currentToolCall.rawParams[state.paramName] = '' + state.currentToolCall.rawParams[state.paramName] += text + } + } + + + let prevFullTextLen = 0 const newOnText: OnText = (params) => { const newText = params.fullText.substring(prevFullTextLen) prevFullTextLen = params.fullText.length trueFullText = params.fullText - console.log('newText', newText.length) parser.write(newText) - console.log('calling ontext...') onText({ ...params, fullText, @@ -256,7 +286,15 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => }); }; - return newOnText; + + const newOnFinalMessage: OnFinalMessage = (params) => { + console.log('final message!!!', trueFullText) + console.log('----- returning ----\n', fullText) + console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) + onFinalMessage({ ...params, fullText, toolCall: currentToolCalls[0] }) + } + + return { newOnText, newOnFinalMessage }; } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts index 9fce5a2c..e27e0753 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts @@ -63,7 +63,7 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser { if (cursor < buffer.length && this.ontext) { this.ontext(buffer.substring(cursor)); } - // Clear the buffer once all content is processed. + // Clear the buffer since all content is processed. buffer = ''; break; } @@ -123,7 +123,11 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser { // Move the cursor past the current tag. cursor = gtIndex + 1; } - }, + + // Remove any content already processed from the buffer. + buffer = buffer.slice(cursor); + } + }; return parser; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index c8af7ff4..3f49d017 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -11,7 +11,7 @@ import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, On import { ChatMode, defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; -import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; +import { extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; type InternalCommonMessageParams = { @@ -156,12 +156,16 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {} const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags if (manuallyParseReasoning) { - onText = extractReasoningOnTextWrapper(onText, openSourceThinkTags) + const { newOnText, newOnFinalMessage } = extractReasoningOnTextWrapper(onText, onFinalMessage, openSourceThinkTags) + onText = newOnText + onFinalMessage = newOnFinalMessage } // manually parse out tool results if (chatMode) { - onText = extractToolsOnTextWrapper(onText, chatMode) + const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode) + onText = newOnText + onFinalMessage = newOnFinalMessage } let fullReasoningSoFar = '' @@ -192,12 +196,7 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage onError({ message: 'Void: Response from model was empty.', fullError: null }) } else { - if (manuallyParseReasoning) { - const { fullText, fullReasoning } = extractReasoningOnFinalMessage(fullTextSoFar, openSourceThinkTags) - onFinalMessage({ fullText, fullReasoning, anthropicReasoning: null }); - } else { - onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null }); - } + onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null }); } }) // when error/fail - this catches errors of both .create() and .then(for await) @@ -282,7 +281,9 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM // manually parse out tool results if (chatMode) { - onText = extractToolsOnTextWrapper(onText, chatMode) + const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode) + onText = newOnText + onFinalMessage = newOnFinalMessage } // when receive text From e331fb4eed352a5d7152129547962a19ef1d8d3c Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 02:44:05 -0700 Subject: [PATCH 14/27] trim whitespace + fix onFinalMessage last chunk error --- .../contrib/void/browser/chatThreadService.ts | 1 + .../llmMessage/extractGrammar.ts | 40 +++++++++++++++++-- .../llmMessage/sendLLMMessage.impl.ts | 28 ++++++------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index f2b28552..966749e9 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -721,6 +721,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => { this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') + console.log('tool call!!', toolCall) resMessageIsDonePromise(toolCall) // resolve with tool calls }, onError: (error) => { diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index 8fb20d3d..dc6b66c9 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -13,7 +13,7 @@ import { createSaxParser } from './sax.js' // =============== reasoning =============== // could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true -export const extractReasoningOnTextWrapper = ( +export const extractReasoningWrapper = ( onText: OnText, onFinalMessage: OnFinalMessage, thinkTags: [string, string] ): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => { let latestAddIdx = 0 // exclusive index in fullText_ @@ -122,6 +122,10 @@ export const extractReasoningOnTextWrapper = ( } const newOnFinalMessage: OnFinalMessage = (params) => { + + // treat like just got text before calling onFinalMessage (or else we sometimes miss the final chunk that's new to finalMessage) + newOnText({ ...params }) + const { fullText, fullReasoning } = getOnFinalMessageParams() onFinalMessage({ ...params, fullText, fullReasoning }) } @@ -145,7 +149,7 @@ type ToolsState = { currentToolCall: RawToolCallObj, } -export const extractToolsOnTextWrapper = ( +export const extractToolsWrapper = ( onText: OnText, onFinalMessage: OnFinalMessage, chatMode: ChatMode ): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => { const tools = availableTools(chatMode) @@ -288,16 +292,44 @@ export const extractToolsOnTextWrapper = ( const newOnFinalMessage: OnFinalMessage = (params) => { + // treat like just got text before calling onFinalMessage (or else we sometimes miss the final chunk that's new to finalMessage) + newOnText({ ...params }) + console.log('final message!!!', trueFullText) console.log('----- returning ----\n', fullText) console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) - onFinalMessage({ ...params, fullText, toolCall: currentToolCalls[0] }) - } + fullText = fullText.trimEnd() + const toolCall = currentToolCalls[0] + if (toolCall) { + // trim off all whitespace at and before first \n and after last \n for each param + for (const paramName in toolCall.rawParams) { + const orig = toolCall.rawParams[paramName] + if (orig === undefined) continue + toolCall.rawParams[paramName] = trimBeforeAndAfterNewLines(orig) + } + } + onFinalMessage({ ...params, fullText, toolCall: currentToolCalls.length > 0 ? currentToolCalls[0] : undefined }) + } return { newOnText, newOnFinalMessage }; } +// trim all whitespace up until the first newline, and all whitespace after the last newline +const trimBeforeAndAfterNewLines = (s: string) => { + if (!s) return s; + const firstNewLineIndex = s.indexOf('\n'); + if (firstNewLineIndex !== -1 && s.substring(0, firstNewLineIndex).trim() === '') { + s = s.substring(firstNewLineIndex + 1, Infinity) + } + + const lastNewLineIndex = s.lastIndexOf('\n'); + if (lastNewLineIndex !== -1 && s.substring(lastNewLineIndex + 1, Infinity).trim() === '') { + s = s.substring(0, lastNewLineIndex) + } + + return s +} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index 3f49d017..edcc1a9a 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -11,7 +11,7 @@ import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, On import { ChatMode, defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js'; -import { extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js'; +import { extractReasoningWrapper, extractToolsWrapper } from './extractGrammar.js'; type InternalCommonMessageParams = { @@ -156,14 +156,14 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {} const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags if (manuallyParseReasoning) { - const { newOnText, newOnFinalMessage } = extractReasoningOnTextWrapper(onText, onFinalMessage, openSourceThinkTags) + const { newOnText, newOnFinalMessage } = extractReasoningWrapper(onText, onFinalMessage, openSourceThinkTags) onText = newOnText onFinalMessage = newOnFinalMessage } // manually parse out tool results if (chatMode) { - const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode) + const { newOnText, newOnFinalMessage } = extractToolsWrapper(onText, onFinalMessage, chatMode) onText = newOnText onFinalMessage = newOnFinalMessage } @@ -281,7 +281,7 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM // manually parse out tool results if (chatMode) { - const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode) + const { newOnText, newOnFinalMessage } = extractToolsWrapper(onText, onFinalMessage, chatMode) onText = newOnText onFinalMessage = newOnFinalMessage } @@ -290,8 +290,8 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM let fullText = '' let fullReasoning = '' - let fullToolName = '' - let fullToolParams = '' + // let fullToolName = '' + // let fullToolParams = '' // there are no events for tool_use, it comes in at the end stream.on('streamEvent', e => { @@ -313,10 +313,10 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM fullReasoning += '[redacted_thinking]' onText({ fullText, fullReasoning, }) } - else if (e.content_block.type === 'tool_use') { - fullToolName += e.content_block.name ?? '' // anthropic gives us the tool name in the start block - onText({ fullText, fullReasoning, }) - } + // else if (e.content_block.type === 'tool_use') { + // fullToolName += e.content_block.name ?? '' // anthropic gives us the tool name in the start block + // onText({ fullText, fullReasoning, }) + // } } // delta @@ -329,10 +329,10 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM fullReasoning += e.delta.thinking onText({ fullText, fullReasoning, }) } - else if (e.delta.type === 'input_json_delta') { // tool use - fullToolParams += e.delta.partial_json ?? '' // anthropic gives us the partial delta (string) here - https://docs.anthropic.com/en/api/messages-streaming - onText({ fullText, fullReasoning, }) - } + // else if (e.delta.type === 'input_json_delta') { // tool use + // fullToolParams += e.delta.partial_json ?? '' // anthropic gives us the partial delta (string) here - https://docs.anthropic.com/en/api/messages-streaming + // onText({ fullText, fullReasoning, }) + // } } }) From 052a50f9b02f7cbddbb8f329f581a96fdb54e266 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 03:20:34 -0700 Subject: [PATCH 15/27] misc fixes + clarify displayContent --- .../contrib/void/browser/chatThreadService.ts | 28 ++++++----- .../react/src/sidebar-tsx/SidebarChat.tsx | 14 +++--- .../void/common/chatThreadServiceTypes.ts | 2 +- .../llmMessage/extractGrammar.ts | 10 +++- .../void/electron-main/llmMessage/sax.ts | 48 ++++++++++++------- 5 files changed, 63 insertions(+), 39 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 966749e9..d5d8fe80 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -68,15 +68,17 @@ const toLLMChatMessages = (chatMessages: ChatMessage[]): LLMChatMessage[] => { for (const c of chatMessages) { if (c.role === 'assistant') - llmChatMessages.push({ role: c.role, content: c.content, anthropicReasoning: c.anthropicReasoning }) + llmChatMessages.push({ role: c.role, content: c.displayContent, anthropicReasoning: c.anthropicReasoning }) // merge all tool/user messages into one big user message else if (c.role === 'user' || c.role === 'tool') { - if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') { + if (c.role === 'tool') + c.content = `TOOL_RESULT (${c.name}):\n${c.content}` + + if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') llmChatMessages.push({ role: 'user', content: c.content }) - } - else { + else llmChatMessages[llmChatMessages.length - 1].content += '\n\n' + c.content - } + } else if (c.role === 'interrupted_streaming_tool') { // pass } @@ -146,7 +148,7 @@ export type ThreadStreamState = { // streaming related - when streaming message streamingToken?: string; - messageSoFar?: string; + displayContentSoFar?: string; reasoningSoFar?: string; toolCallSoFar?: RawToolCallObj; } @@ -519,7 +521,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const isRunning = this.streamState[threadId]?.isRunning if (isRunning === 'LLM') { // abort the stream first so it doesn't change any state - const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' + const displayContentSoFar = this.streamState[threadId]?.displayContentSoFar ?? '' const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar console.log('toolInProgress', toolCallSoFar) @@ -527,7 +529,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const llmCancelToken = this.streamState[threadId]?.streamingToken if (llmCancelToken !== undefined) { this._llmMessageService.abort(llmCancelToken) } - this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) if (toolCallSoFar) { this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) @@ -716,19 +718,19 @@ class ChatThreadService extends Disposable implements IChatThreadService { modelSelectionOptions, logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, onText: ({ fullText, fullReasoning, toolCall }) => { - this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge') + this._setStreamState(threadId, { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge') }, onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => { - this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning }) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') + this._addMessageToThread(threadId, { role: 'assistant', displayContent: fullText, reasoning: fullReasoning, anthropicReasoning }) + this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') console.log('tool call!!', toolCall) resMessageIsDonePromise(toolCall) // resolve with tool calls }, onError: (error) => { - const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' + const messageSoFar = this.streamState[threadId]?.displayContentSoFar ?? '' const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' // add assistant's message to chat history, and clear selection - this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + this._addMessageToThread(threadId, { role: 'assistant', displayContent: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) this._setStreamState(threadId, { error }, 'set') resMessageIsDonePromise() }, diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 28c59250..be3e342a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -1075,7 +1075,7 @@ const AssistantMessageComponent = ({ chatMessage, isCheckpointGhost, isCommitted const reasoningStr = chatMessage.reasoning?.trim() || null const hasReasoning = !!reasoningStr - const isDoneReasoning = !!chatMessage.content + const isDoneReasoning = !!chatMessage.displayContent const thread = chatThreadsService.getCurrentThread() @@ -1084,7 +1084,7 @@ const AssistantMessageComponent = ({ chatMessage, isCheckpointGhost, isCommitted messageIdx: messageIdx, } - const isEmpty = !chatMessage.content && !chatMessage.reasoning + const isEmpty = !chatMessage.displayContent && !chatMessage.reasoning if (isEmpty) return null return <> @@ -1108,7 +1108,7 @@ const AssistantMessageComponent = ({ chatMessage, isCheckpointGhost, isCommitted
    { const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) const isRunning = currThreadStreamState?.isRunning const latestError = currThreadStreamState?.error - const messageSoFar = currThreadStreamState?.messageSoFar + const displayContentSoFar = currThreadStreamState?.displayContentSoFar const reasoningSoFar = currThreadStreamState?.reasoningSoFar const toolCallSoFar = currThreadStreamState?.toolCallSoFar @@ -2082,13 +2082,13 @@ export const SidebarChat = () => { }, [previousMessages, isRunning, threadId]) const streamingChatIdx = previousMessagesHTML.length - const currStreamingMessageHTML = reasoningSoFar || messageSoFar || isRunning ? + const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ? { w-full h-full overflow-x-hidden overflow-y-auto - ${previousMessagesHTML.length === 0 && !messageSoFar ? 'hidden' : ''} + ${previousMessagesHTML.length === 0 && !displayContentSoFar ? 'hidden' : ''} `} > {/* previous messages */} diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index 9a358c55..229eca8f 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -56,7 +56,7 @@ export type ChatMessage = } } | { role: 'assistant'; - content: string; // content received from LLM - allowed to be '', will be replaced with (empty) + displayContent: string; // content received from LLM - allowed to be '', will be replaced with (empty) reasoning: string; // reasoning from the LLM, used for step-by-step thinking anthropicReasoning: AnthropicReasoning[] | null; // anthropic reasoning diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index dc6b66c9..829369b5 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -29,6 +29,7 @@ export const extractReasoningWrapper = ( } const newOnText: OnText = ({ fullText: fullText_, ...p }) => { + // until found the first think tag, keep adding to fullText if (!foundTag1) { const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) @@ -293,6 +294,9 @@ export const extractToolsWrapper = ( const newOnFinalMessage: OnFinalMessage = (params) => { // treat like just got text before calling onFinalMessage (or else we sometimes miss the final chunk that's new to finalMessage) + console.log('final message!!!', trueFullText) + console.log('----- returning ----\n', fullText) + console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) newOnText({ ...params }) console.log('final message!!!', trueFullText) @@ -300,7 +304,7 @@ export const extractToolsWrapper = ( console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) fullText = fullText.trimEnd() - const toolCall = currentToolCalls[0] + const toolCall = currentToolCalls.length > 0 ? currentToolCalls[0] : undefined if (toolCall) { // trim off all whitespace at and before first \n and after last \n for each param for (const paramName in toolCall.rawParams) { @@ -309,7 +313,9 @@ export const extractToolsWrapper = ( toolCall.rawParams[paramName] = trimBeforeAndAfterNewLines(orig) } } - onFinalMessage({ ...params, fullText, toolCall: currentToolCalls.length > 0 ? currentToolCalls[0] : undefined }) + console.log('----- toolCall ----\n', JSON.stringify(toolCall, null, 2)) + + onFinalMessage({ ...params, fullText, toolCall: toolCall }) } return { newOnText, newOnFinalMessage }; } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts index e27e0753..0d65e943 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sax.ts @@ -54,30 +54,38 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser { // Set the current position to the end of the processed chunk. this.position = globalPos - 1; - let cursor: number = 0; + let cursor = 0; + // Flag to indicate if an incomplete tag was found. + let incompleteTagFound = false; + // This will mark the position in the buffer where the incomplete tag starts. + let incompleteStart = 0; + while (cursor < buffer.length) { // Look for the next opening '<' character. const ltIndex = buffer.indexOf('<', cursor); if (ltIndex === -1) { - // No more tags found. Emit any remaining text as a text node. + // No more tags found in the current buffer. if (cursor < buffer.length && this.ontext) { this.ontext(buffer.substring(cursor)); } - // Clear the buffer since all content is processed. + // All content is processed. buffer = ''; + cursor = buffer.length; break; } - // Emit any text that appears before the tag. + // Emit any text between the current cursor and the opening tag. if (ltIndex > cursor && this.ontext) { this.ontext(buffer.substring(cursor, ltIndex)); } - // Look for the closing '>' character. + // Look for the closing '>' character starting from the found '<'. const gtIndex = buffer.indexOf('>', ltIndex); if (gtIndex === -1) { - // Incomplete tag detected—retain the remaining content in the buffer. - buffer = buffer.substring(ltIndex); + // Incomplete tag detected. + incompleteTagFound = true; + // Save the starting point of the incomplete tag. + incompleteStart = ltIndex; break; } @@ -98,24 +106,27 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser { this.onclosetag(tagName); } } else { - // Check for self-closing tags (ending with '/'). + // Handle self-closing tags (ending with '/'). let selfClosing = false; if (tagContent[tagContent.length - 1] === '/') { selfClosing = true; tagContent = tagContent.slice(0, -1).trim(); } - // Determine the tag name (first word before whitespace). + // Determine the tag name (first word before any whitespace). const spaceIndex = tagContent.indexOf(' '); - let tagName = (spaceIndex !== -1 ? tagContent.substring(0, spaceIndex) : tagContent).trim(); + let tagName = + spaceIndex !== -1 + ? tagContent.substring(0, spaceIndex).trim() + : tagContent; if (options.lowercase && tagName) { tagName = tagName.toLowerCase(); } - // Call onopentag with a minimal node object. + // Emit an open tag event. if (this.onopentag) { const node: SaxNode = { name: tagName, attributes: {} }; this.onopentag(node); } - // If the tag is self-closing, immediately emit the closing tag event. + // If it’s a self-closing tag, immediately emit a close tag event. if (selfClosing && this.onclosetag) { this.onclosetag(tagName); } @@ -124,10 +135,15 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser { cursor = gtIndex + 1; } - // Remove any content already processed from the buffer. - buffer = buffer.slice(cursor); - } - + // If an incomplete tag was detected, preserve it. + if (incompleteTagFound) { + // Keep the incomplete portion starting from the '<' + buffer = buffer.substring(incompleteStart); + } else { + // Otherwise, remove all processed content. + buffer = buffer.substring(cursor); + } + }, }; return parser; From ad5a77dc55797f08d957941b0b50f5fd411b3dcf Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 15:43:06 -0700 Subject: [PATCH 16/27] rm sax --- package-lock.json | 13 +------------ package.json | 2 -- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index fde227b4..d753857a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,6 @@ "posthog-node": "^4.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "sax": "^1.4.1", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", "vscode-html-languageservice": "^5.3.1", @@ -89,7 +88,6 @@ "@types/node": "20.x", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/sax": "^1.2.7", "@types/sinon": "^10.0.2", "@types/sinon-test": "^2.4.2", "@types/trusted-types": "^1.0.6", @@ -3941,16 +3939,6 @@ "@types/node": "*" } }, - "node_modules/@types/sax": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", - "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -18823,6 +18811,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, "license": "ISC" }, "node_modules/scheduler": { diff --git a/package.json b/package.json index 4a7eeb4f..f1faef9f 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,6 @@ "posthog-node": "^4.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "sax": "^1.4.1", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", "vscode-html-languageservice": "^5.3.1", @@ -150,7 +149,6 @@ "@types/node": "20.x", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/sax": "^1.2.7", "@types/sinon": "^10.0.2", "@types/sinon-test": "^2.4.2", "@types/trusted-types": "^1.0.6", From 49fbb6225934a32258d0a1eaa4da06329d00a21f Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 23:55:57 -0700 Subject: [PATCH 17/27] proper handling of tool call result, and improved prompting --- .../void/browser/autocompleteService.ts | 13 +- .../contrib/void/browser/chatThreadService.ts | 331 +++++++++--------- .../void/browser/directoryStrService.ts | 29 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 9 +- .../contrib/void/browser/toolsService.ts | 57 ++- .../void/common/chatThreadServiceTypes.ts | 7 +- .../contrib/void/common/prompt/prompts.ts | 309 +++++++++------- .../void/common/sendLLMMessageTypes.ts | 10 +- .../contrib/void/common/toolsServiceTypes.ts | 2 +- .../llmMessage/extractGrammar.ts | 38 +- 10 files changed, 420 insertions(+), 385 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index b8c6c466..e1832646 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -8,8 +8,7 @@ import { ILanguageFeaturesService } from '../../../../editor/common/services/lan import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { Position } from '../../../../editor/common/core/position.js'; -import { InlineCompletion, InlineCompletionContext, } from '../../../../editor/common/languages.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { InlineCompletion, } from '../../../../editor/common/languages.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; @@ -633,8 +632,6 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ async _provideInlineCompletionItems( model: ITextModel, position: Position, - context: InlineCompletionContext, - token: CancellationToken, ): Promise { const isEnabled = this._settingsService.state.globalSettings.enableAutocomplete @@ -852,7 +849,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ newAutocompletion.status = 'error' reject(message) }, - onAbort: () => { }, + onAbort: () => { reject('Aborted autocomplete') }, }) newAutocompletion.requestId = requestId @@ -897,9 +894,9 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ ) { super() - this._langFeatureService.inlineCompletionsProvider.register('*', { + this._register(this._langFeatureService.inlineCompletionsProvider.register('*', { provideInlineCompletions: async (model, position, context, token) => { - const items = await this._provideInlineCompletionItems(model, position, context, token) + const items = await this._provideInlineCompletionItems(model, position) // console.log('item: ', items?.[0]?.insertText) return { items: items, } @@ -936,7 +933,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ }); }, - }) + })) } diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index d5d8fe80..f363adda 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -11,11 +11,11 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, chat_systemMessage, ToolName, } from '../common/prompt/prompts.js'; -import { getErrorMessage, LLMChatMessage, RawToolCallObj, ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js'; +import { chat_userMessageContent, chat_systemMessage, ToolName, toolCallXMLStr, } from '../common/prompt/prompts.js'; +import { getErrorMessage, LLMChatMessage, RawToolCallObj, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; +import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; import { ToolCallParams, ToolResultType, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js'; import { IToolsService } from './toolsService.js'; @@ -37,6 +37,7 @@ import { IModelService } from '../../../../editor/common/services/model.js'; import { IDirectoryStrService } from './directoryStrService.js'; import { truncate } from '../../../../base/common/strings.js'; import { THREAD_STORAGE_KEY } from '../common/storageKeys.js'; +import { deepClone } from '../../../../base/common/objects.js'; /* @@ -61,37 +62,6 @@ A checkpoint appears before every LLM message, and before every user message (be */ -const toLLMChatMessages = (chatMessages: ChatMessage[]): LLMChatMessage[] => { - const llmChatMessages: LLMChatMessage[] = [] - - // merge tools into user message - - for (const c of chatMessages) { - if (c.role === 'assistant') - llmChatMessages.push({ role: c.role, content: c.displayContent, anthropicReasoning: c.anthropicReasoning }) - // merge all tool/user messages into one big user message - else if (c.role === 'user' || c.role === 'tool') { - if (c.role === 'tool') - c.content = `TOOL_RESULT (${c.name}):\n${c.content}` - - if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') - llmChatMessages.push({ role: 'user', content: c.content }) - else - llmChatMessages[llmChatMessages.length - 1].content += '\n\n' + c.content - - } - else if (c.role === 'interrupted_streaming_tool') { // pass - } - else if (c.role === 'checkpoint') { // pass - } - else { - throw new Error(`Role ${(c as any).role} not recognized.`) - } - } - return llmChatMessages -} - - type UserMessageType = ChatMessage & { role: 'user' } type UserMessageState = UserMessageType['state'] const defaultMessageState: UserMessageState = { @@ -466,12 +436,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { lastMsg.role === 'tool' && (lastMsg.type === 'tool_request') )) return // should never happen - const lastUserMsgIdx = findLastIdx(thread.messages, m => m.role === 'user') - const lastUserMessage = thread.messages[lastUserMsgIdx] as ChatMessage & { role: 'user' } - if (lastUserMsgIdx === -1 || !lastUserMessage) return // should never happen - - const instructions = lastUserMessage.displayContent || '' - const callThisToolFirst: ToolMessage = lastMsg this._updateLatestToolTo(threadId, { @@ -484,7 +448,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { }) this._wrapRunAgentToNotify( - this._runChatAgent({ callThisToolFirst, threadId, userMessageContent: instructions, ...this._currentModelSelectionProps() }) + this._runChatAgent({ callThisToolFirst, threadId, ...this._currentModelSelectionProps() }) , threadId ) } @@ -529,7 +493,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const llmCancelToken = this.streamState[threadId]?.streamingToken if (llmCancelToken !== undefined) { this._llmMessageService.abort(llmCancelToken) } - this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, toolCall: toolCallSoFar, anthropicReasoning: null }) if (toolCallSoFar) { this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) @@ -549,136 +513,159 @@ class ChatThreadService extends Disposable implements IChatThreadService { private readonly _currentlyRunningToolInterruptor: { [threadId: string]: (() => void) | undefined } = {} + + + // system message + private _generateSystemMessage = async (chatMode: ChatMode) => { + const workspaceFolders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath) + + const openedURIs = this._modelService.getModels().filter(m => m.isAttachedToEditor()).map(m => m.uri.fsPath) || []; + const activeURI = this._editorService.activeEditor?.resource?.fsPath; + + const directoryStr = await this._directoryStrService.getAllDirectoriesStr({ + cutOffMessage: chatMode === 'agent' || chatMode === 'gather' ? `...Directories string cut off, use tools to read more...` + : `...Directories string cut off, ask user for more if necessary...` + }) + + const runningTerminalIds = this._terminalToolService.listTerminalIds() + const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, runningTerminalIds, chatMode }) + return systemMessage + } + + private _generateLLMMessages = async (threadId: string) => { + const thread = this.state.allThreads[threadId] + if (!thread) return [] + + const chatMessages = deepClone(thread.messages) + const llmChatMessages: LLMChatMessage[] = [] + + // merge tools into user message + for (const c of chatMessages) { + if (c.role === 'assistant') { + // if called a tool, re-add its XML to the message + // alternatively, could just hold onto the original output, but this way requires less piping raw strings everywhere + let content = c.displayContent + if (c.toolCall) { + content = `${content}\n\n${toolCallXMLStr(c.toolCall)}` + } + llmChatMessages.push({ role: c.role, content: content, anthropicReasoning: c.anthropicReasoning }) + } + else if (c.role === 'user' || c.role === 'tool') { + if (c.role === 'tool') + c.content = `<${c.name}_result>\n${c.content}\n` + + if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') + llmChatMessages.push({ role: 'user', content: c.content }) + else + llmChatMessages[llmChatMessages.length - 1].content += '\n\n' + c.content + + } + else if (c.role === 'interrupted_streaming_tool') { // pass + } + else if (c.role === 'checkpoint') { // pass + } + else { + throw new Error(`Role ${(c as any).role} not recognized.`) + } + } + return llmChatMessages + } + + + // returns true when the tool call is waiting for user approval + private _runToolCall = async ( + threadId: string, + toolName: ToolName, + opts: { preapproved: true, validatedParams: ToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj }, + ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { + + // compute these below + let toolParams: ToolCallParams[ToolName] + let toolResult: ToolResultType[typeof toolName] + let toolResultStr: string + + if (!opts.preapproved) { // skip this if pre-approved + // 1. validate tool params + try { + console.log('VALIDATING PARAMS!!!', opts.unvalidatedToolParams) + + const params = await this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) + toolParams = params + } catch (error) { + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', params: null, result: null, name: toolName, content: errorMessage, }) + return {} + } + // once validated, add checkpoint for edit + if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as ToolCallParams['edit_file']).uri }) } + + // 2. if tool requires approval, break from the loop, awaiting approval + const requiresApproval = toolNamesThatRequireApproval.has(toolName) + if (requiresApproval) { + const autoApprove = this._settingsService.state.globalSettings.autoApprove + // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) + this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(never)', result: null, name: toolName, params: toolParams }) + if (!autoApprove) { + return { awaitingUserApproval: true } + } + } + } + else { + toolParams = opts.validatedParams + } + + // 3. call the tool + this._setStreamState(threadId, { isRunning: 'tool' }, 'merge') + let interrupted = false + try { + const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) + this._currentlyRunningToolInterruptor[threadId] = () => { + interrupted = true; + interruptTool?.(); + delete this._currentlyRunningToolInterruptor[threadId]; + } + toolResult = await result // ts is bad... await is needed + } + catch (error) { + if (interrupted) { + // the tool result is added when we stop running + return { interrupted: true } + } + const errorMessage = getErrorMessage(error) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) + return {} + } + + // 4. stringify the result to give to the LLM + try { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + } catch (error) { + const errorMessage = this.errMsgs.errWhenStringifying(error) + this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) + return {} + } + + // 5. add to history and keep going + this._updateLatestToolTo(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, }) + + return {} + }; + + + + private async _runChatAgent({ threadId, modelSelection, modelSelectionOptions, - userMessageContent, callThisToolFirst, }: { threadId: string, modelSelection: ModelSelection | null, modelSelectionOptions: ModelSelectionOptions | undefined, - userMessageContent: string, // content of LATEST user message callThisToolFirst?: ToolMessage & { type: 'tool_request' } }) { - const userMessageFullContent = userMessageContent - const getLatestMessages = async () => { - // replace last userMessage with userMessageFullContent (which contains all the files too) - const thread = this.state.allThreads[threadId] - const latestMessages = thread?.messages ?? [] - const messages_ = toLLMChatMessages(latestMessages) - const lastUserMsgIdx = findLastIdx(messages_, m => m.role === 'user') - if (lastUserMsgIdx === -1) return [] // should never happen (or how did they send the message?!) - - // system message - const workspaceFolders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath) - - const openedURIs = this._modelService.getModels().filter(m => m.isAttachedToEditor()).map(m => m.uri.fsPath) || []; - const activeURI = this._editorService.activeEditor?.resource?.fsPath; - - const { wasCutOff, str: directoryStr_ } = await this._directoryStrService.getAllDirectoriesStr() - - const directoryStr = wasCutOff ? ( - chatMode === 'agent' || chatMode === 'gather' ? `${directoryStr_}\nString cut off, use tools to read more.` - : `${directoryStr_}\nString cut off, ask user for more if necessary.` - ) : directoryStr_ - - const runningTerminalIds = this._terminalToolService.listTerminalIds() - const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, runningTerminalIds, chatMode }) - - // console.log('SYSTEM MESSAGE', systemMessage) - // all messages so far in the chat history (including tools) - const messages: LLMChatMessage[] = [ - { role: 'system', content: systemMessage, }, - ...messages_.slice(0, lastUserMsgIdx), - { role: 'user', content: userMessageFullContent }, - ...messages_.slice(lastUserMsgIdx + 1, Infinity), - ] - // console.log('MESSAGES!!!', messages) - return messages - } - - - - // returns true when the tool call is waiting for user approval - const handleToolCall = async ( - toolName: ToolName, - opts: { preapproved: true, validatedParams: ToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: ParsedToolParamsObj }, - ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { - - // compute these below - let toolParams: ToolCallParams[ToolName] - let toolResult: ToolResultType[typeof toolName] - let toolResultStr: string - - if (!opts.preapproved) { // skip this if pre-approved - // 1. validate tool params - try { - console.log('VALIDATING PARAMS!!!', opts.unvalidatedToolParams) - - const params = await this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) - toolParams = params - } catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', params: null, result: null, name: toolName, content: errorMessage, }) - return {} - } - // once validated, add checkpoint for edit - if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as ToolCallParams['edit_file']).uri }) } - - // 2. if tool requires approval, break from the loop, awaiting approval - const requiresApproval = toolNamesThatRequireApproval.has(toolName) - if (requiresApproval) { - const autoApprove = this._settingsService.state.globalSettings.autoApprove - // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) - this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(never)', result: null, name: toolName, params: toolParams }) - if (!autoApprove) { - return { awaitingUserApproval: true } - } - } - } - else { - toolParams = opts.validatedParams - } - - // 3. call the tool - this._setStreamState(threadId, { isRunning: 'tool' }, 'merge') - let interrupted = false - try { - const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) - this._currentlyRunningToolInterruptor[threadId] = () => { - interrupted = true; - interruptTool?.(); - delete this._currentlyRunningToolInterruptor[threadId]; - } - toolResult = await result // ts is bad... await is needed - } - catch (error) { - if (interrupted) { - // the tool result is added when we stop running - return { interrupted: true } - } - const errorMessage = getErrorMessage(error) - this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) - return {} - } - - // 4. stringify the result to give to the LLM - try { - toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) - } catch (error) { - const errorMessage = this.errMsgs.errWhenStringifying(error) - this._updateLatestToolTo(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, }) - return {} - } - - // 5. add to history and keep going - this._updateLatestToolTo(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, }) - - return {} - }; // above just defines helpers, below starts the actual function const { chatMode } = this._settingsService.state.globalSettings // should not change as we loop even if user changes it, so it goes here @@ -693,7 +680,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // before enter loop, call tool if (callThisToolFirst) { - const { interrupted } = await handleToolCall(callThisToolFirst.name, { preapproved: true, validatedParams: callThisToolFirst.params }) + const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, { preapproved: true, validatedParams: callThisToolFirst.params }) if (interrupted) return } @@ -709,7 +696,14 @@ class ChatThreadService extends Disposable implements IChatThreadService { // send llm message this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge') - const messages = await getLatestMessages() + const systemMessage = await this._generateSystemMessage(chatMode) + const llmMessages = await this._generateLLMMessages(threadId) + const messages: LLMChatMessage[] = [ + { role: 'system', content: systemMessage }, + ...llmMessages + ] + + console.log('SENDING!!', messages) const llmCancelToken = this._llmMessageService.sendLLMMessage({ messagesType: 'chatMessages', chatMode, @@ -721,16 +715,17 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setStreamState(threadId, { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge') }, onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => { - this._addMessageToThread(threadId, { role: 'assistant', displayContent: fullText, reasoning: fullReasoning, anthropicReasoning }) + this._addMessageToThread(threadId, { role: 'assistant', displayContent: fullText, reasoning: fullReasoning, toolCall, anthropicReasoning }) this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge') - console.log('tool call!!', toolCall) + console.log('tool call!!', JSON.stringify(toolCall)) resMessageIsDonePromise(toolCall) // resolve with tool calls }, onError: (error) => { const messageSoFar = this.streamState[threadId]?.displayContentSoFar ?? '' const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' + const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar // add assistant's message to chat history, and clear selection - this._addMessageToThread(threadId, { role: 'assistant', displayContent: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) + this._addMessageToThread(threadId, { role: 'assistant', displayContent: messageSoFar, reasoning: reasoningSoFar, toolCall: toolCallSoFar, anthropicReasoning: null }) this._setStreamState(threadId, { error }, 'set') resMessageIsDonePromise() }, @@ -757,7 +752,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // call tool if there is one const tool: RawToolCallObj | undefined = toolCall if (tool) { - const { awaitingUserApproval, interrupted } = await handleToolCall(tool.name, { preapproved: false, unvalidatedToolParams: tool.rawParams }) + const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, tool.name, { preapproved: false, unvalidatedToolParams: tool.rawParams }) // stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools. // just detect tool interruption which is the same as chat interruption right now @@ -1117,7 +1112,7 @@ We only need to do it for files that were edited since `from`, ie files between this._setThreadState(threadId, { currCheckpointIdx: null }) // no longer at a checkpoint because started streaming this._wrapRunAgentToNotify( - this._runChatAgent({ threadId, userMessageContent, ...this._currentModelSelectionProps(), }), + this._runChatAgent({ threadId, ...this._currentModelSelectionProps(), }), threadId, ) } @@ -1221,7 +1216,7 @@ We only need to do it for files that were edited since `from`, ie files between // else search codebase for `target` let uris: URI[] = [] try { - const { result } = await this._toolsService.callTool['search_pathnames_only']({ queryStr: target, include: null, pageNumber: 0 }) + const { result } = await this._toolsService.callTool['search_pathnames_only']({ queryStr: target, searchInFolder: null, pageNumber: 0 }) uris = result.uris } catch (e) { return null diff --git a/src/vs/workbench/contrib/void/browser/directoryStrService.ts b/src/vs/workbench/contrib/void/browser/directoryStrService.ts index e15b8cc6..f51298f2 100644 --- a/src/vs/workbench/contrib/void/browser/directoryStrService.ts +++ b/src/vs/workbench/contrib/void/browser/directoryStrService.ts @@ -15,18 +15,17 @@ import { IExplorerService } from '../../files/browser/files.js'; import { SortOrder } from '../../files/common/files.js'; import { ExplorerItem } from '../../files/common/explorerModel.js'; import { VoidDirectoryItem } from '../common/directoryStrTypes.js'; +import { MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from '../common/prompt/prompts.js'; -const MAX_CHARS_TOTAL_BEGINNING = 20_000 -const MAX_CHARS_TOTAL_TOOL = 20_000 // const MAX_FILES_TOTAL = 200 export interface IDirectoryStrService { readonly _serviceBrand: undefined; - getDirectoryStrTool(uri: URI): Promise<{ wasCutOff: boolean, str: string }> - getAllDirectoriesStr(): Promise<{ wasCutOff: boolean, str: string }> + getDirectoryStrTool(uri: URI): Promise + getAllDirectoriesStr(opts: { cutOffMessage: string }): Promise } export const IDirectoryStrService = createDecorator('voidDirectoryStrService'); @@ -275,20 +274,21 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { if (!eRoot) throw new Error(`There was a problem reading the URI: ${uri.fsPath}.`) const dirTree = await computeDirectoryTree(eRoot, this.explorerService); - const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_CHARS_TOTAL_TOOL); + const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_DIRSTR_CHARS_TOTAL_TOOL); - return { - str: `Directory of ${uri.fsPath}:\n${content}`, - wasCutOff, - } + let c = content.substring(0, MAX_DIRSTR_CHARS_TOTAL_TOOL) + c = `Directory of ${uri.fsPath}:\n${content}` + if (wasCutOff) c = `${c}\n...Result was truncated...` + + return c } - async getAllDirectoriesStr() { + async getAllDirectoriesStr({ cutOffMessage }: { cutOffMessage: string }) { let str: string = ''; let cutOff = false; const folders = this.workspaceContextService.getWorkspace().folders; if (folders.length === 0) - return { str: '(NO WORKSPACE OPEN)', wasCutOff: false }; + return '(NO WORKSPACE OPEN)'; for (let i = 0; i < folders.length; i += 1) { if (i > 0) str += '\n'; @@ -304,7 +304,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { // Use our new approach with direct explorer service const dirTree = await computeDirectoryTree(eRoot, this.explorerService); console.log('dirtree', dirTree) - const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_CHARS_TOTAL_BEGINNING - str.length); + const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length); str += content; if (wasCutOff) { cutOff = true; @@ -312,7 +312,10 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { } } - return { wasCutOff: cutOff, str }; + if (cutOff) { + return `${str}\n${cutOffMessage}` + } + return str } } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index be3e342a..cce69b89 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -1334,17 +1334,19 @@ const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr }: { applyBoxId: strin -const InvalidTool = ({ toolName }: { toolName: string }) => { +const InvalidTool = ({ toolName }: { toolName: ToolName }) => { const accessor = useAccessor() const title = getTitle({ name: toolName, type: 'invalid_params' }) const desc1 = 'Invalid parameters' const icon = null const isError = true const componentParams: ToolHeaderParams = { title, desc1, isError, icon } + + componentParams.children return } -const CanceledTool = ({ toolName }: { toolName: string }) => { +const CanceledTool = ({ toolName }: { toolName: ToolName }) => { const accessor = useAccessor() const title = getTitle({ name: toolName, type: 'rejected' }) const desc1 = '' @@ -2006,9 +2008,9 @@ export const SidebarChat = () => { const isRunning = currThreadStreamState?.isRunning const latestError = currThreadStreamState?.error const displayContentSoFar = currThreadStreamState?.displayContentSoFar + const toolCallSoFar = currThreadStreamState?.toolCallSoFar const reasoningSoFar = currThreadStreamState?.reasoningSoFar - const toolCallSoFar = currThreadStreamState?.toolCallSoFar const toolIsGenerating = !!toolCallSoFar && toolCallSoFar.name === 'edit_file' // show loading for slow tools (right now just edit) // ----- SIDEBAR CHAT state (local) ----- @@ -2090,6 +2092,7 @@ export const SidebarChat = () => { role: 'assistant', displayContent: displayContentSoFar ?? '', reasoning: reasoningSoFar ?? '', + toolCall: toolCallSoFar, anthropicReasoning: null, }} messageIdx={streamingChatIdx} diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index e088d556..ea6e216d 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -16,7 +16,7 @@ import { IVoidCommandBarService } from './voidCommandBarService.js' import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from './directoryStrService.js' import { IMarkerService } from '../../../../platform/markers/common/markers.js' import { timeout } from '../../../../base/common/async.js' -import { ParsedToolParamsObj } from '../common/sendLLMMessageTypes.js' +import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js' import { ToolName } from '../common/prompt/prompts.js' @@ -25,7 +25,7 @@ import { ToolName } from '../common/prompt/prompts.js' -type ValidateParams = { [T in ToolName]: (p: ParsedToolParamsObj) => Promise } +type ValidateParams = { [T in ToolName]: (p: RawToolParamsObj) => Promise } type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T], interruptTool?: () => void }> } type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Awaited) => string } @@ -45,7 +45,7 @@ const isFalsy = (u: unknown) => { } const validateStr = (argName: string, value: unknown) => { - if (typeof value !== 'string') throw new Error(`Invalid LLM output format: ${argName} must be a string.`) + if (typeof value !== 'string') throw new Error(`Invalid LLM output format: ${argName} must be a string, but it's a ${typeof value}. Value: ${value}.`) return value } @@ -53,7 +53,7 @@ const validateStr = (argName: string, value: unknown) => { // We are NOT checking to make sure in workspace // TODO!!!! check to make sure folder/file exists const validateURI = (uriStr: unknown) => { - if (typeof uriStr !== 'string') throw new Error('Invalid LLM output format: Provided uri must be a string.') + if (typeof uriStr !== 'string') throw new Error(`Invalid LLM output format: Provided uri must be a string, but it's a ${typeof uriStr}. Value: ${uriStr}.`) const uri = URI.file(uriStr) return uri } @@ -92,6 +92,7 @@ const validateNumber = (numStr: unknown, opts: { default: number | null }) => { } const validateRecursiveParamStr = (paramsUnknown: unknown) => { + if (!paramsUnknown) return false if (typeof paramsUnknown !== 'string') throw new Error('Invalid LLM output format: Error calling tool: provided params must be a string.') const params = paramsUnknown const isRecursive = params.includes('r') @@ -155,8 +156,8 @@ export class ToolsService implements IToolsService { const queryBuilder = instantiationService.createInstance(QueryBuilder); this.validateParams = { - read_file: async (params: ParsedToolParamsObj) => { - const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = params + read_file: async (params: RawToolParamsObj) => { + const { uri: uriStr, start_line: startLineUnknown, end_line: endLineUnknown, page_number: pageNumberUnknown } = params const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) @@ -165,38 +166,38 @@ export class ToolsService implements IToolsService { return { uri, startLine, endLine, pageNumber } }, - ls_dir: async (params: ParsedToolParamsObj) => { - const { uri: uriStr, pageNumber: pageNumberUnknown } = params + ls_dir: async (params: RawToolParamsObj) => { + const { uri: uriStr, page_number: pageNumberUnknown } = params const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) return { rootURI: uri, pageNumber } }, - get_dir_structure: async (params: ParsedToolParamsObj) => { + get_dir_structure: async (params: RawToolParamsObj) => { const { uri: uriStr, } = params const uri = validateURI(uriStr) return { rootURI: uri } }, - search_pathnames_only: async (params: ParsedToolParamsObj) => { + search_pathnames_only: async (params: RawToolParamsObj) => { const { query: queryUnknown, - include: includeUnknown, - pageNumber: pageNumberUnknown + search_in_folder: includeUnknown, + page_number: pageNumberUnknown } = params const queryStr = validateStr('query', queryUnknown) const pageNumber = validatePageNum(pageNumberUnknown) - const include = validateOptionalStr('include', includeUnknown) + const searchInFolder = validateOptionalStr('search_in_folder', includeUnknown) - return { queryStr, include, pageNumber } + return { queryStr, searchInFolder, pageNumber } }, - search_files: async (params: ParsedToolParamsObj) => { + search_files: async (params: RawToolParamsObj) => { const { query: queryUnknown, - searchInFolder: searchInFolderUnknown, - isRegex: isRegexUnknown, - pageNumber: pageNumberUnknown + search_in_folder: searchInFolderUnknown, + is_regex: isRegexUnknown, + page_number: pageNumberUnknown } = params const queryStr = validateStr('query', queryUnknown) @@ -210,7 +211,7 @@ export class ToolsService implements IToolsService { // --- - create_file_or_folder: async (params: ParsedToolParamsObj) => { + create_file_or_folder: async (params: RawToolParamsObj) => { const { uri: uriUnknown } = params const uri = validateURI(uriUnknown) const uriStr = validateStr('uri', uriUnknown) @@ -218,7 +219,7 @@ export class ToolsService implements IToolsService { return { uri, isFolder } }, - delete_file_or_folder: async (params: ParsedToolParamsObj) => { + delete_file_or_folder: async (params: RawToolParamsObj) => { const { uri: uriUnknown, params: paramsStr } = params const uri = validateURI(uriUnknown) const isRecursive = validateRecursiveParamStr(paramsStr) @@ -227,15 +228,15 @@ export class ToolsService implements IToolsService { return { uri, isRecursive, isFolder } }, - edit_file: async (params: ParsedToolParamsObj) => { - const { uri: uriStr, changeDescription: changeDescriptionUnknown } = params + edit_file: async (params: RawToolParamsObj) => { + const { uri: uriStr, change_description: changeDescriptionUnknown } = params const uri = validateURI(uriStr) const changeDescription = validateStr('changeDescription', changeDescriptionUnknown) return { uri, changeDescription } }, - run_terminal_command: async (params: ParsedToolParamsObj) => { - const { command: commandUnknown, terminalId: terminalIdUnknown, waitForCompletion: waitForCompletionUnknown } = params + run_terminal_command: async (params: RawToolParamsObj) => { + const { command: commandUnknown, terminal_id: terminalIdUnknown, wait_for_completion: waitForCompletionUnknown } = params const command = validateStr('command', commandUnknown) const proposedTerminalId = validateProposedTerminalId(terminalIdUnknown) const waitForCompletion = validateBoolean(waitForCompletionUnknown, { default: true }) @@ -275,17 +276,15 @@ export class ToolsService implements IToolsService { }, get_dir_structure: async ({ rootURI }) => { - const result = await this.directoryStrService.getDirectoryStrTool(rootURI) - let str = result.str - if (result.wasCutOff) str += '\n(Result was truncated)' + const str = await this.directoryStrService.getDirectoryStrTool(rootURI) return { result: { str } } }, - search_pathnames_only: async ({ queryStr, include, pageNumber }) => { + search_pathnames_only: async ({ queryStr, searchInFolder, pageNumber }) => { const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, - includePattern: include ?? undefined, + includePattern: searchInFolder ?? undefined, }) const data = await searchService.fileSearch(query, CancellationToken.None) diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index 229eca8f..8ebaf213 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -6,7 +6,7 @@ import { URI } from '../../../../base/common/uri.js'; import { VoidFileSnapshot } from './editCodeServiceTypes.js'; import { ToolName } from './prompt/prompts.js'; -import { AnthropicReasoning } from './sendLLMMessageTypes.js'; +import { AnthropicReasoning, RawToolCallObj } from './sendLLMMessageTypes.js'; import { ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; export type ToolMessage = { @@ -14,7 +14,7 @@ export type ToolMessage = { content: string; // give this result to LLM (string of value) } & ( // in order of events: - | { type: 'invalid_params', result: null, params: null, name: string } + | { type: 'invalid_params', result: null, name: T, params: RawToolCallObj | null, } | { type: 'tool_request', result: null, name: T, params: ToolCallParams[T], } // params were validated, awaiting user @@ -27,7 +27,7 @@ export type ToolMessage = { export type DecorativeCanceledTool = { role: 'interrupted_streaming_tool'; - name: string; + name: ToolName; } @@ -58,6 +58,7 @@ export type ChatMessage = role: 'assistant'; displayContent: string; // content received from LLM - allowed to be '', will be replaced with (empty) reasoning: string; // reasoning from the LLM, used for step-by-step thinking + toolCall: RawToolCallObj | undefined; anthropicReasoning: AnthropicReasoning[] | null; // anthropic reasoning } diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index 6e71724b..252e8b2d 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -3,16 +3,24 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { os } from '../helpers/systemInfo.js'; +import { EndOfLinePreference } from '../../../../../editor/common/model.js'; import { StagingSelectionItem } from '../chatThreadServiceTypes.js'; -import { ChatMode } from '../voidSettingsTypes.js'; +import { os } from '../helpers/systemInfo.js'; +import { RawToolCallObj } from '../sendLLMMessageTypes.js'; import { toolNamesThatRequireApproval } from '../toolsServiceTypes.js'; import { IVoidModelService } from '../voidModelService.js'; -import { EndOfLinePreference } from '../../../../../editor/common/model.js'; +import { ChatMode } from '../voidSettingsTypes.js'; // this is just for ease of readability export const tripleTick = ['```', '```'] +export const MAX_DIRSTR_CHARS_TOTAL_BEGINNING = 20_000 +export const MAX_DIRSTR_CHARS_TOTAL_TOOL = 20_000 + +export const MAX_PREFIX_SUFFIX_CHARS = 20_000 + + +// ======================================================== tools ======================================================== const changesExampleContent = `\ // ... existing code ... // {{change 1}} @@ -27,16 +35,13 @@ ${tripleTick[0]} ${changesExampleContent} ${tripleTick[1]}` -const fileNameEdit = `${tripleTick[0]}typescript +const fileNameEditExample = `${tripleTick[0]}typescript /Users/username/Dekstop/my_project/app.ts ${changesExampleContent} ${tripleTick[1]}` -// ======================================================== tools ======================================================== - - export type InternalToolInfo = { name: string, description: string, @@ -47,19 +52,12 @@ export type InternalToolInfo = { -const paginationHelper = { - desc: `Very large results may be paginated (a note will always be included if pagination took place). Pagination fails gracefully if out of bounds or invalid page number.`, - param: { pageNumber: { type: 'number', description: 'The page number (default is the first page = 1).' }, } -} as const - const uriParam = (object: string) => ({ - uri: { description: `The FULL path to the ${object} from the root of the file system.` } + uri: { description: `The FULL path to the ${object}.` } }) - -const searchParams = { - searchInFolder: { description: 'Only search files in this given folder. Leave as empty to search all available files.' }, - isRegex: { description: 'Whether to treat the query as a regular expression. Default is "false".' }, +const paginationParam = { + page_number: { description: 'Optional. The page number of the result. Default is 1.' } } as const @@ -68,27 +66,27 @@ export const voidTools = { read_file: { name: 'read_file', - description: `Returns file contents of a given URI. ${paginationHelper.desc}`, + description: `Returns file contents of a given URI.`, params: { ...uriParam('file'), - startLine: { description: 'Line to start reading from. Default is "null", treated as 1.' }, - endLine: { description: 'Line to stop reading from (inclusive). Default is "null", treated as Infinity.' }, - ...paginationHelper.param, + start_line: { description: 'Optional. Default is 1. Start reading on this line.' }, + end_line: { description: 'Optional. Default is Infinity. Stop reading after this line.' }, + ...paginationParam, }, }, ls_dir: { name: 'ls_dir', - description: `Returns all file names and folder names in a given folder. ${paginationHelper.desc}`, + description: `Lists all files and folders in the given URI.`, params: { ...uriParam('folder'), - ...paginationHelper.param, + ...paginationParam, }, }, get_dir_structure: { name: 'get_dir_structure', - description: `This is a very effective way to learn about the user's codebase. You might want to use this instead of ls_dir. Returns a tree diagram of all the files and folders in the given folder URI. If results are large, the given string will be truncated (this will be indicated), in which case you might want to call this tool on a lower folder to get better results, or just use ls_dir which supports pagination.`, + description: `This is a very effective way to learn about the user's codebase. Returns a tree diagram of all the files and folders in the given folder. `, params: { ...uriParam('folder') } @@ -96,21 +94,22 @@ export const voidTools = { 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. ${paginationHelper.desc}`, + 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.`, params: { query: { description: `Your query for the search.` }, - ...searchParams, - ...paginationHelper.param, + search_in_folder: { description: 'Optional. Only search files in this given folder glob.' }, + ...paginationParam, }, }, search_files: { name: 'search_files', - description: `Returns all pathnames that match a given query (searches ONLY file contents). The query can be any substring or glob. This is often followed by the \`read_file\` tool to view the full file contents of results. ${paginationHelper.desc}`, + description: `Returns all pathnames that match a given query (searches ONLY file contents). The query can be any substring or glob. You can follow this with read_file to view result contents.`, params: { query: { description: `Your query for the search.` }, - ...searchParams, - ...paginationHelper.param, + search_in_folder: { description: 'Optional. Only search files in this given folder glob.' }, + is_regex: { description: 'Optional. Default is false. Whether query is a regex.' }, + ...paginationParam, }, }, @@ -118,7 +117,7 @@ export const voidTools = { create_file_or_folder: { name: 'create_file_or_folder', - description: `Create a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash. Fails gracefully if the file already exists. Missing ancestors in the path will be recursively created automatically.`, + description: `Create a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash.`, params: { ...uriParam('file or folder'), }, @@ -126,25 +125,25 @@ export const voidTools = { delete_file_or_folder: { name: 'delete_file_or_folder', - description: `Delete a file or folder at the given path. Fails gracefully if the file or folder does not exist.`, + description: `Delete a file or folder at the given path.`, params: { ...uriParam('file or folder'), - params: { description: 'Return -r here to delete recursively (if applicable). Default is the empty string.' } + params: { description: 'Optional. Return -r here to delete recursively.' } }, }, edit_file: { // APPLY TOOL name: 'edit_file', - description: `Edits the contents of a file, given the file's URI and a description. Fails gracefully if the file does not exist.`, + description: `Edits the contents of a file given the file's URI and a description.`, params: { ...uriParam('file'), - changeDescription: { + change_description: { description: `\ -- Your changeDescription should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. -- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. -- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. -- You must output your description in triple backticks. -Here's an example of a good description:\n${editToolDescriptionExample}.` +A brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. \ +NEVER re-write the whole file. Instead, use comments like "// ... existing code ...". Bias towards writing as little as possible. \ +Your description will be handed to a smaller model to make the change, so it must be clear and concise. \ +Your description MUST be wrapped in triple backticks. \ +Here's an example of a good description:\n${editToolDescriptionExample}` } }, }, @@ -154,12 +153,11 @@ Here's an example of a good description:\n${editToolDescriptionExample}.` description: `Executes a terminal command.`, params: { command: { description: 'The terminal command to execute.' }, - waitForCompletion: { description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` }, - terminalId: { description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' }, + wait_for_completion: { description: `Optional. Default is true. Make this value false when you want a command to run without waiting for it to complete.` }, + terminal_id: { description: 'Optional. The ID of the terminal instance that should execute the command (if not provided, defaults to the preferred terminal ID). The primary purpose of this is to let you open a new terminal for testing or background processes (e.g. running a dev server for the user in a separate terminal). Must be an integer >= 1.' }, }, }, - // go_to_definition // go_to_usages @@ -169,6 +167,9 @@ Here's an example of a good description:\n${editToolDescriptionExample}.` export type ToolName = keyof typeof voidTools export const toolNames = Object.keys(voidTools) as ToolName[] +type ToolParamNameOfTool = keyof (typeof voidTools)[T]['params'] +export type ToolParamName = { [T in ToolName]: ToolParamNameOfTool }[ToolName] + const toolNamesSet = new Set(toolNames) export const isAToolName = (toolName: string): toolName is ToolName => { @@ -186,11 +187,11 @@ export const availableTools = (chatMode: ChatMode) => { return tools } -const availableToolsStr = (tools: InternalToolInfo[]) => { +const availableXMLToolsStr = (tools: InternalToolInfo[]) => { return `${tools.map((t, i) => { - const params = Object.keys(t.params).map(paramName => ` <${paramName}>\n${t.params[paramName].description}\n `).join('\n') + const params = Object.keys(t.params).map(paramName => `<${paramName}>${t.params[paramName].description}`).join('\n') return `\ -${i}. ${t.name} +${i + 1}. ${t.name} Description: ${t.description} Format: <${t.name}>${!params ? '' : `\n${params}`} @@ -198,112 +199,152 @@ Format: }).join('\n\n')}` } -const systemToolsPrompt = (chatMode: ChatMode) => { +export const toolCallXMLStr = (toolCall: RawToolCallObj) => { + const t = toolCall + const params = Object.keys(t.rawParams).map(paramName => `<${paramName}>${t.rawParams[paramName as ToolParamName]}`).join('\n') + return `\ +<${toolCall.name}>${!params ? '' : `\n${params}`} +` + .replace('\t', ' ') +} + +/* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */ +// - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. +const systemToolsXMLPrompt = (chatMode: ChatMode) => { const tools = availableTools(chatMode) if (!tools || tools.length === 0) return '' - return `\ -You are allowed to call tools in your response. -Tool calling guidelines: -${chatMode === 'agent' ? `\ -- Only call tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools. -- ALWAYS use tools to take actions. For example, if you would like to edit a file, you MUST use a tool. -- You will OFTEN need to gather context before making a change. Do not immediately make a change unless you have ALL relevant context. -- ALWAYS have maximal certainty in a change BEFORE you make it. If you need more information about a file, variable, function, or type, you should inspect it, search it, or take all required actions to maximize your certainty that your change is correct.` - : chatMode === 'gather' ? `\ -- Your primary use of tools should be to gather information to help the user understand the codebase and answer their query. -- You should extensively read files, types, content, etc and gather relevant context.` - : chatMode === 'normal' ? '' - : ''} -- If you think you should use tools, you do not need to ask for permission. -- NEVER refer to a tool by name when speaking with the user (NEVER say something like "I'm going to use \`tool_name\`"). Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc. Also do not refer to "pages" of results, just say you're getting more results. -- Some tools only work if the user has a workspace open.${chatMode === 'agent' ? ` -- NEVER modify a file outside the user's workspace(s) without permission from the user.` : ''}\ - + const toolXMLDefinitions = (`\ Available tools: -${availableToolsStr(tools)} -Tool calling details: ${''/* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */} -- Tool calling is optional. -- To call a tool, just write its name followed by any parameters in XML format. For example: - - -value1 - - -value2 - - -- You must write your tool call at the END of your response. The beginning of your response should be normal text, explanations, etc (if you decide to write anything), followed by the tool call at the END. -- You are only allowed to output one tool call per response. -- You may omit optional parameters. -- The tool call will be executed immediately, and you will have access to the results in your next response.` +${availableXMLToolsStr(tools)}`) + + const toolCallXMLGuidelines = (`\ +Tool calling details: +- Once you write a tool call, you must STOP and WAIT for the result. +- All parameters are REQUIRED unless noted otherwise. +- To call a tool, write its name and parameters in one of the XML formats specified above. +- You are only allowed to output ONE tool call, and it must be at the END of your response. +- Your tool call will be executed immediately, and the results will appear in the following user message.`) + + return `\ +${toolXMLDefinitions} + +${toolCallXMLGuidelines}` } -// - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. - // ======================================================== chat (normal, gather, agent) ======================================================== - -export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, directoryStr, chatMode: mode }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, runningTerminalIds: string[], chatMode: ChatMode }) => `\ -You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the user's IDE called Void. Your job is \ -${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to their codebase. You should ALWAYS bring user's task to completion to the fullest extent possible, calling tools to make all necessary changes.` - : mode === 'gather' ? `to search and understand the user's codebase. You MUST use tools to read files and help the user understand the codebase, even if you were initially given files.` - : mode === 'normal' ? `to assist the user with their coding tasks.` - : ''} -You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of files that the user has specifically selected, \`SELECTIONS\`. -Please assist the user with their query. The user's query is never invalid. - -${/* tool use */ mode === 'agent' || mode === 'gather' ? `\ -${systemToolsPrompt(mode)} -\ -`: `\ -You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it. -\ -`} -${/* code blocks */ mode === 'agent' ? `\ -Behavior: -- Always use tools (edit, terminal, etc) to take actions and implement changes. Don't just describe them. -- Prioritize taking as many steps as you need to complete your request over stopping early.\ -`: `\ -If you think it's appropriate to suggest an edit to a file, then you must describe your suggestion in CODE BLOCK(S) (wrapped in triple backticks). -- The first line of the code block must be the FULL PATH of the file you want to change. -- The remaining contents should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. -- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. -- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. -Here's an example of a good code block:\n${fileNameEdit}. - -If you write a code block that's related to a specific file, please use the same format as above: -- The first line of the code block must be the FULL PATH of the related file if known. -- The remaining contents of the file should proceed as usual. -\ -`} +export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, directoryStr, chatMode: mode }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, runningTerminalIds: string[], chatMode: ChatMode }) => { + const header = (`You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} whose job is \ +${mode === 'agent' ? `to help the user develop, run, and make changes to their codebase.` + : mode === 'gather' ? `to search, understand, and reference files in the user's codebase.` + : mode === 'normal' ? `to assist the user with their coding tasks.` + : ''} +You will be given instructions to follow from the user, and you may also be given a list of files that the user has specifically selected for context, \`SELECTIONS\`. +Please assist the user with their query.`) -${/* misc */''} -Misc: -- Do not make things up. -- Do not be lazy. -- NEVER re-write the entire file. -- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}. -- Today's date is ${new Date().toDateString()} -${/* system info */''} -The user's system information is as follows: + const sysInfo = (`Here is the user's system information: + - ${os} -- Open workspace(s): ${workspaceFolders.join(', ') || 'NO WORKSPACE OPEN'} -- Open tab(s): ${openedURIs.join(', ') || 'NO OPENED EDITORS'} -- Active tab: ${activeURI} -${(mode === 'agent') && runningTerminalIds.length !== 0 ? ` + +- Open workspaces: +${workspaceFolders.join('\n') || 'NO WORKSPACE OPEN'} + +- Active file: +${activeURI} + +- Open files: +${openedURIs.join('\n') || 'NO OPENED EDITORS'}${''/* separator */}${mode === 'agent' && runningTerminalIds.length !== 0 ? ` + - Existing terminal IDs: ${runningTerminalIds.join(', ')}` : ''} -- The user's codebase is structured as follows:\n${directoryStr} +`) -\ -`.trim().replace('\t', ' ') + const fsInfo = (`Here is an overview of the user's file system: + +${directoryStr} +`) + const toolDefinitions = systemToolsXMLPrompt(mode) + + const details: string[] = [] + + if (mode === 'agent' || mode === 'gather') { + details.push(`Only call tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools.`) + details.push('Only use ONE tool call at a time, and always wait for the result before proceeding.') // XML + details.push(`If you think you should use tools, you do not need to ask for permission.`) + details.push(`NEVER say something like "I'm going to use \`tool_name\`". Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc.`) + details.push(`Many tools only work if the user has a workspace open.`) + } + else { + details.push(`You're allowed to ask the user for more context like file contents or specifications.`) + } + + if (mode === 'agent') { + details.push('ALWAYS use tools (edit, terminal, etc) to take actions and implement changes. For example, if you would like to edit a file, you MUST use a tool.') + details.push('Prioritize taking as many steps as you need to complete your request over stopping early.') + details.push(`You will OFTEN need to gather context before making a change. Do not immediately make a change unless you have ALL relevant context.`) + details.push(`ALWAYS have maximal certainty in a change BEFORE you make it. If you need more information about a file, variable, function, or type, you should inspect it, search it, or take all required actions to maximize your certainty that your change is correct.`) + details.push(`NEVER modify a file outside the user's workspace(s) without permission from the user.`) + } + + if (mode === 'gather') { + details.push(`Your primary use of tools should be to gather information to help the user understand the codebase and answer their query.`) + details.push(`You should extensively read files, types, content, etc and gather relevant context.`) + } + + + if (mode === 'gather' || mode === 'normal') { + details.push(`If you write any code blocks, please use this format: +- The first line of the code block must be the FULL PATH of the related file if known (otherwise omit). +- The remaining contents of the file should proceed as usual.`) + + details.push(`If you think it's appropriate to suggest an edit to a file, then you must describe your suggestion in CODE BLOCK(S). +- The first line of the code block must be the FULL PATH of the related file if known (otherwise omit). +- The remaining contents should be \ +a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. \ +NEVER re-write the whole file. Instead, use comments like "// ... existing code ...". Bias towards writing as little as possible. \ +Here's an example of a good edit suggestion: +${fileNameEditExample}.`) + } + + details.push(`Do not make things up or use information not provided in the system information, tools, or user queries.`) + details.push(`Today's date is ${new Date().toDateString()}.`) + + const importantDetails = (`Important notes: +${details.map((d, i) => `${i + 1}. ${d}`).join('\n\n')}`) + + + // return answer + const ansStrs: string[] = [] + ansStrs.push(header) + ansStrs.push(sysInfo) + ansStrs.push(fsInfo) + if (toolDefinitions) ansStrs.push(toolDefinitions) + ansStrs.push(importantDetails) + ansStrs.push('Now, please assist the user with their query.') + + const fullSystemMsgStr = ansStrs + .join('\n\n\n') + .trim() + .replace('\t', ' ') + + return fullSystemMsgStr + +} + + +// log all prompts +for (const chatMode of ['agent', 'gather', 'normal'] satisfies ChatMode[]) { + console.log(`========================================= SYSTEM MESSAGE FOR ${chatMode} ===================================\n`, + chat_systemMessage({ chatMode, workspaceFolders: [], openedURIs: [], activeURI: 'pee', runningTerminalIds: [], directoryStr: 'lol', })) +} + export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null, opts: { type: 'references' } | { type: 'fullCode', voidModelService: IVoidModelService } @@ -458,8 +499,6 @@ export const voidPrefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullF const fullFileLines = fullFileStr.split('\n') - // we can optimize this later - const MAX_PREFIX_SUFFIX_CHARS = 20_000 /* a diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index de65b21d..26167aaf 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ToolName } from './prompt/prompts.js' +import { ToolName, ToolParamName } from './prompt/prompts.js' import { ChatMode, ModelSelection, ModelSelectionOptions, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -40,13 +40,13 @@ export type LLMChatMessage = { } -export type ParsedToolParamsObj = { - [paramName: string]: string | undefined; +export type RawToolParamsObj = { + [paramName in ToolParamName]?: string; } export type RawToolCallObj = { name: ToolName; - rawParams: ParsedToolParamsObj; - doneParams: string[]; + rawParams: RawToolParamsObj; + doneParams: ToolParamName[]; isDone: boolean; }; diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index 383dab54..b1a402b5 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -24,7 +24,7 @@ export type ToolCallParams = { 'read_file': { uri: URI, startLine: number | null, endLine: number | null, pageNumber: number }, 'ls_dir': { rootURI: URI, pageNumber: number }, 'get_dir_structure': { rootURI: URI }, - 'search_pathnames_only': { queryStr: string, include: string | null, pageNumber: number }, + 'search_pathnames_only': { queryStr: string, searchInFolder: string | null, pageNumber: number }, 'search_files': { queryStr: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number }, // --- 'edit_file': { uri: URI, changeDescription: string }, diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index 829369b5..40a9a0ab 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' -import { availableTools, InternalToolInfo, ToolName } from '../../common/prompt/prompts.js' +import { availableTools, InternalToolInfo, ToolName, ToolParamName } from '../../common/prompt/prompts.js' import { OnFinalMessage, OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js' import { ChatMode } from '../../common/voidSettingsTypes.js' import { createSaxParser } from './sax.js' @@ -141,12 +141,12 @@ type ToolsState = { level: 'normal', } | { level: 'tool', - toolName: string, + toolName: ToolName, currentToolCall: RawToolCallObj, } | { level: 'param', - toolName: string, - paramName: string, + toolName: ToolName, + paramName: ToolParamName, currentToolCall: RawToolCallObj, } @@ -162,7 +162,7 @@ export const extractToolsWrapper = ( // detect , etc let fullText = ''; let trueFullText = '' - const currentToolCalls: RawToolCallObj[] = []; // the answer + const firstToolCallRef: { current: RawToolCallObj | undefined } = { current: undefined } let state: ToolsState = { level: 'normal' } @@ -170,7 +170,7 @@ export const extractToolsWrapper = ( const getRawNewText = () => { return trueFullText.substring(parser.startTagPosition, parser.position + 1) } - const parser = createSaxParser({ lowercase: true }) + const parser = createSaxParser() // when see open tag parser.onopentag = (node) => { @@ -183,9 +183,10 @@ export const extractToolsWrapper = ( if (tagName in toolOfToolName) { // valid toolName state = { level: 'tool', - toolName: tagName, + toolName: tagName as ToolName, currentToolCall: { name: tagName as ToolName, rawParams: {}, doneParams: [], isDone: false } } + firstToolCallRef.current = state.currentToolCall } else { fullText += rawNewText // count as plaintext @@ -198,7 +199,7 @@ export const extractToolsWrapper = ( state = { level: 'param', toolName: state.toolName, - paramName: tagName, + paramName: tagName as ToolParamName, currentToolCall: state.currentToolCall, } } @@ -229,7 +230,6 @@ export const extractToolsWrapper = ( else if (state.level === 'tool') { if (tagName === state.toolName) { // closed the tool state.currentToolCall.isDone = true - currentToolCalls.push(state.currentToolCall) state = { level: 'normal', } @@ -287,33 +287,31 @@ export const extractToolsWrapper = ( onText({ ...params, fullText, - toolCall: currentToolCalls.length > 0 ? currentToolCalls[0] : undefined + toolCall: firstToolCallRef.current, }); }; const newOnFinalMessage: OnFinalMessage = (params) => { // treat like just got text before calling onFinalMessage (or else we sometimes miss the final chunk that's new to finalMessage) - console.log('final message!!!', trueFullText) - console.log('----- returning ----\n', fullText) - console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) newOnText({ ...params }) - console.log('final message!!!', trueFullText) - console.log('----- returning ----\n', fullText) - console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2)) - fullText = fullText.trimEnd() - const toolCall = currentToolCalls.length > 0 ? currentToolCalls[0] : undefined + const toolCall = firstToolCallRef.current if (toolCall) { // trim off all whitespace at and before first \n and after last \n for each param - for (const paramName in toolCall.rawParams) { + for (const p in toolCall.rawParams) { + const paramName = p as ToolParamName const orig = toolCall.rawParams[paramName] if (orig === undefined) continue toolCall.rawParams[paramName] = trimBeforeAndAfterNewLines(orig) } } - console.log('----- toolCall ----\n', JSON.stringify(toolCall, null, 2)) + + // console.log('final message!!!', trueFullText) + // console.log('----- returning ----\n', fullText) + // console.log('----- tools ----\n', JSON.stringify(firstToolCallRef.current, null, 2)) + // console.log('----- toolCall ----\n', JSON.stringify(toolCall, null, 2)) onFinalMessage({ ...params, fullText, toolCall: toolCall }) } From 8c26fd208179af8ba4a6f5ce8b075ff7b14472da Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 8 Apr 2025 23:59:07 -0700 Subject: [PATCH 18/27] params --- .../void/browser/react/src/sidebar-tsx/SidebarChat.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index cce69b89..1a26e5c1 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -1334,7 +1334,7 @@ const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr }: { applyBoxId: strin -const InvalidTool = ({ toolName }: { toolName: ToolName }) => { +const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: string }) => { const accessor = useAccessor() const title = getTitle({ name: toolName, type: 'invalid_params' }) const desc1 = 'Invalid parameters' @@ -1342,7 +1342,11 @@ const InvalidTool = ({ toolName }: { toolName: ToolName }) => { const isError = true const componentParams: ToolHeaderParams = { title, desc1, isError, icon } - componentParams.children + componentParams.children = + + {message} + + return } @@ -1902,7 +1906,7 @@ const ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, mes if (chatMessage.type === 'invalid_params') { return
    - +
    } From 78671db5b8870d62e8c93347962a1117c462a09a Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Wed, 9 Apr 2025 02:45:34 -0700 Subject: [PATCH 19/27] checkpoint state improvements --- .../contrib/void/browser/chatThreadService.ts | 22 ++++++++++++++----- .../void/browser/react/src/util/inputs.tsx | 1 + .../contrib/void/common/voidModelService.ts | 10 +++++++++ .../llmMessage/extractGrammar.ts | 1 + 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index f363adda..f6859f79 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -358,9 +358,9 @@ class ChatThreadService extends Disposable implements IChatThreadService { else if (behavior === 'set') { this.streamState[threadId] = state } + else throw new Error(`setStreamState`) } - this._onDidChangeStreamState.fire({ threadId }) } @@ -488,7 +488,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { const displayContentSoFar = this.streamState[threadId]?.displayContentSoFar ?? '' const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? '' const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar - console.log('toolInProgress', toolCallSoFar) const llmCancelToken = this.streamState[threadId]?.streamingToken if (llmCancelToken !== undefined) { this._llmMessageService.abort(llmCancelToken) } @@ -498,6 +497,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (toolCallSoFar) { this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) } + + this._addUserCheckpoint({ threadId }) } this._setStreamState(threadId, {}, 'set') @@ -1089,14 +1090,21 @@ We only need to do it for files that were edited since `from`, ie files between if (!thread) return // should never happen + const llmCancelToken = this.streamState[threadId]?.streamingToken // currently streaming LLM on this thread + if (llmCancelToken === undefined && this.streamState[threadId]?.isRunning === 'LLM') { + // if about to call the other LLM, just wait for it by stopping right now + return + } + // stop it (this simply resolves the promise to free up space) + if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) + + + // add dummy before this message to keep checkpoint before user message idea consistent if (thread.messages.length === 0) { this._addUserCheckpoint({ threadId }) } - // if the current thread is already streaming, stop it (this simply resolves the promise to free up space) - const llmCancelToken = this.streamState[threadId]?.streamingToken - if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) const { chatMode } = this._settingsService.state.globalSettings @@ -1488,6 +1496,10 @@ We only need to do it for files that were edited since `from`, ie files between } } }, true) + + // when change focused message idx, jump + if (messageIdx !== undefined) + this.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true }) } // set message.state diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index 8e82d750..d2417f44 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -106,6 +106,7 @@ export const VoidInputBox2 = forwardRef(fun return (