Merge pull request #389 from voideditor/model-selection

Misc 1.0.3 canary improvements - tool call fix, tooltips, lint error passing
This commit is contained in:
Andrew Pareles 2025-04-11 03:34:29 -07:00 committed by GitHub
commit 1081b8879f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 484 additions and 452 deletions

View file

@ -52,7 +52,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart {
static readonly viewContainersWorkspaceStateKey = 'workbench.auxiliarybar.viewContainersWorkspaceState';
// Use the side bar dimensions
override readonly minimumWidth: number = 230; // Void changed this (was 170)
override readonly minimumWidth: number = 280; // Void changed this (was 170)
override readonly maximumWidth: number = Number.POSITIVE_INFINITY;
override readonly minimumHeight: number = 0;
override readonly maximumHeight: number = Number.POSITIVE_INFINITY;

View file

@ -15,7 +15,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
// to change this, just Cmd+Shift+F and replace DummyService with YourServiceName, and replace
// to change this, just Cmd+Shift+F and replace DummyService with YourServiceName, and create a unique ID below
export interface IDummyService {
readonly _serviceBrand: undefined; // services need this, just leave it undefined
}

View file

@ -661,7 +661,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
console.log('a', chatMode)
// clear any previous error
this._setStreamState(threadId, { error: undefined }, 'set')
@ -670,13 +669,11 @@ class ChatThreadService extends Disposable implements IChatThreadService {
let isRunningWhenEnd: IsRunningType = undefined
let aborted = false
console.log('b')
// before enter loop, call tool
if (callThisToolFirst) {
const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, { preapproved: true, validatedParams: callThisToolFirst.params })
if (interrupted) return
}
console.log('c')
// tool use loop
while (shouldSendAnotherMessage) {
@ -688,17 +685,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
console.log('d')
// send llm message
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
const systemMessage = await this._generateSystemMessage(chatMode)
console.log('e0')
const llmMessages = await this._generateLLMMessages(threadId)
const messages: LLMChatMessage[] = [
{ role: 'system', content: systemMessage },
...llmMessages
]
console.log('e')
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
@ -740,20 +734,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
break
}
this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message
console.log('waiting...')
const toolCall = await messageIsDonePromise // wait for message to complete
console.log('done!')
if (aborted) { return }
console.log('H')
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
console.log('I')
// call tool if there is one
const tool: RawToolCallObj | undefined = toolCall
if (tool) {
console.log('J')
const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, tool.name, { preapproved: false, unvalidatedToolParams: tool.rawParams })
console.log('K')
// 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
@ -768,17 +756,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
} // end while
console.log('L')
// if awaiting user approval, keep isRunning true, else end isRunning
this._setStreamState(threadId, { isRunning: isRunningWhenEnd }, 'merge')
console.log('M')
// add checkpoint before the next user message
if (!isRunningWhenEnd)
this._addUserCheckpoint({ threadId })
console.log('N')
// capture number of messages sent
this._metricsService.capture('Agent Loop Done', { nMessagesSent, chatMode })
@ -969,7 +954,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const [_, toIdx] = c
if (toIdx === fromIdx) return
console.log(`going from ${fromIdx} to ${toIdx}`)
// console.log(`going from ${fromIdx} to ${toIdx}`)
// update the user's checkpoint
this._addUserModificationsToCurrCheckpoint({ threadId })
@ -1064,6 +1049,7 @@ We only need to do it for files that were edited since `from`, ie files between
severity: error ? Severity.Warning : Severity.Info,
message: error ? `Error: ${error} ` : `A new Chat result is ready.`,
source: messageContent,
sticky: true,
actions: {
primary: [{
id: 'void.goToChat',
@ -1500,9 +1486,9 @@ 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 })
// // when change focused message idx, jump - do not jump back when click edit, too confusing.
// if (messageIdx !== undefined)
// this.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true })
}
// set message.state

View file

@ -14,18 +14,23 @@ import { MAX_CHILDREN_URIs_PAGE } from './toolsService.js';
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_FILES_TOTAL = 200
const MAX_FILES_TOTAL = 300;
const DEFAULT_MAX_DEPTH = 3;
const DEFAULT_MAX_ITEMS_PER_DIR = 3;
const START_MAX_DEPTH = Infinity;
const START_MAX_ITEMS_PER_DIR = Infinity; // Add start value as Infinity
export interface IDirectoryStrService {
readonly _serviceBrand: undefined;
getDirectoryStrTool(uri: URI): Promise<string>
getAllDirectoriesStr(opts: { cutOffMessage: string }): Promise<string>
getDirectoryStrTool(uri: URI, options?: { maxItemsPerDir?: number }): Promise<string>
getAllDirectoriesStr(opts: { cutOffMessage: string, maxItemsPerDir?: number }): Promise<string>
}
export const IDirectoryStrService = createDecorator<IDirectoryStrService>('voidDirectoryStrService');
@ -53,11 +58,17 @@ const shouldExcludeDirectory = (item: ExplorerItem) => {
item.name === 'obj' ||
item.name === 'vendor' ||
item.name === 'logs' ||
item.name === 'cache'
item.name === 'cache' ||
item.name === 'resource' ||
item.name === 'resources'
) {
return true;
}
if (item.name.match(/\bout\b/)) return true
if (item.name.match(/\bbuild\b/)) return true
return false;
}
@ -128,137 +139,175 @@ export const stringifyDirectoryTree1Deep = (params: ToolCallParams['ls_dir'], re
// ---------- IN GENERAL ----------
// if the filter exists use it to filter out files and folders when creating the tree
const computeDirectoryTree = async (
// Remove the old computeDirectoryTree function and replace with a combined version that handles both computation and rendering
const computeAndStringifyDirectoryTree = async (
eItem: ExplorerItem,
explorerService: IExplorerService
): Promise<VoidDirectoryItem> => {
// Fetch children with default sort order
console.log('11111!!!!')
const eChildren = await eItem.fetchChildren(SortOrder.FilesFirst);
console.log('222222!!!!')
const isGitIgnoredDirectory = eItem.isDirectory && shouldExcludeDirectory(eItem)
// Process children recursively
const children = !isGitIgnoredDirectory ? await Promise.all(
eChildren.map(async c => await computeDirectoryTree(c, explorerService))
) : null
console.log('333333!!!!!')
// Create our directory item
const item: VoidDirectoryItem = {
uri: eItem.resource,
name: eItem.name,
isDirectory: eItem.isDirectory,
isSymbolicLink: eItem.isSymbolicLink,
children,
isGitIgnoredDirectory: isGitIgnoredDirectory && { numChildren: eItem.children.size },
};
return item;
};
const stringifyDirectoryTree = (
node: VoidDirectoryItem,
explorerService: IExplorerService,
MAX_CHARS: number,
): { content: string, wasCutOff: boolean } => {
let content = '';
let wasCutOff = false;
fileCount: { count: number } = { count: 0 },
options: { maxDepth?: number, currentDepth?: number, maxItemsPerDir?: number } = {}
): Promise<{ content: string, wasCutOff: boolean }> => {
// Set default values for options
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const currentDepth = options.currentDepth ?? 0;
const maxItemsPerDir = options.maxItemsPerDir ?? DEFAULT_MAX_ITEMS_PER_DIR;
// Check if we've reached the max depth
if (currentDepth > maxDepth) {
return { content: '', wasCutOff: true };
}
// Check if we've reached the file limit
if (fileCount.count >= MAX_FILES_TOTAL) {
return { content: '', wasCutOff: true };
}
// If we're already exceeding the max characters, return immediately
if (MAX_CHARS <= 0) {
return { content, wasCutOff: true };
return { content: '', wasCutOff: true };
}
// Increment file count
fileCount.count++;
// Add the root node first (without tree characters)
const nodeLine = `${node.name}${node.isDirectory ? '/' : ''}${node.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
const nodeLine = `${eItem.name}${eItem.isDirectory ? '/' : ''}${eItem.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
if (nodeLine.length > MAX_CHARS) {
return { content: '', wasCutOff: true };
}
content += nodeLine;
let content = nodeLine;
let wasCutOff = false;
let remainingChars = MAX_CHARS - nodeLine.length;
// Then recursively add all children with proper tree formatting
if (node.children && node.children.length > 0) {
const { childrenContent, childrenCutOff } = renderChildren(
node.children,
remainingChars,
''
);
content += childrenContent;
wasCutOff = childrenCutOff;
// Check if it's a directory we should skip
const isGitIgnoredDirectory = eItem.isDirectory && shouldExcludeDirectory(eItem);
// Fetch and process children if not a filtered directory
if (eItem.isDirectory && !isGitIgnoredDirectory) {
// Fetch children with Modified sort order to show recently modified first
const eChildren = await eItem.fetchChildren(SortOrder.Modified);
// Then recursively add all children with proper tree formatting
if (eChildren && eChildren.length > 0) {
const { childrenContent, childrenCutOff } = await renderChildrenCombined(
eChildren,
remainingChars,
'',
explorerService,
fileCount,
{ maxDepth, currentDepth, maxItemsPerDir } // Pass maxItemsPerDir to the render function
);
content += childrenContent;
wasCutOff = childrenCutOff;
}
}
return { content, wasCutOff };
};
// Helper function to render children with proper tree formatting
const renderChildren = (
children: VoidDirectoryItem[],
const renderChildrenCombined = async (
children: ExplorerItem[],
maxChars: number,
parentPrefix: string
): { childrenContent: string, childrenCutOff: boolean } => {
parentPrefix: string,
explorerService: IExplorerService,
fileCount: { count: number },
options: { maxDepth: number, currentDepth: number, maxItemsPerDir?: number }
): Promise<{ childrenContent: string, childrenCutOff: boolean }> => {
const { maxDepth, currentDepth } = options; // Remove maxItemsPerDir from destructuring
// Get maxItemsPerDir separately and make sure we use it
// For first level (currentDepth = 0), always use Infinity regardless of what was passed
const maxItemsPerDir = currentDepth === 0 ?
Infinity :
(options.maxItemsPerDir ?? DEFAULT_MAX_ITEMS_PER_DIR);
const nextDepth = currentDepth + 1;
let childrenContent = '';
let childrenCutOff = false;
let remainingChars = maxChars;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const isLast = i === children.length - 1;
// Check if we've reached max depth
if (nextDepth > maxDepth) {
return { childrenContent: '', childrenCutOff: true };
}
console.log('child!!!!', child.uri.fsPath)
// Apply maxItemsPerDir limit - only process the specified number of items
const itemsToProcess = maxItemsPerDir === Infinity ? children : children.slice(0, maxItemsPerDir);
const hasMoreItems = children.length > itemsToProcess.length;
for (let i = 0; i < itemsToProcess.length; i++) {
// Check if we've reached the file limit
if (fileCount.count >= MAX_FILES_TOTAL) {
childrenCutOff = true;
break;
}
const child = itemsToProcess[i];
const isLast = (i === itemsToProcess.length - 1) && !hasMoreItems;
// Create the tree branch symbols
const branchSymbol = isLast ? '└── ' : '├── ';
const childLine = `${parentPrefix}${branchSymbol}${child.name}${child.isDirectory ? '/' : ''}${child.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
// Check if adding this line would exceed the limit
if (childrenContent.length + childLine.length > maxChars) {
if (childLine.length > remainingChars) {
childrenCutOff = true;
break;
}
childrenContent += childLine;
remainingChars -= childLine.length;
fileCount.count++;
const nextLevelPrefix = parentPrefix + (isLast ? ' ' : '│ ');
// if gitignored, just say the number of children
if (child.isDirectory && child.isGitIgnoredDirectory && child.isGitIgnoredDirectory.numChildren > 0) {
childrenContent += `${nextLevelPrefix}└── ... (${child.isGitIgnoredDirectory.numChildren} children) ...\n`
}
// Skip processing children for git ignored directories
const isGitIgnoredDirectory = child.isDirectory && shouldExcludeDirectory(child);
// Create the prefix for the next level (continuation line or space)
else if (child.children && child.children.length > 0) {
if (child.isDirectory && !isGitIgnoredDirectory) {
// Fetch children with Modified sort order to show recently modified first
const eChildren = await child.fetchChildren(SortOrder.Modified);
const {
childrenContent: grandChildrenContent,
childrenCutOff: grandChildrenCutOff
} = renderChildren(
child.children,
maxChars,
nextLevelPrefix
);
if (eChildren && eChildren.length > 0) {
const {
childrenContent: grandChildrenContent,
childrenCutOff: grandChildrenCutOff
} = await renderChildrenCombined(
eChildren,
remainingChars,
nextLevelPrefix,
explorerService,
fileCount,
{ maxDepth, currentDepth: nextDepth, maxItemsPerDir }
);
// If adding grandchildren content would exceed the limit
if (childrenContent.length + grandChildrenContent.length > maxChars) {
childrenCutOff = true;
break;
}
if (grandChildrenContent.length > 0) {
childrenContent += grandChildrenContent;
remainingChars -= grandChildrenContent.length;
}
childrenContent += grandChildrenContent;
if (grandChildrenCutOff) {
childrenCutOff = true;
break;
if (grandChildrenCutOff) {
childrenCutOff = true;
}
}
}
}
// Add a message if we truncated the items due to maxItemsPerDir
if (hasMoreItems) {
const remainingCount = children.length - itemsToProcess.length;
const truncatedLine = `${parentPrefix}└── (${remainingCount} more items not shown...)\n`;
if (truncatedLine.length <= remainingChars) {
childrenContent += truncatedLine;
remainingChars -= truncatedLine.length;
}
childrenCutOff = true;
}
return { childrenContent, childrenCutOff };
};
@ -276,12 +325,37 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
super();
}
async getDirectoryStrTool(uri: URI) {
async getDirectoryStrTool(uri: URI, options?: { maxItemsPerDir?: number }) {
const eRoot = this.explorerService.findClosest(uri)
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_DIRSTR_CHARS_TOTAL_TOOL);
const maxItemsPerDir = options?.maxItemsPerDir ?? START_MAX_ITEMS_PER_DIR; // Use START_MAX_ITEMS_PER_DIR
// First try with START_MAX_DEPTH
const { content: initialContent, wasCutOff: initialCutOff } = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
MAX_DIRSTR_CHARS_TOTAL_TOOL,
{ count: 0 },
{ maxDepth: START_MAX_DEPTH, currentDepth: 0, maxItemsPerDir }
);
// If cut off, try again with DEFAULT_MAX_DEPTH and DEFAULT_MAX_ITEMS_PER_DIR
let content, wasCutOff;
if (initialCutOff) {
const result = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
MAX_DIRSTR_CHARS_TOTAL_TOOL,
{ count: 0 },
{ maxDepth: DEFAULT_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: DEFAULT_MAX_ITEMS_PER_DIR }
);
content = result.content;
wasCutOff = result.wasCutOff;
} else {
content = initialContent;
wasCutOff = initialCutOff;
}
let c = content.substring(0, MAX_DIRSTR_CHARS_TOTAL_TOOL)
c = `Directory of ${uri.fsPath}:\n${content}`
@ -290,13 +364,16 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
return c
}
async getAllDirectoriesStr({ cutOffMessage }: { cutOffMessage: string }) {
async getAllDirectoriesStr({ cutOffMessage, maxItemsPerDir }: { cutOffMessage: string, maxItemsPerDir?: number }) {
let str: string = '';
let cutOff = false;
const folders = this.workspaceContextService.getWorkspace().folders;
if (folders.length === 0)
return '(NO WORKSPACE OPEN)';
// Use START_MAX_ITEMS_PER_DIR if not specified
const startMaxItemsPerDir = maxItemsPerDir ?? START_MAX_ITEMS_PER_DIR;
for (let i = 0; i < folders.length; i += 1) {
if (i > 0) str += '\n';
@ -308,19 +385,44 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
const eRoot = this.explorerService.findClosestRoot(rootURI);
if (!eRoot) continue;
// Use our new approach with direct explorer service
const dirTree = await computeDirectoryTree(eRoot, this.explorerService);
const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length);
// First try with START_MAX_DEPTH and startMaxItemsPerDir
const { content: initialContent, wasCutOff: initialCutOff } = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length,
{ count: 0 },
{ maxDepth: START_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: startMaxItemsPerDir }
);
// If cut off, try again with DEFAULT_MAX_DEPTH and DEFAULT_MAX_ITEMS_PER_DIR
let content, wasCutOff;
if (initialCutOff) {
const result = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length,
{ count: 0 },
{ maxDepth: DEFAULT_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: DEFAULT_MAX_ITEMS_PER_DIR }
);
content = result.content;
wasCutOff = result.wasCutOff;
} else {
content = initialContent;
wasCutOff = initialCutOff;
}
str += content;
if (wasCutOff) {
cutOff = true;
break;
}
}
console.log('cutoff!!!!!!!', str, cutOffMessage)
if (cutOff) {
return `${str}\n${cutOffMessage}`
}
return str
}
}

View file

@ -22,12 +22,13 @@ import { WarningBox } from '../void-settings-tsx/WarningBox.js';
import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../common/modelCapabilities.js';
import { AlertTriangle, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X } from 'lucide-react';
import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js';
import { ToolCallParams, ToolNameWithApproval } from '../../../../common/toolsServiceTypes.js';
import { LintErrorItem, ToolCallParams, ToolNameWithApproval } from '../../../../common/toolsServiceTypes.js';
import { ApplyButtonsHTML, CopyButton, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyButtonState } from '../markdown/ApplyBlockHoverButtons.js';
import { IsRunningType } from '../../../chatThreadService.js';
import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js';
import { PlacesType } from 'react-tooltip';
import { ToolName, toolNames } from '../../../../common/prompt/prompts.js';
import { error } from 'console';
@ -671,6 +672,7 @@ const ToolHeaderWrapper = ({
numResults,
hasNextPage,
children,
bottomChildren,
isError,
onClick,
isOpen,
@ -733,6 +735,7 @@ const ToolHeaderWrapper = ({
{children}
</div>}
</div>
{bottomChildren}
</div>);
};
@ -1338,6 +1341,28 @@ const EditToolChildren = ({ uri, changeDescription }: { uri: URI, changeDescript
</div>
}
const EditToolLintErrors = ({ lintErrors }: { lintErrors: LintErrorItem[] }) => {
if (lintErrors.length === 0) return null;
return (
<div className="w-full px-2">
<div className="w-full border-l border-r border-b border-void-border-2 rounded bg-void-bg-3 overflow-hidden">
<div className="text-xs text-void-fg-4 opacity-80 border-l-2 border-void-warning px-2 py-0.5 flex flex-col gap-0.5 overflow-x-auto whitespace-nowrap">
{lintErrors.map((error, i) => (
<div key={i}>Lines {error.startLineNumber}-{error.endLineNumber}: {error.message}</div>
))}
</div>
</div>
</div>
)
}
const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr }: { applyBoxId: string, uri: URI, codeStr: string }) => {
const { currStreamState } = useApplyButtonState({ applyBoxId, uri })
return <div className='flex items-center gap-1'>
@ -1726,7 +1751,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
if (toolMessage.type !== 'tool_error') {
const { params, result } = toolMessage
// componentParams.bottomChildren = <EditToolLintErrors lintErrors={result?.lintErrors || []} />
componentParams.bottomChildren = <EditToolLintErrors lintErrors={result?.lintErrors || []} />
componentParams.children = <ToolChildrenWrapper className='bg-void-bg-3'>
<EditToolChildren

View file

@ -167,15 +167,13 @@ const AddModelInputBox = ({ providerName: permanentProviderName, className, comp
const [isOpen, setIsOpen] = useState(false)
// const providerNameRef = useRef<ProviderName | null>(null)
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName | null>(null)
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName>('anthropic')
const providerName = permanentProviderName ?? userChosenProviderName;
const [modelName, setModelName] = useState<string>('')
const [errorString, setErrorString] = useState('')
if (!providerName) { return null; }
const numModels = settingsState.settingsOfProvider[providerName].models.length
if (!isOpen) {
@ -206,9 +204,8 @@ const AddModelInputBox = ({ providerName: permanentProviderName, className, comp
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionsEqual={(a, b) => a === b}
className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root
py-[4px] px-[6px]
`}
// className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root py-[4px] px-[6px]`}
className={`max-w-32 mx-2 w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1 py-1 px-2 rounded`}
arrowTouchesText={false}
/>
}
@ -219,7 +216,7 @@ const AddModelInputBox = ({ providerName: permanentProviderName, className, comp
onChangeValue={setModelName}
placeholder='Model Name'
compact={compact}
className={'max-w-44'}
className={'max-w-32'}
/>
{/* add button */}
@ -299,7 +296,14 @@ export const ModelDump = () => {
<span className='w-fit truncate'>{modelName}</span>
</div>
{/* right part is anything that fits */}
<div className='flex items-center gap-4'>
<div className='flex items-center gap-4'
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content={disabled? `${displayInfoOfProviderName(providerName).title} is disabled`
: (isHidden ? `'${modelName}' won't appear in dropdowns` : ``)
}
>
<span className='opacity-50 truncate'>{isAutodetected ? '(detected locally)' : isDefault ? '' : '(custom model)'}</span>
<VoidSwitch
@ -533,7 +537,7 @@ export const FeaturesTab = () => {
<h2 className={`text-3xl mb-2`}>Models</h2>
<ErrorBoundary>
<ModelDump />
<AddModelInputBox />
<AddModelInputBox className='my-4' compact />
<AutoDetectLocalModelsToggle />
<RefreshableModels />
</ErrorBoundary>
@ -641,6 +645,16 @@ export const FeaturesTab = () => {
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.autoApprove ? 'Auto-approve' : 'Auto-approve'}</span>
</div>
{/* Tool Lint Errors Switch */}
<div className='flex items-center gap-x-2 my-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.includeToolLintErrors}
onChange={(newVal) => voidSettingsService.setGlobalSetting('includeToolLintErrors', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.includeToolLintErrors ? 'Include after-edit lint errors' : `Don't include lint errors`}</span>
</div>
</div>
</div>
@ -890,7 +904,7 @@ const GeneralTab = () => {
return <>
<div className=''>
<h2 className={`text-3xl mb-2`}>One-Click Switch</h2>
<h4 className={`text-void-fg-3 mb-2`}>{`Transfer your settings from another editor to Void in one click.`}</h4>
<h4 className={`text-void-fg-3 mb-4`}>{`Transfer your settings from another editor to Void in one click.`}</h4>
<div className='flex flex-col gap-4'>
<OneClickSwitchButton className='w-48' fromEditor="VS Code" />
@ -903,7 +917,7 @@ const GeneralTab = () => {
<div className='mt-12'>
<h2 className={`text-3xl mb-2`}>Built-in Settings</h2>
<h4 className={`text-void-fg-3 mb-2`}>{`IDE settings, keyboard settings, and theme customization.`}</h4>
<h4 className={`text-void-fg-3 mb-4`}>{`IDE settings, keyboard settings, and theme customization.`}</h4>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.openSettings') }}>
@ -930,7 +944,7 @@ const GeneralTab = () => {
<div className='mt-12 max-w-[600px]'>
<h2 className={`text-3xl mb-2`}>AI Instructions</h2>
<h4 className={`text-void-fg-3 mb-2`}>{`Instructions to include on all AI requests.`}</h4>
<h4 className={`text-void-fg-3 mb-4`}>{`Instructions to include on all AI requests.`}</h4>
<AIInstructionsBox />
</div>
@ -946,7 +960,7 @@ export const Settings = () => {
const [tab, setTab] = useState<TabName>('models')
const deleteme = false
const deleteme = true
if (deleteme) {
return <div className={`@@void-scope ${isDark ? 'dark' : ''}`} style={{ width: '100%', height: '100%' }}>
<VoidOnboarding />
@ -1103,7 +1117,7 @@ const PreviousButton = ({ onClick, ...props }: { onClick: () => void } & React.B
}
const ollamaSetupInstructions = <div className='prose-p:my-0 prose-p:py-0 prose-ol:my-0 prose-ol:py-0 prose-span:my-0 prose-span:py-0 text-void-fg-3 text-sm font-light list-decimal select-text opacity-80'>
const ollamaSetupInstructions = <div className='prose-p:my-0 prose-ol:list-decimal prose-p:py-0 prose-ol:my-0 prose-ol:py-0 prose-span:my-0 prose-span:py-0 text-void-fg-3 text-sm list-decimal select-text'>
<div className=''><ChatMarkdownRender string={`Ollama Setup Instructions`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`2. Open your terminal.`} chatMessageLocation={undefined} /></div>
@ -1440,38 +1454,44 @@ const VoidOnboarding = () => {
<FadeIn>
<div className="text-3xl font-medium mb-6 mt-8 text-center">AI Preferences</div>
<div className="text-5xl font-light mb-6 mt-12 text-center">AI Preferences</div>
<div className="flex flex-col items-center w-full mx-auto">
<div className="text-base text-void-fg-2 mb-8 text-center">What are you looking for in an AI model?</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 w-full md:max-w-[75%] max-w-[90%]">
<div className="flex md:flex-nowrap gap-4 w-full md:max-w-[80%] max-w-[90%]">
<div
onClick={() => { setWantToUseOption('smart'); setPageIndex(pageIndex + 1); }}
className="flex flex-col items-center justify-center p-6 rounded-md transition-all duration-300 cursor-pointer md:aspect-[8/7] border-void-border-1 border bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent hover:from-[#0e70c0]/25 hover:via-[#0e70c0]/10 hover:to-[#0e70c0]/5 dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 dark:hover:from-[#0e70c0]/30 dark:hover:via-[#0e70c0]/15 dark:hover:to-[#0e70c0]/5"
className="flex flex-col items-center w-full justify-center p-6 rounded-md cursor-pointer md:aspect-[8/7] border-void-border-1 border relative overflow-hidden group"
>
<span className="text-5xl mb-4">🧠</span>
<h3 className="text-xl font-medium mb-3">Intelligence</h3>
<p className="text-center text-sm text-void-fg-2">{basicDescOfWantToUseOption['smart']}</p>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-100"></div>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/25 via-[#0e70c0]/10 to-[#0e70c0]/5 dark:from-[#0e70c0]/30 dark:via-[#0e70c0]/15 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100"></div>
<span className="text-5xl mb-4 relative z-10">🧠</span>
<h3 className="text-xl font-medium mb-3 relative z-10">Intelligence</h3>
<p className="text-center text-root text-void-fg-2 relative z-10">{basicDescOfWantToUseOption['smart']}</p>
</div>
<div
onClick={() => { setWantToUseOption('private'); setPageIndex(pageIndex + 1); }}
className="flex flex-col items-center justify-center p-6 rounded-md transition-all duration-300 cursor-pointer md:aspect-[8/7] border-void-border-1 border bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent hover:from-[#0e70c0]/25 hover:via-[#0e70c0]/10 hover:to-[#0e70c0]/5 dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 dark:hover:from-[#0e70c0]/30 dark:hover:via-[#0e70c0]/15 dark:hover:to-[#0e70c0]/5"
className="flex flex-col items-center w-full justify-center p-6 rounded-md cursor-pointer md:aspect-[8/7] border-void-border-1 border relative overflow-hidden group"
>
<span className="text-5xl mb-4">🔒</span>
<h3 className="text-xl font-medium mb-3">Privacy</h3>
<p className="text-center text-sm text-void-fg-2">{basicDescOfWantToUseOption['private']}</p>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-100"></div>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/25 via-[#0e70c0]/10 to-[#0e70c0]/5 dark:from-[#0e70c0]/30 dark:via-[#0e70c0]/15 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100"></div>
<span className="text-5xl mb-4 relative z-10">🔒</span>
<h3 className="text-xl font-medium mb-3 relative z-10">Privacy</h3>
<p className="text-center text-sm text-void-fg-2 relative z-10">{basicDescOfWantToUseOption['private']}</p>
</div>
<div
onClick={() => { setWantToUseOption('cheap'); setPageIndex(pageIndex + 1); }}
className="flex flex-col items-center justify-center p-6 rounded-md transition-all duration-300 cursor-pointer md:aspect-[8/7] border-void-border-1 border bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent hover:from-[#0e70c0]/25 hover:via-[#0e70c0]/10 hover:to-[#0e70c0]/5 dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 dark:hover:from-[#0e70c0]/30 dark:hover:via-[#0e70c0]/15 dark:hover:to-[#0e70c0]/5"
className="flex flex-col items-center w-full justify-center p-6 rounded-md cursor-pointer md:aspect-[8/7] border-void-border-1 border relative overflow-hidden group"
>
<span className="text-5xl mb-4">💵</span>
<h3 className="text-xl font-medium mb-3">Low-Cost</h3>
<p className="text-center text-sm text-void-fg-2">{basicDescOfWantToUseOption['cheap']}</p>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/15 via-[#0e70c0]/5 to-transparent dark:from-[#0e70c0]/20 dark:via-[#0e70c0]/10 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-100"></div>
<div className="absolute inset-0 bg-gradient-to-br from-[#0e70c0]/25 via-[#0e70c0]/10 to-[#0e70c0]/5 dark:from-[#0e70c0]/30 dark:via-[#0e70c0]/15 dark:to-[#0e70c0]/5 transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100"></div>
<span className="text-5xl mb-4 relative z-10">💵</span>
<h3 className="text-xl font-medium mb-3 relative z-10">Low-Cost</h3>
<p className="text-center text-sm text-void-fg-2 relative z-10">{basicDescOfWantToUseOption['cheap']}</p>
</div>
</div>
</div>
@ -1630,11 +1650,11 @@ const VoidOnboarding = () => {
<FadeIn>
<div className="text-5xl font-light mb-6 mt-12">Settings and Themes</div>
<div className="text-center flex flex-col gap-4 w-full max-w-md mx-auto">
<h4 className="text-void-fg-3 mb-2">Transfer your settings from an existing editor?</h4>
<OneClickSwitchButton fromEditor="VS Code" />
<OneClickSwitchButton fromEditor="Cursor" />
<OneClickSwitchButton fromEditor="Windsurf" />
<div className="text-center flex flex-col items-center gap-4 w-full max-w-md mx-auto">
<h4 className="text-void-fg-3 mb-4">Transfer your settings from an existing editor?</h4>
<OneClickSwitchButton className='w-full' fromEditor="VS Code" />
<OneClickSwitchButton className='w-full' fromEditor="Cursor" />
<OneClickSwitchButton className='w-full' fromEditor="Windsurf" />
</div>
</FadeIn>
@ -1655,7 +1675,7 @@ const VoidOnboarding = () => {
}
return <div key={pageIndex} className="w-full h-full text-left mx-auto overflow-y-auto flex flex-col items-center justify-between">
return <div key={pageIndex} className="w-full h-full text-left mx-auto overflow-y-auto flex flex-col items-center justify-around">
{contentOfIdx[pageIndex]}
</div>
}

View file

@ -18,6 +18,7 @@ import { IMarkerService } from '../../../../platform/markers/common/markers.js'
import { timeout } from '../../../../base/common/async.js'
import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js'
import { ToolName } from '../common/prompt/prompts.js'
import { IVoidSettingsService } from '../common/voidSettingsService.js'
// tool use for AI
@ -151,6 +152,7 @@ export class ToolsService implements IToolsService {
@IVoidCommandBarService private readonly commandBarService: IVoidCommandBarService,
@IDirectoryStrService private readonly directoryStrService: IDirectoryStrService,
@IMarkerService private readonly markerService: IMarkerService,
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
@ -412,10 +414,13 @@ export class ToolsService implements IToolsService {
return `URI ${params.uri.fsPath} successfully deleted.`
},
edit_file: (params, result) => {
const lintErrsString = (
this.voidSettingsService.state.globalSettings.includeToolLintErrors ?
(result.lintErrors ? ` Lint errors found after change:\n${lintErrorsStr(result.lintErrors)}.\nIf this is related to a change made while calling this tool, you might want to fix the error.`
: ` No lint errors found.`)
: '')
const additionalStr = result.lintErrors ? `Lint errors found after change:\n${lintErrorsStr(result.lintErrors)}.\nIf this is related to a change made while calling this tool, you might want to fix the error.` : `No lint errors found.`
return `Change successfully made to ${params.uri.fsPath}.${additionalStr}`
return `Change successfully made to ${params.uri.fsPath}.${lintErrsString}`
},
run_terminal_command: (params, result) => {
const {

View file

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js'
class SurroundingsRemover {
export class SurroundingsRemover {
readonly originalS: string
i: number
j: number
@ -58,12 +58,13 @@ class SurroundingsRemover {
// return offset === suffix.length
// }
// either removes all or nothing
removeFromStartUntilFullMatch = (until: string, alsoRemoveUntilStr: boolean) => {
const index = this.originalS.indexOf(until, this.i)
if (index === -1) {
this.i = this.j + 1
return null
// this.i = this.j + 1
return false
}
// console.log('index', index, until.length)

View file

@ -16,6 +16,10 @@ export const tripleTick = ['```', '```']
export const MAX_DIRSTR_CHARS_TOTAL_BEGINNING = 20_000
export const MAX_DIRSTR_CHARS_TOTAL_TOOL = 20_000
export const MAX_DIRSTR_RESULTS_TOTAL_BEGINNING = 100
export const MAX_DIRSTR_RESULTS_TOTAL_TOOL = 100
export const MAX_PREFIX_SUFFIX_CHARS = 20_000
@ -300,8 +304,8 @@ ${directoryStr}
}
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.`)
details.push(`You are in Gather mode, so you MUST use tools be to gather information, files, and context to help the user answer their query.`)
details.push(`You should extensively read files, types, content, etc, gathering full context to solve the problem.`)
}

View file

@ -211,7 +211,15 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
async readAndInitializeState() {
const readS = await this._readState();
let readS: VoidSettingsState
try {
readS = await this._readState();
// 1.0.3 addition, remove when enough users have had this code run
if (readS.globalSettings.includeToolLintErrors === undefined) readS.globalSettings.includeToolLintErrors = true
}
catch (e) {
readS = defaultState()
}
// the stored data structure might be outdated, so we need to update it here
const finalState = readS

View file

@ -356,6 +356,7 @@ export type GlobalSettings = {
chatMode: ChatMode;
autoApprove: boolean;
showInlineSuggestions: boolean;
includeToolLintErrors: boolean;
}
export const defaultGlobalSettings: GlobalSettings = {
@ -367,6 +368,7 @@ export const defaultGlobalSettings: GlobalSettings = {
chatMode: 'agent',
autoApprove: false,
showInlineSuggestions: true,
includeToolLintErrors: true,
}
export type GlobalSettingName = keyof GlobalSettings

View file

@ -3,11 +3,10 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js'
import { endsWithAnyPrefixOf, SurroundingsRemover } from '../../common/helpers/extractCodeFromResult.js'
import { availableTools, InternalToolInfo, ToolName, ToolParamName } from '../../common/prompt/prompts.js'
import { OnFinalMessage, OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js'
import { OnFinalMessage, OnText, RawToolCallObj, RawToolParamsObj } from '../../common/sendLLMMessageTypes.js'
import { ChatMode } from '../../common/voidSettingsTypes.js'
import { createSaxParser } from './sax.js'
// =============== reasoning ===============
@ -137,17 +136,122 @@ export const extractReasoningWrapper = (
// =============== tools ===============
type ToolsState = {
level: 'normal',
} | {
level: 'tool',
toolName: ToolName,
currentToolCall: RawToolCallObj,
} | {
level: 'param',
toolName: ToolName,
paramName: ToolParamName,
currentToolCall: RawToolCallObj,
const findPartiallyWrittenToolTagAtEnd = (fullText: string, toolTags: string[]) => {
for (const toolTag of toolTags) {
const foundPrefix = endsWithAnyPrefixOf(fullText, toolTag)
if (foundPrefix) {
return [foundPrefix, toolTag] as const
}
}
return false
}
const findIndexOfAny = (fullText: string, matches: string[]) => {
for (const str of matches) {
const idx = fullText.indexOf(str);
if (idx !== -1) {
return [idx, str] as const
}
}
return null
}
type ToolOfToolName = { [toolName: string]: InternalToolInfo | undefined }
const parseXMLPrefixToToolCall = (toolName: ToolName, str: string, toolOfToolName: ToolOfToolName): RawToolCallObj => {
const paramsObj: RawToolParamsObj = {}
const doneParams: ToolParamName[] = []
let isDone = false
const getAnswer = (): RawToolCallObj => {
// trim off all whitespace at and before first \n and after last \n for each param
for (const p in paramsObj) {
const paramName = p as ToolParamName
const orig = paramsObj[paramName]
if (orig === undefined) continue
paramsObj[paramName] = trimBeforeAndAfterNewLines(orig)
}
// return tool call
const ans: RawToolCallObj = {
name: toolName,
rawParams: paramsObj,
doneParams: doneParams,
isDone: isDone
}
return ans
}
// find first toolName tag
const openToolTag = `<${toolName}>`
let i = str.indexOf(openToolTag)
if (i === -1) return getAnswer()
let j = str.lastIndexOf(`</${toolName}>`)
if (j === -1) j = Infinity
else isDone = true
str = str.substring(i + openToolTag.length, j)
const pm = new SurroundingsRemover(str)
const allowedParams = Object.keys(toolOfToolName[toolName]?.params ?? {}) as ToolParamName[]
if (allowedParams.length === 0) return getAnswer()
let latestMatchedOpenParam: null | ToolParamName = null
let n = 0
while (true) {
n += 1
if (n > 10) return getAnswer() // just for good measure as this code is early
// find the param name opening tag
let matchedOpenParam: null | ToolParamName = null
for (const paramName of allowedParams) {
const removed = pm.removeFromStartUntilFullMatch(`<${paramName}>`, true)
if (removed) {
matchedOpenParam = paramName
break
}
}
// if did not find a new param, stop
if (matchedOpenParam === null) {
if (latestMatchedOpenParam !== null) {
paramsObj[latestMatchedOpenParam] += pm.value()
}
return getAnswer()
}
else {
latestMatchedOpenParam = matchedOpenParam
}
paramsObj[latestMatchedOpenParam] = ''
// find the param name closing tag
let matchedCloseParam: boolean = false
let paramContents = ''
for (const paramName of allowedParams) {
const i = pm.i
const closeTag = `</${paramName}>`
const removed = pm.removeFromStartUntilFullMatch(closeTag, true)
if (removed) {
const i2 = pm.i
paramContents = pm.originalS.substring(i, i2 - closeTag.length)
matchedCloseParam = true
break
}
}
// if did not find a new close tag, stop
if (!matchedCloseParam) {
paramsObj[latestMatchedOpenParam] += pm.value()
return getAnswer()
}
else {
doneParams.push(latestMatchedOpenParam)
}
paramsObj[latestMatchedOpenParam] += paramContents
}
}
export const extractToolsWrapper = (
@ -156,125 +260,17 @@ export const extractToolsWrapper = (
const tools = availableTools(chatMode)
if (!tools) return { newOnText: onText, newOnFinalMessage: onFinalMessage }
const toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } = {}
const toolOfToolName: ToolOfToolName = {}
const toolOpenTags = tools.map(t => `<${t.name}>`)
for (const t of tools) { toolOfToolName[t.name] = t }
// detect <availableTools[0]></availableTools[0]>, etc
let fullText = '';
let trueFullText = ''
const firstToolCallRef: { current: RawToolCallObj | undefined } = { current: undefined }
let state: ToolsState = { level: 'normal' }
const getRawNewText = () => {
return trueFullText.substring(parser.startTagPosition, parser.position + 1)
}
const parser = createSaxParser()
// when see open tag <tagName>
parser.onopentag = (node) => {
const rawNewText = getRawNewText()
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 = {
level: 'tool',
toolName: tagName as ToolName,
currentToolCall: { name: tagName as ToolName, rawParams: {}, doneParams: [], isDone: false }
}
firstToolCallRef.current = state.currentToolCall
}
else {
fullText += rawNewText // count as plaintext
console.log('adding raw a', rawNewText)
}
}
else if (state.level === 'tool') {
if (tagName in (toolOfToolName[state.toolName]?.params ?? {})) { // valid param
state = {
level: 'param',
toolName: state.toolName,
paramName: tagName as ToolParamName,
currentToolCall: state.currentToolCall,
}
}
else {
// would normally be rawNewText, but we ignore all text inside tools
}
}
else if (state.level === 'param') { // cannot double nest
fullText += rawNewText // count as plaintext
console.log('adding raw b', rawNewText)
}
console.log('state1:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName })
};
parser.onclosetag = (tagName) => {
const rawNewText = getRawNewText()
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
state.currentToolCall.isDone = true
state = {
level: 'normal',
}
}
else { // add as text
fullText += rawNewText
console.log('adding raw B', rawNewText)
}
}
else if (state.level === 'param') {
if (tagName === state.paramName) { // closed the param
state.currentToolCall.doneParams.push(state.paramName)
state = {
level: 'tool',
toolName: state.toolName,
currentToolCall: state.currentToolCall,
}
}
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 latestToolCall: RawToolCallObj | undefined = undefined
let foundOpenTag: { idx: number, toolName: ToolName } | null = null
let openToolTagBuffer = '' // the characters we've seen so far that come after a < with no space afterwards, not yet added to fullText
let prevFullTextLen = 0
const newOnText: OnText = (params) => {
@ -282,13 +278,55 @@ export const extractToolsWrapper = (
prevFullTextLen = params.fullText.length
trueFullText = params.fullText
parser.write(newText)
// console.log('NEWTEXT', JSON.stringify(newText))
if (foundOpenTag === null) {
const newFullText = openToolTagBuffer + newText
// ensure the code below doesn't run if only half a tag has been written
const isPartial = findPartiallyWrittenToolTagAtEnd(newFullText, toolOpenTags)
if (isPartial) {
// console.log('--- partial!!!')
openToolTagBuffer += newText
}
// if no tooltag is partially written at the end, attempt to get the index
else {
// we will instantly retroactively remove this if it's a tag match
fullText += openToolTagBuffer
openToolTagBuffer = ''
fullText += newText
const i = findIndexOfAny(fullText, toolOpenTags)
if (i !== null) {
const [idx, toolTag] = i
const toolName = toolTag.substring(1, toolTag.length - 1) as ToolName
// console.log('found ', toolName)
foundOpenTag = { idx, toolName }
// do not count anything at or after i in fullText
fullText = fullText.substring(0, idx)
}
}
}
// toolTagIdx is not null, so parse the XML
if (foundOpenTag !== null) {
latestToolCall = parseXMLPrefixToToolCall(
foundOpenTag.toolName,
trueFullText.substring(foundOpenTag.idx, Infinity),
toolOfToolName,
)
}
// firstToolCallRef.current === state.currentToolCall is always true
onText({
...params,
fullText,
toolCall: firstToolCallRef.current,
toolCall: latestToolCall,
});
};
@ -298,16 +336,7 @@ export const extractToolsWrapper = (
newOnText({ ...params })
fullText = fullText.trimEnd()
const toolCall = firstToolCallRef.current
if (toolCall) {
// trim off all whitespace at and before first \n and after last \n for each param
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)
}
}
const toolCall = latestToolCall
// console.log('final message!!!', trueFullText)
// console.log('----- returning ----\n', fullText)
@ -321,7 +350,7 @@ export const extractToolsWrapper = (
// trim all whitespace up until the first newline, and all whitespace after the last newline
// trim all whitespace up until the first newline, and all whitespace up until the last newline
const trimBeforeAndAfterNewLines = (s: string) => {
if (!s) return s;

View file

@ -1,150 +0,0 @@
/*--------------------------------------------------------------------------------------
* 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 = 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 in the current buffer.
if (cursor < buffer.length && this.ontext) {
this.ontext(buffer.substring(cursor));
}
// All content is processed.
buffer = '';
cursor = buffer.length;
break;
}
// 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 starting from the found '<'.
const gtIndex = buffer.indexOf('>', ltIndex);
if (gtIndex === -1) {
// Incomplete tag detected.
incompleteTagFound = true;
// Save the starting point of the incomplete tag.
incompleteStart = 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 {
// 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 any whitespace).
const spaceIndex = tagContent.indexOf(' ');
let tagName =
spaceIndex !== -1
? tagContent.substring(0, spaceIndex).trim()
: tagContent;
if (options.lowercase && tagName) {
tagName = tagName.toLowerCase();
}
// Emit an open tag event.
if (this.onopentag) {
const node: SaxNode = { name: tagName, attributes: {} };
this.onopentag(node);
}
// If its a self-closing tag, immediately emit a close tag event.
if (selfClosing && this.onclosetag) {
this.onclosetag(tagName);
}
}
// Move the cursor past the current tag.
cursor = gtIndex + 1;
}
// 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;
}